diff --git a/.changeset/unify-ordered-window-semantics.md b/.changeset/unify-ordered-window-semantics.md new file mode 100644 index 000000000..b0294f658 --- /dev/null +++ b/.changeset/unify-ordered-window-semantics.md @@ -0,0 +1,6 @@ +--- +'@tanstack/db': patch +'@tanstack/db-ivm': patch +--- + +Use one total order and applied row provenance for lazy ordered windows so cursor pagination, live updates, and window replay preserve exact results. diff --git a/packages/db-ivm/src/utils.ts b/packages/db-ivm/src/utils.ts index 2c4170c89..d7569ba21 100644 --- a/packages/db-ivm/src/utils.ts +++ b/packages/db-ivm/src/utils.ts @@ -185,6 +185,14 @@ function range(start: number, end: number): Array { export function compareKeys(a: string | number, b: string | number): number { // Same type: compare directly if (typeof a === typeof b) { + if (typeof a === `number` && typeof b === `number`) { + const aIsNaN = Number.isNaN(a) + const bIsNaN = Number.isNaN(b) + if (aIsNaN || bIsNaN) { + if (aIsNaN && bIsNaN) return 0 + return aIsNaN ? 1 : -1 + } + } if (a < b) return -1 if (a > b) return 1 return 0 diff --git a/packages/db-ivm/tests/utils.test.ts b/packages/db-ivm/tests/utils.test.ts index 3e6b17f4c..064c23405 100644 --- a/packages/db-ivm/tests/utils.test.ts +++ b/packages/db-ivm/tests/utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Temporal } from 'temporal-polyfill' -import { DefaultMap, serializeValue } from '../src/utils.js' +import { DefaultMap, compareKeys, serializeValue } from '../src/utils.js' import { hash } from '../src/hashing/index.js' describe(`DefaultMap`, () => { @@ -30,6 +30,14 @@ describe(`DefaultMap`, () => { }) }) +describe(`compareKeys`, () => { + it(`orders finite numeric keys before NaN`, () => { + expect(compareKeys(1, Number.NaN)).toBeLessThan(0) + expect(compareKeys(Number.NaN, 1)).toBeGreaterThan(0) + expect(compareKeys(Number.NaN, Number.NaN)).toBe(0) + }) +}) + describe(`serializeValue`, () => { it(`preserves the established JSON form for ordinary keys`, () => { expect(serializeValue(`user1`)).toBe(`"user1"`) diff --git a/packages/db/package.json b/packages/db/package.json index a18427641..c9db40aff 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -21,7 +21,7 @@ "dev": "vite build --watch", "lint": "eslint . --fix", "test": "vitest --run", - "test:oracles": "vitest --run tests/collection-sync-reentrancy.test.ts tests/collection-subscription-replay-oracle.property.test.ts tests/query/coverage-registry-oracle.property.test.ts tests/query/load-subset-full-flow-oracle.property.test.ts tests/query/load-subset-projection-oracle.property.test.ts tests/query/includes-oracle.property.test.ts tests/query/includes-collection-oracle.property.test.ts tests/query/includes-cross-formulation-oracle.property.test.ts tests/query/includes-temporal-oracle.test.ts tests/query/includes-optimistic-oracle.property.test.ts tests/query/includes-publication-oracle.test.ts tests/query/includes-query-shape-oracle.test.ts tests/query/includes-work-counter-oracle.test.ts tests/query/includes-context-transport-oracle.test.ts" + "test:oracles": "vitest --run tests/collection-sync-reentrancy.test.ts tests/collection-subscription-replay-oracle.property.test.ts tests/query/coverage-registry-oracle.property.test.ts tests/query/load-subset-full-flow-oracle.property.test.ts tests/query/load-subset-projection-oracle.property.test.ts tests/query/includes-oracle.property.test.ts tests/query/includes-collection-oracle.property.test.ts tests/query/includes-cross-formulation-oracle.property.test.ts tests/query/includes-temporal-oracle.test.ts tests/query/includes-optimistic-oracle.property.test.ts tests/query/includes-publication-oracle.test.ts tests/query/includes-query-shape-oracle.test.ts tests/query/includes-work-counter-oracle.test.ts tests/query/includes-context-transport-oracle.test.ts tests/query/pagination-oracle.property.test.ts" }, "type": "module", "main": "dist/cjs/index.cjs", diff --git a/packages/db/src/collection/change-events.ts b/packages/db/src/collection/change-events.ts index e70e44903..d78f2f45a 100644 --- a/packages/db/src/collection/change-events.ts +++ b/packages/db/src/collection/change-events.ts @@ -11,8 +11,9 @@ import { optimizeExpressionWithIndexes, } from '../utils/index-optimization.js' import { ensureIndexForField } from '../indexes/auto-index.js' -import { makeComparator } from '../utils/comparison.js' +import { ReverseIndex } from '../indexes/reverse-index.js' import { buildCompareOptions } from '../query/compiler/order-by' +import { TotalOrder } from '../query/total-order.js' import type { ChangeMessage, CollectionLike, @@ -358,7 +359,30 @@ function getOrderedKeys( // Take the keys that match the filter and limit // if no limit is provided `index.keyCount` is used, // i.e. we will take all keys that match the filter - return index.takeFromStart(limit ?? index.keyCount, filterFn) + if (!(index instanceof ReverseIndex)) { + return index.takeFromStart(limit ?? index.keyCount, filterFn) + } + + // Reversing a value index also reverses keys inside an equal-value + // bucket, but query TotalOrder keeps its public-key tie-break ascending. + // Refine all matching indexed rows locally so a limit cannot cut the + // wrong side of a tied boundary. + const totalOrder = new TotalOrder(orderBy, collection) + const indexedEntries = index + .takeFromStart(index.keyCount, filterFn) + .flatMap((key) => { + const value = collection.get(key) + return value === undefined ? [] : [{ key, value }] + }) + indexedEntries.sort((left, right) => + totalOrder.compareEntries( + [left.key, left.value], + [right.key, right.value], + ), + ) + return indexedEntries + .slice(0, limit ?? indexedEntries.length) + .map(({ key }) => key) } } } @@ -375,24 +399,10 @@ function getOrderedKeys( } } - // Sort using makeComparator - const compare = (a: { key: TKey; value: T }, b: { key: TKey; value: T }) => { - for (const clause of orderBy) { - const compareFn = makeComparator(clause.compareOptions) - - // Extract values for comparison - const aValue = extractValueFromItem(a.value, clause.expression) - const bValue = extractValueFromItem(b.value, clause.expression) - - const result = compareFn(aValue, bValue) - if (result !== 0) { - return result - } - } - return 0 - } - - allItems.sort(compare) + const totalOrder = new TotalOrder(orderBy, collection) + allItems.sort((left, right) => + totalOrder.compareEntries([left.key, left.value], [right.key, right.value]), + ) const sortedKeys = allItems.map((item) => item.key) // Apply limit if provided @@ -403,23 +413,3 @@ function getOrderedKeys( // if no limit is provided, we will return all keys return sortedKeys } - -/** - * Helper function to extract a value from an item based on an expression - */ -function extractValueFromItem(item: any, expression: BasicExpression): any { - if (expression.type === `ref`) { - const propRef = expression - let value = item - for (const pathPart of propRef.path) { - value = value?.[pathPart] - } - return value - } else if (expression.type === `val`) { - return expression.value - } else { - // It must be a function - const evaluator = compileSingleRowExpression(expression) - return evaluator(item as Record) - } -} diff --git a/packages/db/src/collection/state.ts b/packages/db/src/collection/state.ts index 4904bb6b8..7f422cfc8 100644 --- a/packages/db/src/collection/state.ts +++ b/packages/db/src/collection/state.ts @@ -3,6 +3,11 @@ import { SortedMap } from '../SortedMap' import { enrichRowWithVirtualProps } from '../virtual-props.js' import { SyncTransactionAbortedError } from '../errors.js' import { createDeferred } from '../deferred' +import { + copySyncRequestProvenance, + getSyncRequestProvenance, + setSyncRequestProvenance, +} from '../load-subset-request-provenance.js' import { DIRECT_TRANSACTION_METADATA_KEY } from './transaction-metadata.js' import type { VirtualOrigin, @@ -42,6 +47,8 @@ interface PendingSyncedTransaction< deletes: Set } preserveHydrationSeedKeys?: boolean + /** Exact subset request whose commit produced this transaction. */ + requestSignal?: AbortSignal /** * When true, this transaction should be processed immediately even if there * are persisting user transactions. Used by manual write operations (writeInsert, @@ -339,13 +346,15 @@ export class CollectionStateManager< : this.enrichWithVirtualProps(change.previousValue, change.key) : undefined - return { + const enriched = { key: change.key, type: change.type, value: enrichedValue, previousValue: enrichedPreviousValue, metadata: change.metadata, } as ChangeMessage, TKey> + copySyncRequestProvenance(change, enriched) + return enriched } /** @@ -940,13 +949,50 @@ export class CollectionStateManager< const changedKeys = new Set() for (const transaction of committedSyncedTransactions) { for (const operation of transaction.operations) { - changedKeys.add(operation.key as TKey) + const key = operation.key as TKey + changedKeys.add(key) } for (const [key] of transaction.rowMetadataWrites) { changedKeys.add(key) } } + type AppliedRequestProvenance = { + version: { + value: TOutput | undefined + origin: VirtualOrigin | undefined + } + hasOrdinarySource: boolean + requestSignals: Set + } + const requestProvenanceByKey = new Map() + const provenanceForSignal = (signal: AbortSignal | undefined) => ({ + hasOrdinarySource: signal === undefined, + requestSignals: + signal === undefined + ? new Set() + : new Set([signal]), + }) + const recordRequestProvenance = ( + key: TKey, + signal: AbortSignal | undefined, + ) => { + const version = { + value: this.syncedData.get(key), + origin: this.rowOrigins.get(key), + } + const previous = requestProvenanceByKey.get(key) + if (previous !== undefined && deepEquals(previous.version, version)) { + if (signal === undefined) previous.hasOrdinarySource = true + else previous.requestSignals.add(signal) + return + } + requestProvenanceByKey.set(key, { + version, + ...provenanceForSignal(signal), + }) + } + const virtualSnapshotKeys = new Set(changedKeys) for (const key of this.pendingOptimisticDirectUpserts) { virtualSnapshotKeys.add(key) @@ -1013,7 +1059,16 @@ export class CollectionStateManager< truncateOptimisticSnapshot?.upserts.get(key) || this.syncedData.get(key) if (previousValue !== undefined) { - events.push({ type: `delete`, key, value: previousValue }) + const event: ChangeMessage = { + type: `delete`, + key, + value: previousValue, + } + setSyncRequestProvenance( + event, + provenanceForSignal(transaction.requestSignal), + ) + events.push(event) } } @@ -1105,6 +1160,7 @@ export class CollectionStateManager< this.pendingOptimisticDirectDeletes.delete(key) break } + recordRequestProvenance(key, transaction.requestSignal) if (!transaction.preserveHydrationSeedKeys) { this.hydrationSeedKeys.delete(key) this.hydratedKeys.delete(key) @@ -1114,9 +1170,9 @@ export class CollectionStateManager< for (const [key, metadataWrite] of transaction.rowMetadataWrites) { if (metadataWrite.type === `delete`) { this.syncedMetadata.delete(key) - continue + } else { + this.syncedMetadata.set(key, metadataWrite.value) } - this.syncedMetadata.set(key, metadataWrite.value) } for (const [ @@ -1226,6 +1282,7 @@ export class CollectionStateManager< this.isThisCollection(mutation.collection) && mutation.optimistic ) { + requestProvenanceByKey.delete(mutation.key) switch (mutation.type) { case `insert`: case `update`: @@ -1261,6 +1318,7 @@ export class CollectionStateManager< this.pendingOptimisticUpserts.delete(key) this.pendingLocalOrigins.delete(key) } + requestProvenanceByKey.delete(key) } for (const key of this.pendingOptimisticDirectDeletes) { if (!changedKeys.has(key)) { @@ -1268,6 +1326,7 @@ export class CollectionStateManager< } this.pendingOptimisticDeletes.delete(key) this.pendingLocalOrigins.delete(key) + requestProvenanceByKey.delete(key) } this.pendingOptimisticDirectUpserts.clear() this.pendingOptimisticDirectDeletes.clear() @@ -1379,6 +1438,17 @@ export class CollectionStateManager< } } + for (const event of events) { + if (getSyncRequestProvenance(event) !== undefined) continue + const provenance = requestProvenanceByKey.get(event.key) + if (provenance !== undefined) { + setSyncRequestProvenance(event, { + hasOrdinarySource: provenance.hasOrdinarySource, + requestSignals: new Set(provenance.requestSignals), + }) + } + } + // Update cached size after synced data changes this.size = this.calculateSize() diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index b6427bd0f..3678ab11b 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -1,17 +1,27 @@ import { ensureIndexForExpression } from '../indexes/auto-index.js' -import { and, eq, gte, lt } from '../query/builder/functions.js' -import { PropRef, Value } from '../query/ir.js' +import { and, gte, lt } from '../query/builder/functions.js' +import { 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 { + getSyncRequestProvenance, + isLoadSubsetRequestSignalFor, +} from '../load-subset-request-provenance.js' +import { + buildCursor, + buildCursorEquality, + canExpressCursorOrder, +} from '../utils/cursor.js' import { deepEquals } from '../utils.js' +import { WindowState } from '../query/live/window-state.js' import { createFilterFunctionFromExpression, createFilteredCallback, } from './change-events.js' import type { BasicExpression, OrderBy } from '../query/ir.js' +import type { TotalOrderBoundary } from '../query/total-order.js' import type { IndexInterface } from '../indexes/base-index.js' import type { + AppliedLoadSubsetOutcome, ChangeMessage, LoadSubsetOptions, LoadSubsetRequestResult, @@ -67,17 +77,31 @@ type CollectionSubscriptionOptions = { onLoadSubsetError?: (event: SubscriptionLoadSubsetErrorEvent) => void } -type TruncatePublicationState = { +type OrderedPublicationState = { + prefixSize: number + boundary: TotalOrderBoundary | undefined + /** Rows authorized to participate in this ordered publication. */ + candidateRows: Map +} + +type PublicationState = { loadedInitialState: boolean snapshotSent: boolean sentKeys: Set publishedRows: Map - limitedSnapshotRowCount: number - lastSentKey: string | number | undefined + ordered: OrderedPublicationState | undefined } +type OrderedAcquisitionState = Readonly<{ + requestedPrefix: number + hadBoundary: boolean + requiresUnboundedRefinement: boolean + revision: number +}> + type SubsetAcquisition = { options: LoadSubsetOptions + ordered?: OrderedAcquisitionState abortController?: AbortController removeRequestAbortListener?: () => void } @@ -86,24 +110,121 @@ type ReplaySubsetAcquisition = SubsetAcquisition & { abortController: AbortController } +type ReplayHandoffResult = + | { installed: true; failures?: ReadonlyArray } + | { installed: false; failures?: ReadonlyArray } + type SubsetDemand = SubsetAcquisition & { requestOptions: LoadSubsetOptions + onLoadSubsetResult?: ( + result: LoadSubsetRequestResult, + demand: LoadSubsetOptions, + ) => void pendingReplayAcquisitions: Set + /** Logical ownership; failed unloads may retain an inactive cleanup debt. */ + active: boolean + /** Prevent adapter cleanup from releasing the same acquisition reentrantly. */ + releaseInProgress: boolean releaseFailed: boolean releaseSettled: boolean } type TruncateReplayAttempt = { pending: Set<{ promise: Promise }> + pendingCallbacks: number failed: boolean setupComplete: boolean } type TruncateReplaySession = { - publicationState: TruncatePublicationState + publicationState: PublicationState buffer: Array>> attempts: Set currentAttempt: TruncateReplayAttempt + errors: Array +} + +type TruncateReplayContext = Readonly<{ + session: TruncateReplaySession + attempt: TruncateReplayAttempt +}> + +type SubsetFailureOccurrence = { + readonly error: unknown + readonly options: LoadSubsetOptions + readonly order: number + attributed: boolean + reported: boolean +} + +type SubsetFailureGroup = Readonly<{ + propagatedError: unknown + failures: ReadonlyArray +}> + +type ReplayResultCallbackFrame = { + replayContext: TruncateReplayContext + options: LoadSubsetOptions + previous: ReplayResultCallbackFrame | undefined + failureGroups: Array +} + +type SubsetCleanupBoundaryFrame = { + options: LoadSubsetOptions + previous: SubsetCleanupBoundaryFrame | undefined + failureGroups: Array +} + +type SubsetAcquisitionFrame = Readonly<{ + options: LoadSubsetOptions + previous: SubsetAcquisitionFrame | undefined + failureGroups: Array +}> + +type SubsetAcquisitionEntryResult = + | Readonly<{ completed: true; value: T }> + | Readonly<{ + completed: false + error: unknown + publicError: unknown + propagated: boolean + retainedFailures: ReadonlyArray + directFailure?: SubsetFailureOccurrence + }> + +type SubsetCleanupCaptureResult = Readonly<{ + completed: boolean + failures?: ReadonlyArray +}> + +class SubsetCleanupAggregateError extends AggregateError { + constructor(errors: ReadonlyArray) { + super(errors, `Several subset acquisition releases failed`) + } +} + +/** Internal carrier that distinguishes propagation from an equal new throw. */ +class SubsetFailurePropagation extends Error { + constructor( + readonly payload: unknown, + private readonly adoptingOptions: ReadonlySet, + ) { + super( + payload instanceof Error + ? payload.message + : `A nested subset operation failed`, + ) + this.name = `SubsetFailurePropagation` + } + + isAdoptedBy(options: LoadSubsetOptions): boolean { + return this.adoptingOptions.has(options) + } +} + +function createSubsetCleanupError(errors: ReadonlyArray): unknown { + if (errors.length === 1) return errors[0] + return new SubsetCleanupAggregateError(errors) } export class CollectionSubscription @@ -134,29 +255,147 @@ 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 - - // Track the last key sent via requestLimitedSnapshot for cursor-based pagination - private lastSentKey: string | number | undefined + // One object owns the last complete ordered publication. A failed replay + // retains the full publication state instead of reconstructing it from + // parallel offset, key, and boundary fields. + private orderedPublication: OrderedPublicationState | undefined + private stalePublication: PublicationState | undefined private filteredCallback: (changes: Array>) => boolean private orderByIndex: IndexInterface | undefined + private orderedWindow: WindowState | undefined // Status tracking private _status: SubscriptionStatus = `ready` private _lastError: unknown | undefined + private _lastErrorVersion = 0 + private unsubscribed = false + private terminalEventDispatched = false private pendingLoadSubsetPromises: Set> = new Set() - // Cleanup function for truncate event listener private truncateCleanup: (() => void) | undefined // One replay session owns the publication baseline, overlapping attempts, // and buffered changes until every attempt settles. private truncateReplaySession: TruncateReplaySession | undefined + // Adapter boundaries identify failure occurrences while arbitrary replay + // callbacks run. Payload identity alone cannot distinguish two operations + // that throw the same Error object. + private activeReplayResultCallback: ReplayResultCallbackFrame | undefined + private activeSubsetCleanupBoundary: SubsetCleanupBoundaryFrame | undefined + private activeSubsetAcquisition: SubsetAcquisitionFrame | undefined + private nextSubsetFailureOrder = 0 + private replayErrorReportDepth = 0 + private clearListenersAfterReplayErrors = false + private unsubscribeInProgress = false + private replayTeardownPending = false + private replayTeardownFinalizationScheduled = false + + private isActiveDemand(demand: SubsetDemand): boolean { + return demand.active && this.subsetDemands.includes(demand) + } + + /** Consume only a possible rejection once arbitrary code retires a demand. */ + private ignoreObsoleteSubsetResult( + demand: SubsetDemand, + result: LoadSubsetRequestResult, + ): boolean { + if (this.isActiveDemand(demand)) return false + if (result instanceof Promise) void result.catch(() => {}) + return true + } + + private hasActiveOrderedDemand(): boolean { + return this.subsetDemands.some( + (demand) => demand.active && demand.ordered !== undefined, + ) + } + + private activeAdditionalFilters(): Array<(row: object) => boolean> { + return this.subsetDemands + .filter((demand) => demand.active && demand.ordered === undefined) + .map((demand) => + demand.requestOptions.where + ? createFilterFunctionFromExpression( + demand.requestOptions.where, + ) + : () => true, + ) + } + + private diffPublishedRows( + desired: ReadonlyMap, + ): Array> { + const changes: Array> = [] + for (const [key, previousValue] of this.publishedRows) { + const value = desired.get(key) + if (value === undefined) { + changes.push({ type: `delete`, key, value: previousValue }) + } else if (!deepEquals(value, previousValue)) { + changes.push({ type: `update`, key, value, previousValue }) + } + } + for (const [key, value] of desired) { + if (!this.publishedRows.has(key)) { + changes.push({ type: `insert`, key, value }) + } + } + return changes + } + + /** Keep every retained replay baseline equal to what consumers still see. */ + private synchronizeRetainedPublication(): void { + if (this.stalePublication) { + this.stalePublication.publishedRows = new Map(this.publishedRows) + this.stalePublication.sentKeys = new Set(this.sentKeys) + this.stalePublication.ordered = undefined + if (this.stalePublication.publishedRows.size === 0) { + this.stalePublication = undefined + } + } + if (this.truncateReplaySession) { + const publication = this.truncateReplaySession.publicationState + publication.publishedRows = new Map(this.publishedRows) + publication.sentKeys = new Set(this.sentKeys) + publication.ordered = undefined + } + } + + /** Remove ordered authority and its exclusive rows when its last owner leaves. */ + private retireUnownedOrderedPublication(): void { + if (!this.orderedWindow || this.hasActiveOrderedDemand()) return + + const additionalFilters = this.activeAdditionalFilters() + const desired = new Map( + [...this.publishedRows].filter(([, row]) => + additionalFilters.some((filter) => filter(row)), + ), + ) + this.orderedWindow.resetCoverage() + this.orderedPublication = undefined + if (this.stalePublication) this.stalePublication.ordered = undefined + if (this.truncateReplaySession) { + this.truncateReplaySession.publicationState.ordered = undefined + } + const changes = this.diffPublishedRows(desired) + if (changes.length > 0) this.callback(changes) + this.synchronizeRetainedPublication() + } + + /** Forget inactive demand state after every owned adapter lease is gone. */ + private collectReleasedDemand(demand: SubsetDemand): void { + if ( + demand.active || + demand.releaseInProgress || + !demand.releaseSettled || + demand.pendingReplayAcquisitions.size > 0 + ) { + return + } + const index = this.subsetDemands.indexOf(demand) + if (index !== -1) this.subsetDemands.splice(index, 1) + } public get status(): SubscriptionStatus { return this._status @@ -166,6 +405,10 @@ export class CollectionSubscription return this._lastError } + public get lastErrorVersion(): number { + return this._lastErrorVersion + } + constructor( private collection: CollectionImpl, private callback: (changes: Array>) => void, @@ -189,6 +432,7 @@ export class CollectionSubscription ) => { this.trackPublishedRows(changes) this.trackSentKeys(changes) + this.refreshOrderedPublication() callback(changes) } @@ -220,7 +464,7 @@ export class CollectionSubscription * and retains subset ownership so a later truncate can retry the replay. */ private handleTruncate() { - const demandsToReload = [...this.subsetDemands] + const demandsToReload = this.subsetDemands.filter((demand) => demand.active) // 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. @@ -231,13 +475,14 @@ export class CollectionSubscription if (demandsToReload.length === 0 || !hasLoadSubsetHandler) { this.snapshotSent = false this.loadedInitialState = false - this.limitedSnapshotRowCount = 0 - this.lastSentKey = undefined + this.orderedPublication = undefined + this.stalePublication = undefined return } const attempt: TruncateReplayAttempt = { pending: new Set(), + pendingCallbacks: 0, failed: false, setupComplete: false, } @@ -249,18 +494,28 @@ export class CollectionSubscription snapshotSent: this.snapshotSent, sentKeys: new Set(this.sentKeys), publishedRows: new Map(this.publishedRows), - limitedSnapshotRowCount: this.limitedSnapshotRowCount, - lastSentKey: this.lastSentKey, + ordered: + this.orderedPublication === undefined + ? undefined + : { + ...this.orderedPublication, + candidateRows: new Map(this.orderedPublication.candidateRows), + }, }, buffer: [], attempts: new Set(), currentAttempt: attempt, + errors: [], } this.truncateReplaySession = session } session.attempts.add(attempt) session.currentAttempt = attempt + // Truncate starts a new source generation. Its rows cannot inherit an + // ordered boundary or admission proof from the generation being replaced. + this.orderedWindow?.resetCoverage() + // 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) { @@ -278,8 +533,6 @@ export class CollectionSubscription // Reset snapshot/pagination tracking state for the replacement snapshot. this.snapshotSent = false this.loadedInitialState = false - this.limitedSnapshotRowCount = 0 - this.lastSentKey = undefined // Defer the requests so the truncate commit's deletes enter the session // buffer before a synchronous adapter can publish replacement rows. @@ -287,72 +540,140 @@ export class CollectionSubscription if (this.truncateReplaySession !== session) return for (const demand of demandsToReload) { - if (!this.subsetDemands.includes(demand)) continue + if (!this.isActiveDemand(demand)) continue const isCurrentAttempt = () => this.truncateReplaySession === session && session.currentAttempt === attempt - const nextAcquisition = this.createSubsetAcquisition(demand) + const nextAcquisition = this.createSubsetAcquisition(demand, true) demand.pendingReplayAcquisitions.add(nextAcquisition) - let syncResult: LoadSubsetRequestResult - try { - syncResult = this.loadSubset( - nextAcquisition.options, - isCurrentAttempt, - ) - } catch { + const replayContext = { session, attempt } + const entry = this.enterSubsetAcquisition( + nextAcquisition.options, + replayContext, + () => this.collection._sync.loadSubset(nextAcquisition.options), + ) + if (!entry.completed) { + const shouldReportError = + isCurrentAttempt() && + (!nextAcquisition.options.signal?.aborted || + this.replayTeardownPending) demand.pendingReplayAcquisitions.delete(nextAcquisition) nextAcquisition.abortController.abort() nextAcquisition.removeRequestAbortListener?.() attempt.failed = true + if (shouldReportError) { + if (entry.propagated) { + this.queueUnattributedReplayFailures( + session, + entry.retainedFailures, + ) + } else if (entry.directFailure) { + this.queueTruncateReplayError( + session, + nextAcquisition.options, + entry.publicError, + entry.directFailure, + ) + } + } continue } + const syncResult = entry.value - this.observeLoadSubsetResult( - syncResult, - nextAcquisition.options, - true, - () => isCurrentAttempt() && !nextAcquisition.options.signal?.aborted, - ) - + let ownsReplacement = false if (syncResult instanceof Promise) { - // 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) + // Install the replacement lease before ordered coverage observes the + // same settlement. Replay publication is tracked last so a fallback + // request can join this atomic attempt before it completes. void syncResult.then( () => { - this.completeReplayAcquisition( + ownsReplacement = this.completeReplayAcquisition( session, attempt, demand, nextAcquisition, ) - this.settleTruncateReplay(session, attempt, pending) }, - () => { + (error: unknown) => { + const adoptedPropagation = + error instanceof SubsetFailurePropagation && + error.isAdoptedBy(nextAcquisition.options) const failedCurrentDemand = - this.subsetDemands.includes(demand) && - !nextAcquisition.options.signal?.aborted + (this.isActiveDemand(demand) && + !nextAcquisition.options.signal?.aborted) || + (this.replayTeardownPending && isCurrentAttempt()) // 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 (failedCurrentDemand) { attempt.failed = true + if (!adoptedPropagation) { + this.queueTruncateReplayError( + session, + nextAcquisition.options, + this.publicSubsetFailure(error), + ) + } + } + const cleanupFailures = this.discardReplayAcquisition( + demand, + nextAcquisition, + ) + if (cleanupFailures) { + this.queueUnattributedReplayFailures(session, cleanupFailures) } - this.discardReplayAcquisition(demand, nextAcquisition) - this.settleTruncateReplay(session, attempt, pending) }, ) } else { - this.completeReplayAcquisition( + ownsReplacement = this.completeReplayAcquisition( session, attempt, demand, nextAcquisition, ) } + + if (demand.ordered !== undefined) { + // The replacement acquisition, not the retired generation, owns any + // row provenance published by this replay result. + this.observeOrderedCoverage( + syncResult, + demand, + nextAcquisition, + () => ownsReplacement, + ) + } + + // Register this after ordered coverage so replay publication cannot + // overtake its boundary evidence on the same promise. + this.trackTruncateReplayResult(session, attempt, syncResult, () => { + // 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. + return ( + this.isActiveDemand(demand) && + !nextAcquisition.options.signal?.aborted + ) + }) + // Readiness and errors are observable callbacks. Register them only + // after replacement ownership, ordered evidence, and replay + // publication so listeners cannot reenter a half-settled replay. + this.observeLoadSubsetResult( + syncResult, + nextAcquisition.options, + true, + () => false, + ) + // Preserve the original demand's consumer-local in-flight guard. This + // observer is registered last so its settlement sees replay ownership, + // coverage, and attempt bookkeeping before it may continue the window. + this.invokeReplayResultCallback( + { session, attempt }, + nextAcquisition.options, + () => + demand.onLoadSubsetResult?.(syncResult, nextAcquisition.options), + ) } attempt.setupComplete = true @@ -360,6 +681,420 @@ export class CollectionSubscription }) } + private queueTruncateReplayError( + session: TruncateReplaySession, + options: LoadSubsetOptions, + error: unknown, + occurrence?: SubsetFailureOccurrence, + ): void { + if (this.truncateReplaySession !== session) return + const failure = + occurrence ?? this.createSubsetFailureOccurrence(options, error) + if (failure.reported || session.errors.includes(failure)) return + failure.attributed = true + session.errors.push(failure) + } + + private queueUnattributedReplayFailures( + session: TruncateReplaySession, + failures: ReadonlyArray, + ): void { + if (this.truncateReplaySession !== session) return + for (const failure of failures) { + if (!failure.attributed) { + this.queueTruncateReplayError( + session, + failure.options, + failure.error, + failure, + ) + } + } + } + + private createSubsetFailureOccurrence( + options: LoadSubsetOptions, + error: unknown, + ): SubsetFailureOccurrence { + return { + error, + options, + order: this.nextSubsetFailureOrder++, + attributed: false, + reported: false, + } + } + + /** Record which adapter failure occurrences propagate through one frame. */ + private noteSubsetFailureGroup( + replayContext: TruncateReplayContext | undefined, + group: SubsetFailureGroup, + ): void { + this.activeSubsetCleanupBoundary?.failureGroups.push(group) + this.activeSubsetAcquisition?.failureGroups.push(group) + const frame = this.activeReplayResultCallback + if ( + !frame || + (replayContext && frame.replayContext.session !== replayContext.session) + ) { + return + } + frame.failureGroups.push(group) + } + + private subsetFailureBoundaryOptions(): LoadSubsetOptions | undefined { + if (this.activeSubsetCleanupBoundary) { + return this.activeSubsetCleanupBoundary.options + } + const callbackFrame = this.activeReplayResultCallback + return callbackFrame && + this.truncateReplaySession === callbackFrame.replayContext.session + ? callbackFrame.options + : undefined + } + + /** Tokenize nested propagation without changing the reported payload. */ + private propagatedSubsetFailure( + error: unknown, + { + excludeCurrentAcquisition = false, + callbackBoundaryOnly = false, + }: { + excludeCurrentAcquisition?: boolean + callbackBoundaryOnly?: boolean + } = {}, + ): unknown { + const adoptingOptions = new Set() + let frame = excludeCurrentAcquisition + ? this.activeSubsetAcquisition?.previous + : this.activeSubsetAcquisition + while (frame) { + adoptingOptions.add(frame.options) + frame = frame.previous + } + if ( + !this.subsetFailureBoundaryOptions() && + (callbackBoundaryOnly || adoptingOptions.size === 0) + ) { + return error + } + return new SubsetFailurePropagation(error, adoptingOptions) + } + + private publicSubsetFailure(error: unknown): unknown { + return error instanceof SubsetFailurePropagation ? error.payload : error + } + + /** Keep every nested occurrence observed before an acquisition frame exits. */ + private retainReplayAcquisitionFailures( + replayContext: TruncateReplayContext | undefined, + failureGroups: ReadonlyArray, + ): void { + if ( + !replayContext || + this.truncateReplaySession !== replayContext.session + ) { + return + } + const retainedFailures = failureGroups.flatMap((group) => + group.failures.filter((failure) => !failure.attributed), + ) + if (retainedFailures.length === 0) return + + replayContext.attempt.failed = true + this.queueUnattributedReplayFailures( + replayContext.session, + retainedFailures, + ) + } + + /** Run one adapter entry with the same causal frame on every start path. */ + private enterSubsetAcquisition( + options: LoadSubsetOptions, + replayContext: TruncateReplayContext | undefined, + enter: () => T, + ): SubsetAcquisitionEntryResult { + const frame: SubsetAcquisitionFrame = { + options, + previous: this.activeSubsetAcquisition, + failureGroups: [], + } + this.activeSubsetAcquisition = frame + try { + const value = enter() + this.retainReplayAcquisitionFailures(replayContext, frame.failureGroups) + return { completed: true, value } + } catch (error) { + const adoptedCarrier = + error instanceof SubsetFailurePropagation && error.isAdoptedBy(options) + const retainedFailures = frame.failureGroups.flatMap((group) => + Object.is(group.propagatedError, error) ? group.failures : [], + ) + const propagated = adoptedCarrier || retainedFailures.length > 0 + const publicError = this.publicSubsetFailure(error) + let propagatedError = error + let directFailure: SubsetFailureOccurrence | undefined + + if (!options.signal?.aborted || this.replayTeardownPending) { + if (retainedFailures.length > 0) { + propagatedError = this.propagatedSubsetFailure(publicError, { + excludeCurrentAcquisition: true, + }) + if (!Object.is(propagatedError, error)) { + this.noteSubsetFailureGroup(replayContext, { + propagatedError, + failures: retainedFailures, + }) + } + } else if (!propagated) { + propagatedError = this.propagatedSubsetFailure(publicError, { + excludeCurrentAcquisition: true, + }) + directFailure = this.createSubsetFailureOccurrence( + options, + publicError, + ) + this.noteSubsetFailureGroup(replayContext, { + propagatedError, + failures: [directFailure], + }) + } + } + + this.retainReplayAcquisitionFailures(replayContext, frame.failureGroups) + + const escapesOrdinaryOutermostStart = + propagated && + frame.previous === undefined && + !this.subsetFailureBoundaryOptions() + return { + completed: false, + error: escapesOrdinaryOutermostStart ? publicError : propagatedError, + publicError, + propagated, + retainedFailures, + directFailure, + } + } finally { + this.activeSubsetAcquisition = frame.previous + } + } + + /** Preserve nested cleanup provenance across one arbitrary adapter callback. */ + private captureSubsetCleanupFailures( + options: LoadSubsetOptions, + callback: () => void, + ): SubsetCleanupCaptureResult { + const frame: SubsetCleanupBoundaryFrame = { + options, + previous: this.activeSubsetCleanupBoundary, + failureGroups: [], + } + this.activeSubsetCleanupBoundary = frame + let caught = false + let caughtError: unknown + try { + callback() + } catch (error) { + caught = true + caughtError = error + } finally { + this.activeSubsetCleanupBoundary = frame.previous + } + + const failures = frame.failureGroups.flatMap((group) => group.failures) + const propagatedNestedFailure = + caught && + frame.failureGroups.some((group) => + Object.is(group.propagatedError, caughtError), + ) + if (caught && !propagatedNestedFailure) { + failures.push( + this.createSubsetFailureOccurrence( + options, + this.publicSubsetFailure(caughtError), + ), + ) + } + return { + completed: !caught, + ...(failures.length > 0 && { failures }), + } + } + + /** Attribute one replay callback failure without merging equal payloads. */ + private invokeReplayResultCallback( + replayContext: TruncateReplayContext | undefined, + options: LoadSubsetOptions, + callback: () => void, + ): void { + if ( + !replayContext || + this.truncateReplaySession !== replayContext.session + ) { + try { + callback() + } catch (error) { + throw this.publicSubsetFailure(error) + } + return + } + + const frame: ReplayResultCallbackFrame = { + replayContext, + options, + previous: this.activeReplayResultCallback, + failureGroups: [], + } + this.activeReplayResultCallback = frame + let caught = false + let caughtError: unknown + try { + callback() + } catch (error) { + caught = true + caughtError = error + } finally { + this.activeReplayResultCallback = frame.previous + } + + if (this.truncateReplaySession !== replayContext.session) { + if (caught) { + throw this.publicSubsetFailure(caughtError) + } + return + } + + if ( + frame.previous?.replayContext.session === replayContext.session && + frame.failureGroups.length > 0 + ) { + frame.previous.failureGroups.push(...frame.failureGroups) + } + + const seen = new Set() + const nestedFailures: Array = [] + for (const group of frame.failureGroups) { + for (const failure of group.failures) { + if (!failure.attributed && !seen.has(failure)) { + seen.add(failure) + nestedFailures.push(failure) + } + } + } + const propagated = + caught && + frame.failureGroups.some((group) => + Object.is(group.propagatedError, caughtError), + ) + const hasCallbackFailure = caught && !propagated + if (nestedFailures.length === 0 && !hasCallbackFailure) return + + replayContext.attempt.failed = true + for (const failure of nestedFailures) { + this.queueTruncateReplayError( + replayContext.session, + failure.options, + failure.error, + failure, + ) + } + if (hasCallbackFailure) { + this.queueTruncateReplayError( + replayContext.session, + options, + this.publicSubsetFailure(caughtError), + ) + } + this.checkTruncateReplayComplete(replayContext.session) + } + + private reportSubsetFailureOccurrence( + failure: SubsetFailureOccurrence, + ): void { + if (failure.reported) return + // Mark first because an error listener may reenter teardown while the + // public event is being dispatched. + failure.attributed = true + failure.reported = true + this.recordLoadSubsetError(failure.options, failure.error, true) + } + + private reportTruncateReplayErrors(session: TruncateReplaySession): void { + this.replayErrorReportDepth++ + try { + session.errors.sort((left, right) => left.order - right.order) + while (session.errors.length > 0) { + this.reportSubsetFailureOccurrence(session.errors.shift()!) + } + } finally { + this.replayErrorReportDepth-- + if ( + this.replayErrorReportDepth === 0 && + this.clearListenersAfterReplayErrors + ) { + this.clearListenersAfterReplayErrors = false + this.clearListeners() + } + } + } + + /** Report every retained failure before teardown discards its replay session. */ + private reportReplayFailuresBeforeTeardown(): void { + const session = this.truncateReplaySession + if (!session) return + + const frames: Array = [] + let frame = this.activeReplayResultCallback + while (frame?.replayContext.session === session) { + frames.push(frame) + frame = frame.previous + } + + const seen = new Set() + const failures: Array = [] + for (const failure of session.errors.splice(0)) { + if (seen.has(failure)) continue + seen.add(failure) + failures.push(failure) + } + for (const callbackFrame of frames.reverse()) { + for (const group of callbackFrame.failureGroups) { + for (const failure of group.failures) { + if (failure.reported || seen.has(failure)) continue + seen.add(failure) + failures.push(failure) + } + } + } + failures.sort((left, right) => left.order - right.order) + for (const failure of failures) { + this.reportSubsetFailureOccurrence(failure) + } + } + + private trackTruncateReplayResult( + session: TruncateReplaySession, + attempt: TruncateReplayAttempt, + result: LoadSubsetRequestResult, + shouldFailAttempt: () => boolean, + ): void { + if (!(result instanceof Promise)) return + + // 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: result } + attempt.pending.add(pending) + void result.then( + () => this.settleTruncateReplay(session, attempt, pending), + () => { + if (shouldFailAttempt()) attempt.failed = true + this.settleTruncateReplay(session, attempt, pending) + }, + ) + } + private settleTruncateReplay( session: TruncateReplaySession, attempt: TruncateReplayAttempt, @@ -374,14 +1109,32 @@ export class CollectionSubscription 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 ( + !attempt.setupComplete || + attempt.pending.size > 0 || + attempt.pendingCallbacks > 0 + ) { + return + } } if (session.currentAttempt.failed) { this.abandonTruncateReplay(session) - } else { - this.flushTruncateReplay(session) + this.reportTruncateReplayErrors(session) + return + } + // A fulfilled page can still say that more ordered rows exist. Keep the + // old publication until a continuation proves the whole retained prefix; + // request settlement alone is not a safe replacement boundary. + if ( + this.orderedWindow && + this.hasActiveOrderedDemand() && + !this.orderedWindow.coversRetainedWindow + ) { + return } + this.flushTruncateReplay(session) + this.reportTruncateReplayErrors(session) } /** @@ -392,13 +1145,16 @@ export class CollectionSubscription private abandonTruncateReplay(session: TruncateReplaySession): void { if (this.truncateReplaySession !== session) return const publicationState = session.publicationState + // Evidence established by the rejected source generation cannot satisfy a + // later consumer guard. The retained publication remains public, but the + // next acquisition must prove coverage again. + this.orderedWindow?.resetCoverage() 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.orderedPublication = publicationState.ordered + this.stalePublication = publicationState this.truncateReplaySession = undefined } @@ -407,39 +1163,42 @@ export class CollectionSubscription if (this.truncateReplaySession !== session) return this.truncateReplaySession = undefined - const retainedDeletes = [...this.stalePublishedRows].map( + const retainedDeletes = [ + ...(this.stalePublication?.publishedRows ?? []), + ].map( ([key, value]): ChangeMessage => ({ type: `delete`, key, value, }), ) - this.stalePublishedRows.clear() + this.stalePublication = undefined 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), - ) + const activeDemandFilters = this.subsetDemands + .filter((demand) => demand.active) + .map((demand) => + demand.requestOptions.where + ? createFilterFunctionFromExpression(demand.requestOptions.where) + : undefined, + ) + // The raw replay buffer can contain rows retained for another demand or + // outside the ordered prefix. Publish the settled ordered reconciliation + // as the replacement's one atomic batch. + const replacement = this.orderedWindow + ? this.reconcileOrderedWindow() + : 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) - } + this.refreshOrderedPublication() } /** Reduce a replay's raw delete/insert stream to one exact semantic delta. */ @@ -496,6 +1255,229 @@ export class CollectionSubscription return this.orderByIndex !== undefined } + /** Retain enough locally known rows to cover this ordered window prefix. */ + ensureOrderedWindowSize(size: number): boolean { + if (!this.orderedWindow) return false + this.orderedWindow.ensureSize(size) + // Retain the new target now, but let the replacement epoch reconcile and + // publish it together with the buffered source rows. + if (this.isBufferingForTruncate) { + return false + } + if (this.stalePublication?.ordered) { + const changes = this.reconcileStaleOrderedPublication([]) + if (changes.length === 0) return false + this.callback(changes) + return true + } + const changes = this.reconcileOrderedWindow() + if (changes.length === 0) return false + this.callback(changes) + return true + } + + get orderedRowsNeeded(): number { + return this.hasActiveOrderedDemand() + ? (this.orderedWindow?.rowsNeeded() ?? 0) + : 0 + } + + get orderedRetainedWindowSize(): number { + return this.orderedWindow?.retainedPrefixSize ?? 0 + } + + get requiresOrderedPrefixRefresh(): boolean { + return this.orderedWindow?.requiresPrefixRefresh ?? false + } + + get hasOrderedCoverageForActiveWindow(): boolean { + return ( + this.hasActiveOrderedDemand() && + (this.orderedWindow?.coversActiveWindow ?? false) + ) + } + + get orderedBoundaryRow(): object | undefined { + if (!this.hasActiveOrderedDemand()) return undefined + const boundary = this.retainedOrderedPublication + ? this.orderedBoundary() + : this.orderedWindow?.progressBoundary() + return boundary === undefined + ? undefined + : (this.publishedRows.get(boundary.key) ?? + this.collection.get(boundary.key)) + } + + get orderedBoundaryKey(): string | number | undefined { + if (!this.hasActiveOrderedDemand()) return undefined + return ( + this.retainedOrderedPublication + ? this.orderedBoundary() + : this.orderedWindow?.progressBoundary() + )?.key + } + + private orderedBoundary() { + const retainedPublication = this.retainedOrderedPublication + return retainedPublication === undefined + ? this.orderedWindow?.boundary() + : retainedPublication.boundary + } + + private get retainedOrderedPublication(): + | OrderedPublicationState + | undefined { + return ( + this.truncateReplaySession?.publicationState.ordered ?? + this.stalePublication?.ordered + ) + } + + private reconcileOrderedWindow(): Array> { + if (!this.orderedWindow) return [] + const additionalFilters = this.activeAdditionalFilters() + const changes = this.orderedWindow.reconcile( + this.publishedRows, + additionalFilters.length === 0 + ? undefined + : (row) => additionalFilters.some((filter) => filter(row)), + ) + this.refreshOrderedPublication() + return changes + } + + /** + * Evolve a failed replay's last good ordered publication without admitting + * rows installed by the rejected replacement. Later source deltas form a + * new, isolated candidate set around that public baseline. Keeping this + * state even when the public prefix is empty prevents private replay progress + * from becoming a cursor or publication boundary. + */ + private reconcileStaleOrderedPublication( + changes: ReadonlyArray>, + source: + | `ordered-source` + | `additional-demand` + | (( + change: ChangeMessage, + ) => `ordered-source` | `additional-demand`) = `ordered-source`, + ): Array> { + const stalePublication = this.stalePublication + const ordered = stalePublication?.ordered + const window = this.orderedWindow + if (!stalePublication || !ordered || !window) return [] + + const orderedFilter = this.options.whereExpression + ? createFilterFunctionFromExpression(this.options.whereExpression) + : undefined + const additionalFilters = this.activeAdditionalFilters() + const isOrderedRow = (row: object) => orderedFilter?.(row) ?? true + const isAdditionalRow = (row: object) => + additionalFilters.some((filter) => filter(row)) + const orderedCandidates = ordered.candidateRows + + for (const change of changes) { + const changeSource = + typeof source === `function` ? source(change) : source + const admitsOrderedCandidates = changeSource === `ordered-source` + if (change.type === `delete`) { + stalePublication.publishedRows.delete(change.key) + orderedCandidates.delete(change.key) + } else { + if (admitsOrderedCandidates) { + if (isOrderedRow(change.value)) { + orderedCandidates.set(change.key, change.value) + } else { + orderedCandidates.delete(change.key) + } + } else { + const candidate = orderedCandidates.get(change.key) + if (candidate !== undefined && !deepEquals(candidate, change.value)) { + // Additional visibility may replace the collection's current row, + // but it cannot transfer ordered authority from an older version. + orderedCandidates.delete(change.key) + } + } + if ( + orderedCandidates.has(change.key) || + isAdditionalRow(change.value) + ) { + stalePublication.publishedRows.set(change.key, change.value) + } else { + stalePublication.publishedRows.delete(change.key) + } + } + } + for (const [key, row] of stalePublication.publishedRows) { + if (!orderedCandidates.has(key) && !isAdditionalRow(row)) { + stalePublication.publishedRows.delete(key) + } + } + + const orderedRows = [...orderedCandidates] + .sort((left, right) => window.totalOrder.compareEntries(left, right)) + .slice(0, window.retainedPrefixSize) + const desired = new Map(orderedRows) + if (additionalFilters.length > 0) { + for (const [key, row] of stalePublication.publishedRows) { + if (isAdditionalRow(row)) desired.set(key, row) + } + } + + const lastOrderedRow = orderedRows.at(-1) + const nextOrderedPublication: OrderedPublicationState = { + prefixSize: orderedRows.length, + boundary: + lastOrderedRow === undefined + ? undefined + : window.totalOrder.boundary(lastOrderedRow[1], lastOrderedRow[0]), + candidateRows: orderedCandidates, + } + stalePublication.ordered = nextOrderedPublication + this.orderedPublication = { + ...nextOrderedPublication, + candidateRows: new Map(orderedCandidates), + } + + const reconciled: Array> = [] + for (const [key, previousValue] of this.publishedRows) { + const value = desired.get(key) + if (value === undefined) { + reconciled.push({ type: `delete`, key, value: previousValue }) + } else if (!deepEquals(value, previousValue)) { + reconciled.push({ type: `update`, key, value, previousValue }) + } + } + for (const [key, value] of desired) { + if (!this.publishedRows.has(key)) { + reconciled.push({ type: `insert`, key, value }) + } + } + return reconciled + } + + /** Capture the exact continuation state of the last complete publication. */ + private refreshOrderedPublication(): void { + if ( + !this.orderedWindow || + !this.hasActiveOrderedDemand() || + this.isBufferingForTruncate || + this.stalePublication + ) { + return + } + const publicationEntries = this.orderedWindow.publicationEntries() + const lastEntry = publicationEntries.at(-1) + this.orderedPublication = { + prefixSize: publicationEntries.length, + boundary: + lastEntry === undefined + ? undefined + : this.orderedWindow.totalOrder.boundary(lastEntry[1], lastEntry[0]), + candidateRows: new Map(publicationEntries), + } + } + /** * Set subscription status and emit events if changed */ @@ -531,6 +1513,7 @@ export class CollectionSubscription options: LoadSubsetOptions, trackStatus: boolean, shouldReportError: () => boolean = () => true, + reportAborted = false, ) { if (!(syncResult instanceof Promise)) return @@ -549,29 +1532,156 @@ export class CollectionSubscription } void syncResult.then(finish, (error: unknown) => { - if (shouldReportError()) this.recordLoadSubsetError(options, error) + const adoptedPropagation = + error instanceof SubsetFailurePropagation && error.isAdoptedBy(options) + if (!adoptedPropagation && shouldReportError()) { + this.recordLoadSubsetError( + options, + this.publicSubsetFailure(error), + reportAborted, + ) + } finish() }) } - private loadSubset( - options: LoadSubsetOptions, - shouldReportError: () => boolean = () => true, - ): LoadSubsetRequestResult { - try { - return this.collection._sync.loadSubset(options) - } catch (error) { - if (shouldReportError()) this.recordLoadSubsetError(options, error) - throw error + private staleChangeSource( + change: ChangeMessage, + ): `ordered-source` | `additional-demand` { + const provenance = getSyncRequestProvenance(change) + if (provenance === undefined || provenance.hasOrdinarySource) { + return `ordered-source` + } + + for (const requestSignal of provenance.requestSignals) { + for (const demand of this.subsetDemands) { + if (!demand.active) continue + if ( + demand.options.signal !== undefined && + !demand.options.signal.aborted && + isLoadSubsetRequestSignalFor(requestSignal, demand.options.signal) + ) { + if (demand.ordered !== undefined) return `ordered-source` + } + for (const pending of demand.pendingReplayAcquisitions) { + if ( + pending.options.signal !== undefined && + !pending.options.signal.aborted && + isLoadSubsetRequestSignalFor(requestSignal, pending.options.signal) + ) { + if (pending.ordered !== undefined) return `ordered-source` + } + } + } + } + // A tagged request that has no active ordered owner here may belong to an + // unordered, released, or peer demand. None may mint ordered authority for + // this subscription. Untagged transactions took the ordinary branch above. + return `additional-demand` + } + + private buildOrderedCursorExpressions( + orderBy: OrderBy, + cursorValues: ReadonlyArray | undefined, + lastKey: string | number | undefined, + ): { + cursor: LoadSubsetOptions[`cursor`] + requiresUnboundedRefinement: boolean + } { + if (cursorValues === undefined || cursorValues.length === 0) { + return { cursor: undefined, requiresUnboundedRefinement: false } + } + + if (!canExpressCursorOrder(orderBy, cursorValues)) { + return { cursor: undefined, requiresUnboundedRefinement: true } + } + + const whereFrom = buildCursor(orderBy, [...cursorValues]) + if (!whereFrom) { + return { cursor: undefined, requiresUnboundedRefinement: false } + } + + const { expression } = orderBy[0]! + const cursorMinValue = cursorValues[0] + // A JS Date represents a 1ms range while some backends retain finer + // precision, so equality must cover that complete interval. + const whereCurrent = + cursorMinValue instanceof Date + ? and( + gte(expression, new Value(cursorMinValue)), + lt(expression, new Value(new Date(cursorMinValue.getTime() + 1))), + ) + : buildCursorEquality(expression, cursorMinValue) + + return { + cursor: { whereFrom, whereCurrent, lastKey }, + requiresUnboundedRefinement: false, + } + } + + /** Rebuild ordered transport and evidence from this replacement generation. */ + private createReplayRequest(demand: SubsetDemand): { + options: LoadSubsetOptions + ordered: OrderedAcquisitionState | undefined + } { + const ordered = demand.ordered + const window = this.orderedWindow + const orderBy = demand.requestOptions.orderBy + if (!ordered || !window || !orderBy) { + return { options: demand.requestOptions, ordered } + } + + const boundary = window.requestBoundary() + const builtCursor = this.buildOrderedCursorExpressions( + orderBy, + boundary?.values, + boundary?.key, + ) + const requiresUnboundedRefinement = + ordered.requiresUnboundedRefinement || + window.requiresFullRefinement || + builtCursor.requiresUnboundedRefinement + const currentOffset = window.localPrefixSize + const limit = demand.requestOptions.limit + const replayOrdered: OrderedAcquisitionState = { + requestedPrefix: + limit === undefined ? ordered.requestedPrefix : currentOffset + limit, + hadBoundary: boundary !== undefined, + requiresUnboundedRefinement, + revision: window.coverageRevision, + } + + if (requiresUnboundedRefinement) { + return { + options: { + where: demand.requestOptions.where, + orderBy, + subscription: demand.requestOptions.subscription, + }, + ordered: replayOrdered, + } + } + + return { + options: { + ...demand.requestOptions, + cursor: builtCursor.cursor, + offset: currentOffset, + }, + ordered: replayOrdered, } } /** Create a fresh, abortable adapter acquisition for a replay generation. */ private createSubsetAcquisition( demand: SubsetDemand, + replay = false, ): SubsetAcquisition & { abortController: AbortController } { const abortController = new AbortController() const requestSignal = demand.requestOptions.signal + const request = replay + ? this.createReplayRequest(demand) + : { options: demand.requestOptions, ordered: demand.ordered } let removeRequestAbortListener: (() => void) | undefined if (requestSignal?.aborted) { @@ -585,9 +1695,10 @@ export class CollectionSubscription return { options: { - ...demand.requestOptions, + ...request.options, signal: abortController.signal, }, + ordered: request.ordered, abortController, removeRequestAbortListener, } @@ -597,16 +1708,60 @@ export class CollectionSubscription private replaceSubsetAcquisition( demand: SubsetDemand, next: SubsetAcquisition & { abortController: AbortController }, - ): void { + ): ReplayHandoffResult { + if (demand.releaseInProgress) return { installed: false } + demand.releaseInProgress = true 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 - demand.releaseFailed = false - demand.releaseSettled = false + const failures: Array = [] + try { + const previousCleanup = this.captureSubsetCleanupFailures( + previousOptions, + () => { + this.collection._sync.unloadSubset(previousOptions) + }, + ) + if (previousCleanup.failures) failures.push(...previousCleanup.failures) + if (!previousCleanup.completed) { + return { + installed: false, + ...(failures.length > 0 && { failures }), + } + } + removePreviousAbortListener?.() + demand.releaseFailed = false + demand.releaseSettled = true + + // unloadSubset is user adapter code and may synchronously release the + // logical demand. In that case the replacement must never become its + // new live acquisition. + if (!this.isActiveDemand(demand)) { + const replacementCleanup = this.captureSubsetCleanupFailures( + next.options, + () => this.releaseReplayAcquisitionUnprotected(demand, next), + ) + if (replacementCleanup.failures) { + failures.push(...replacementCleanup.failures) + } + return { + installed: false, + ...(failures.length > 0 && { failures }), + } + } + + demand.options = next.options + demand.ordered = next.ordered + demand.abortController = next.abortController + demand.removeRequestAbortListener = next.removeRequestAbortListener + demand.releaseSettled = false + return { + installed: true, + ...(failures.length > 0 && { failures }), + } + } finally { + demand.releaseInProgress = false + this.collectReleasedDemand(demand) + } } /** Attach a successful replay only while every owning authority is current. */ @@ -615,52 +1770,77 @@ export class CollectionSubscription attempt: TruncateReplayAttempt, demand: SubsetDemand, next: ReplaySubsetAcquisition, - ): void { + ): boolean { const mayReplace = this.truncateReplaySession === session && session.currentAttempt === attempt && - this.subsetDemands.includes(demand) && + this.isActiveDemand(demand) && demand.pendingReplayAcquisitions.has(next) && !demand.releaseSettled && !next.options.signal?.aborted if (mayReplace) { - this.tryReplaceSubsetAcquisition(demand, next, attempt) - } else { - this.discardReplayAcquisition(demand, next) + return this.tryReplaceSubsetAcquisition(session, demand, next, attempt) } + const cleanupFailures = this.discardReplayAcquisition(demand, next) + if (cleanupFailures) { + this.queueUnattributedReplayFailures(session, cleanupFailures) + } + return false } private tryReplaceSubsetAcquisition( + session: TruncateReplaySession, demand: SubsetDemand, next: ReplaySubsetAcquisition, attempt: TruncateReplayAttempt, - ): void { - try { - this.replaceSubsetAcquisition(demand, next) + ): boolean { + const handoff = this.replaceSubsetAcquisition(demand, next) + if (handoff.failures) { + attempt.failed = true + this.queueUnattributedReplayFailures(session, handoff.failures) + } + if (handoff.installed) { demand.pendingReplayAcquisitions.delete(next) - } catch (error) { + return true + } + if (handoff.failures) { // The old lease remains owned when its release fails. Release the new - // acquisition and keep the old one available for a cleanup retry. - this.discardReplayAcquisition(demand, next) - this.recordLoadSubsetError(demand.options, error, true) - attempt.failed = true + // acquisition and preserve every failed cleanup as a distinct event. + const discardFailures = this.discardReplayAcquisition(demand, next) + if (discardFailures) { + this.queueUnattributedReplayFailures(session, discardFailures) + } } + return false } private discardReplayAcquisition( demand: SubsetDemand, next: ReplaySubsetAcquisition, + ): ReadonlyArray | undefined { + // A failed acquisition stays on the demand. releaseSnapshot, unsubscribe, + // or collection cleanup will retry its exact owner route. + return this.captureSubsetCleanupFailures(next.options, () => + this.releaseReplayAcquisition(demand, next), + ).failures + } + + private releaseReplayAcquisition( + demand: SubsetDemand, + next: ReplaySubsetAcquisition, ): void { + if (demand.releaseInProgress) return + demand.releaseInProgress = true try { - this.releaseReplayAcquisition(demand, next) - } catch { - // Keep the failed acquisition on the demand. releaseSnapshot, - // unsubscribe, or collection cleanup will retry its exact owner route. + this.releaseReplayAcquisitionUnprotected(demand, next) + } finally { + demand.releaseInProgress = false + this.collectReleasedDemand(demand) } } - private releaseReplayAcquisition( + private releaseReplayAcquisitionUnprotected( demand: SubsetDemand, next: ReplaySubsetAcquisition, ): void { @@ -676,65 +1856,201 @@ export class CollectionSubscription /** Abort and release one current adapter acquisition. */ private releaseSubsetDemand(demand: SubsetDemand): void { - demand.abortController?.abort() - let firstReleaseError: unknown - for (const pending of [...demand.pendingReplayAcquisitions]) { - try { - this.releaseReplayAcquisition(demand, pending) - } catch (error) { - firstReleaseError ??= error + if (demand.releaseInProgress) return + demand.releaseInProgress = true + try { + demand.abortController?.abort() + const releaseFailures: Array = [] + for (const pending of [...demand.pendingReplayAcquisitions]) { + const cleanup = this.captureSubsetCleanupFailures(pending.options, () => + this.releaseReplayAcquisitionUnprotected(demand, pending), + ) + if (cleanup.failures) releaseFailures.push(...cleanup.failures) } - } - if (!demand.releaseSettled) { - try { - this.collection._sync.unloadSubset(demand.options) - demand.releaseFailed = false - demand.releaseSettled = true - } catch (error) { - demand.releaseFailed = true - firstReleaseError ??= error - } finally { - demand.removeRequestAbortListener?.() + if (!demand.releaseSettled) { + try { + const cleanup = this.captureSubsetCleanupFailures( + demand.options, + () => { + this.collection._sync.unloadSubset(demand.options) + demand.releaseFailed = false + demand.releaseSettled = true + }, + ) + if (!cleanup.completed) demand.releaseFailed = true + if (cleanup.failures) releaseFailures.push(...cleanup.failures) + } finally { + demand.removeRequestAbortListener?.() + } + } + if (releaseFailures.length > 0) { + const cleanupError = createSubsetCleanupError( + releaseFailures.map(({ error: failure }) => failure), + ) + const propagatedError = this.propagatedSubsetFailure(cleanupError, { + callbackBoundaryOnly: true, + }) + this.noteSubsetFailureGroup(undefined, { + propagatedError, + failures: releaseFailures, + }) + throw propagatedError } + } finally { + demand.releaseInProgress = false + this.collectReleasedDemand(demand) } - if (firstReleaseError !== undefined) throw firstReleaseError } /** Start and retain the first acquisition for one logical subset demand. */ - private startSubsetDemand(requestOptions: LoadSubsetOptions): { + private startSubsetDemand( + requestOptions: LoadSubsetOptions, + ordered?: SubsetDemand[`ordered`], + ): { demand: SubsetDemand + acquisition: SubsetAcquisition & { abortController: AbortController } result: LoadSubsetRequestResult + replayContext: TruncateReplayContext | undefined } { const demand: SubsetDemand = { requestOptions, options: requestOptions, + ...(ordered === undefined ? {} : { ordered }), pendingReplayAcquisitions: new Set(), + active: true, + releaseInProgress: false, releaseFailed: false, releaseSettled: false, } const acquisition = this.createSubsetAcquisition(demand) demand.options = acquisition.options + demand.ordered = acquisition.ordered demand.abortController = acquisition.abortController demand.removeRequestAbortListener = acquisition.removeRequestAbortListener + const replaySession = this.truncateReplaySession + const replayContext = replaySession + ? { session: replaySession, attempt: replaySession.currentAttempt } + : undefined if (acquisition.abortController.signal.aborted) { acquisition.removeRequestAbortListener?.() - return { demand, result: true } + return { demand, acquisition, result: true, replayContext } } // Reentrant release must see the exact acquisition before adapter work // starts. A genuine load throw removes this tentative logical owner below. this.subsetDemands.push(demand) - try { - const result = this.loadSubset(acquisition.options) - return { demand, result } - } catch (error) { - const demandIndex = this.subsetDemands.indexOf(demand) - if (demandIndex !== -1 && !demand.releaseFailed) { - this.subsetDemands.splice(demandIndex, 1) - acquisition.abortController.abort() - acquisition.removeRequestAbortListener?.() + // A synchronous start failure is not observable until the tentative owner + // has rolled back. Otherwise an error listener can reenter release and + // unload a request that never established an acquisition. + const entry = this.enterSubsetAcquisition( + acquisition.options, + replayContext, + () => this.collection._sync.loadSubset(acquisition.options), + ) + if (entry.completed) { + return { demand, acquisition, result: entry.value, replayContext } + } + + const shouldReportError = + !acquisition.options.signal?.aborted || + Boolean( + this.replayTeardownPending && + replayContext && + this.truncateReplaySession === replayContext.session, + ) + const demandIndex = this.subsetDemands.indexOf(demand) + if (demandIndex !== -1 && !demand.releaseFailed) { + this.subsetDemands.splice(demandIndex, 1) + acquisition.abortController.abort() + acquisition.removeRequestAbortListener?.() + } + if (shouldReportError && entry.directFailure) { + const occurrence = entry.directFailure + if ( + replayContext && + this.truncateReplaySession === replayContext.session + ) { + replayContext.attempt.failed = true + this.queueTruncateReplayError( + replayContext.session, + acquisition.options, + entry.publicError, + occurrence, + ) + this.checkTruncateReplayComplete(replayContext.session) + } else { + occurrence.attributed = true + occurrence.reported = true + this.recordLoadSubsetError(acquisition.options, entry.publicError, true) } - throw error } + throw entry.error + } + + /** Keep replay publication private until one result callback returns. */ + private retainReplayResultCallback( + replayContext: TruncateReplayContext | undefined, + ): TruncateReplayContext | undefined { + if ( + !replayContext || + this.truncateReplaySession !== replayContext.session + ) { + return undefined + } + replayContext.attempt.pendingCallbacks++ + return replayContext + } + + private releaseReplayResultCallback( + replayContext: TruncateReplayContext | undefined, + ): void { + if (!replayContext) return + replayContext.attempt.pendingCallbacks-- + if (this.truncateReplaySession === replayContext.session) { + this.checkTruncateReplayComplete(replayContext.session) + } + } + + /** Join demand work started by a replay callback to that publication epoch. */ + private trackDemandStartedDuringReplay( + demand: SubsetDemand, + result: LoadSubsetRequestResult, + replayContext: TruncateReplayContext | undefined, + ): TruncateReplayContext | undefined { + if ( + !replayContext || + this.truncateReplaySession !== replayContext.session + ) { + return undefined + } + const { session, attempt } = replayContext + if (!(result instanceof Promise)) return replayContext + + void result.then( + () => {}, + (error: unknown) => { + if ( + error instanceof SubsetFailurePropagation && + error.isAdoptedBy(demand.options) + ) { + // The originating nested boundary already failed this replay and + // retained its public payload. Promise adoption must not turn the + // private propagation carrier into a second adapter occurrence. + return + } + if (this.isActiveDemand(demand) && !demand.options.signal?.aborted) { + attempt.failed = true + this.queueTruncateReplayError( + session, + demand.options, + this.publicSubsetFailure(error), + ) + } + }, + ) + this.trackTruncateReplayResult(session, attempt, result, () => { + return this.isActiveDemand(demand) && !demand.options.signal?.aborted + }) + return replayContext } private recordLoadSubsetError( @@ -747,6 +2063,7 @@ export class CollectionSubscription if (options.signal?.aborted && !reportAborted) return this._lastError = error + this._lastErrorVersion++ this.emitInner(`loadSubset:error`, { type: `loadSubset:error`, subscription: this, @@ -764,6 +2081,45 @@ export class CollectionSubscription } emitEvents(changes: Array>): boolean { + if ( + this.orderedWindow && + this.hasActiveOrderedDemand() && + !this.isBufferingForTruncate && + this.stalePublication?.ordered + ) { + const orderedChanges = this.reconcileStaleOrderedPublication( + changes, + (change) => this.staleChangeSource(change), + ) + if (changes.length > 0 && orderedChanges.length === 0) return false + this.callback(orderedChanges) + return true + } + + if ( + this.orderedWindow && + this.hasActiveOrderedDemand() && + !this.isBufferingForTruncate && + !this.stalePublication + ) { + this.orderedWindow.admitChanges(changes) + const orderedChanges = this.reconcileOrderedWindow() + if (changes.length > 0 && orderedChanges.length === 0) return false + this.callback(orderedChanges) + return true + } + + // A truncate replacement is private until it has enough ordered evidence + // to publish. Still admit its source changes so a later continuation uses + // the exact replacement candidates, including deltas that raced the page. + if ( + this.orderedWindow && + this.hasActiveOrderedDemand() && + this.isBufferingForTruncate + ) { + this.orderedWindow.admitChanges(changes) + } + const newChanges = this.filterAndFlipChanges(changes) // Reconciliation can reduce a source delta to no visible change. Do not @@ -790,6 +2146,7 @@ export class CollectionSubscription * or, the entire state was already loaded. */ requestSnapshot(opts?: RequestSnapshotOptions): boolean { + if (this.unsubscribed) return false if (this.loadedInitialState) { // Subscription was deoptimized so we already sent the entire initial state return false @@ -828,17 +2185,59 @@ export class CollectionSubscription limit: opts?.limit, } - const { demand, result: syncResult } = this.startSubsetDemand(loadOptions) + // Reentrant adapter code must be able to release a request by the exact + // caller predicate even when the subscription predicate was combined into + // the transport predicate. if (opts?.where) this.requestedSubsetWhere.set(loadOptions, opts.where) + const { + demand, + result: syncResult, + replayContext: startedReplayContext, + } = this.startSubsetDemand(loadOptions) + const replayTracksCallback = + this.retainReplayResultCallback(startedReplayContext) + // Replay settlement owns the acquisition even if the result callback + // immediately releases its logical demand. Install the barrier and its + // status observer before invoking arbitrary callback code. + const replayTracksResult = this.trackDemandStartedDuringReplay( + demand, + syncResult, + startedReplayContext, + ) + if (replayTracksResult) { + this.observeLoadSubsetResult( + syncResult, + demand.options, + opts?.trackLoadSubsetPromise ?? true, + () => false, + ) + } + if (this.ignoreObsoleteSubsetResult(demand, syncResult)) { + // The adapter synchronously released or the caller had already aborted + // this demand. Observe only to consume a possible rejection; obsolete + // work cannot report status, establish readiness, or publish a scan. + this.releaseReplayResultCallback(replayTracksCallback) + return false + } + demand.onLoadSubsetResult = opts?.onLoadSubsetResult // Pass the raw loadSubset result to the caller for external tracking - opts?.onLoadSubsetResult?.(syncResult, demand.options) + try { + this.invokeReplayResultCallback(replayTracksResult, demand.options, () => + opts?.onLoadSubsetResult?.(syncResult, demand.options), + ) + } finally { + this.releaseReplayResultCallback(replayTracksCallback) + } + if (this.ignoreObsoleteSubsetResult(demand, syncResult)) return false - this.observeLoadSubsetResult( - syncResult, - demand.options, - opts?.trackLoadSubsetPromise ?? true, - ) + if (!replayTracksResult) { + this.observeLoadSubsetResult( + syncResult, + demand.options, + opts?.trackLoadSubsetPromise ?? true, + ) + } // Also load data immediately from the collection let snapshot: Array> | void @@ -863,6 +2262,23 @@ export class CollectionSubscription return false } + if ( + this.orderedWindow && + !this.isBufferingForTruncate && + this.stalePublication?.ordered + ) { + this.snapshotSent = true + // A local snapshot can expose a row for this sibling demand, but it + // cannot prove that a row left behind by a rejected replay belongs in + // the ordered prefix. + const changes = this.reconcileStaleOrderedPublication( + snapshot, + `additional-demand`, + ) + if (changes.length > 0) this.callback(changes) + return true + } + // Only send changes that have not been sent yet const filteredSnapshot = snapshot.filter( (change) => !this.sentKeys.has(change.key), @@ -881,32 +2297,58 @@ export class CollectionSubscription } /** Release one exact subset request while keeping the subscription alive. */ - releaseSnapshot(where: BasicExpression): void { - const index = this.subsetDemands.findIndex( - (demand) => - demand.requestOptions.where === where || - this.requestedSubsetWhere.get(demand.requestOptions) === where, - ) - if (index === -1) return - - const demand = this.subsetDemands[index] + releaseSnapshot( + where: BasicExpression, + acquisitionSignal?: AbortSignal, + ): void { + const matchesWhere = (demand: SubsetDemand) => + demand.requestOptions.where === where || + this.requestedSubsetWhere.get(demand.requestOptions) === where + const matchesAcquisition = (demand: SubsetDemand) => + acquisitionSignal === undefined || + demand.requestOptions.signal === acquisitionSignal || + demand.options.signal === acquisitionSignal || + [...demand.pendingReplayAcquisitions].some( + (pending) => pending.options.signal === acquisitionSignal, + ) + let demand = + acquisitionSignal === undefined + ? this.subsetDemands.find( + (candidate) => candidate.active && matchesWhere(candidate), + ) + : this.subsetDemands.find( + (candidate) => + matchesWhere(candidate) && matchesAcquisition(candidate), + ) + if (!demand && acquisitionSignal === undefined) { + // A prior unload may have failed after logical release. With no active + // owner left, a repeated release retries that exact cleanup debt. + demand = this.subsetDemands.find(matchesWhere) + } if (!demand) return - this.releaseSubsetDemand(demand) - this.subsetDemands.splice(index, 1) + + demand.active = false + if (demand.ordered !== undefined) this.retireUnownedOrderedPublication() + let releaseFailure: { error: unknown } | undefined + try { + this.releaseSubsetDemand(demand) + } catch (error) { + releaseFailure = { error } + } finally { + this.collectReleasedDemand(demand) + if (this.orderedWindow && !this.isBufferingForTruncate) { + const changes = this.stalePublication?.ordered + ? this.reconcileStaleOrderedPublication([]) + : this.reconcileOrderedWindow() + if (changes.length > 0) this.callback(changes) + } + } + if (releaseFailure) throw releaseFailure.error } /** - * Sends a snapshot that fulfills the `where` clause and all rows are bigger or equal to the cursor. - * Requires a range index to be set with `setOrderByIndex` prior to calling this method. - * It uses that range index to load the items in the order of the index. - * - * For multi-column orderBy: - * - Uses first value from `minValues` for LOCAL index operations (wide bounds, ensures no missed rows) - * - Uses all `minValues` to build a precise composite cursor for SYNC layer loadSubset - * - * Note 1: it may load more rows than the provided LIMIT because it loads all values equal to the first cursor value + limit values greater. - * This is needed to ensure that it does not accidentally skip duplicate values when the limit falls in the middle of some duplicated values. - * Note 2: it does not send keys that have already been sent before. + * Reconciles the exact locally known ordered prefix, then asks the sync layer + * for enough rows after its total-order boundary to extend that prefix. */ requestLimitedSnapshot({ orderBy, @@ -916,198 +2358,281 @@ export class CollectionSubscription trackLoadSubsetPromise: shouldTrackLoadSubsetPromise = true, onLoadSubsetResult, }: RequestLimitedSnapshotOptions) { - if (!limit) throw new Error(`limit is required`) - + if (this.unsubscribed) return if (!this.orderByIndex) { throw new Error( `Ordered snapshot was requested but no index was found. You have to call setOrderByIndex before requesting an ordered snapshot.`, ) } - // Check if minValues has a first element (regardless of its value) - // This distinguishes between "no min value provided" vs "min value is undefined" - const hasMinValue = minValues !== undefined && minValues.length > 0 - // Derive first column value from minValues (used for local index operations) - const minValue = minValues?.[0] - // Cast for index operations (index expects string | number) - const minValueForIndex = minValue as string | number | undefined - - const index = this.orderByIndex - const where = this.options.whereExpression - const whereFilterFn = where - ? createFilterFunctionFromExpression(where) - : undefined - - const filterFn = (key: string | number | undefined): boolean => { - if (key !== undefined && this.sentKeys.has(key)) { - return false - } - - const value = this.collection.get(key) - if (value === undefined) { - return false - } - - return whereFilterFn?.(value) ?? true - } - - let biggestObservedValue = minValueForIndex - const changes: Array> = [] - - // If we have a minValue we need to handle the case - // where there might be duplicate values equal to minValue that we need to include - // because we can have data like this: [1, 2, 3, 3, 3, 4, 5] - // so if minValue is 3 then the previous snapshot may not have included all 3s - // e.g. if it was offset 0 and limit 3 it would only have loaded the first 3 - // so we load all rows equal to minValue first, to be sure we don't skip any duplicate values - // - // For multi-column orderBy, we use the first column value for index operations (wide bounds) - // This may load some duplicates but ensures we never miss any rows. - let keys: Array = [] - if (hasMinValue) { - // First, get all items with the same FIRST COLUMN value as minValue - // This provides wide bounds for the local index - const { expression } = orderBy[0]! - const allRowsWithMinValue = this.collection.currentStateAsChanges({ - where: eq(expression, new Value(minValueForIndex)), - }) - - if (allRowsWithMinValue) { - const keysWithMinValue = allRowsWithMinValue - .map((change) => change.key) - .filter((key) => !this.sentKeys.has(key) && filterFn(key)) - - // Add items with the minValue first - keys.push(...keysWithMinValue) + this.orderedWindow ??= new WindowState( + this.collection, + orderBy, + this.options.whereExpression, + limit, + ) - // Then get items greater than minValue - const keysGreaterThanMin = index.take( - limit - keys.length, - minValueForIndex!, - filterFn, - ) - keys.push(...keysGreaterThanMin) - } else { - keys = index.take(limit, minValueForIndex!, filterFn) + if (this.stalePublication && !this.stalePublication.ordered) { + // A failed replay may leave rows that are still owned only by active + // unordered demands. A later ordered incarnation starts with no ordered + // candidates, but it must still route ingress through top-K admission + // while preserving that additional publication baseline. + this.stalePublication.ordered = { + prefixSize: 0, + boundary: undefined, + candidateRows: new Map(), } - } else { - // No min value provided, start from the beginning - keys = index.takeFromStart(limit, filterFn) } - const valuesNeeded = () => Math.max(limit - changes.length, 0) - const collectionExhausted = () => keys.length === 0 - - // Create a value extractor for the orderBy field to properly track the biggest indexed value - const orderByExpression = orderBy[0]!.expression - const valueExtractor = - orderByExpression.type === `ref` - ? compileExpression(new PropRef(orderByExpression.path), true) - : null - - while (valuesNeeded() > 0 && !collectionExhausted()) { - const insertedKeys = new Set() // Track keys we add to `changes` in this iteration - - for (const key of keys) { - const value = this.collection.get(key)! - changes.push({ - type: `insert`, - key, - value, - }) - // Extract the indexed value (e.g., salary) from the row, not the full row - // This is needed for index.take() to work correctly with the BTree comparator - biggestObservedValue = valueExtractor ? valueExtractor(value) : value - insertedKeys.add(key) // Track this key - } - - keys = index.take(valuesNeeded(), biggestObservedValue!, filterFn) + const where = this.options.whereExpression + const retainedPublication = this.retainedOrderedPublication + const activeReplacement = this.truncateReplaySession !== undefined + const replayOwnsContinuation = + activeReplacement || retainedPublication !== undefined + const refreshPrefix = + !retainedPublication && this.orderedWindow.requiresPrefixRefresh + // An active replacement continues its private source progress so it can + // prove a new publication. A failed replay instead continues from the last + // complete public prefix until a later replay replaces it. + const currentOffset = activeReplacement + ? this.orderedWindow.localPrefixSize + : retainedPublication + ? retainedPublication.prefixSize + : refreshPrefix + ? 0 + : this.orderedWindow.localPrefixSize + const requestedPrefix = replayOwnsContinuation + ? currentOffset + limit + : refreshPrefix + ? Math.max(this.orderedWindow.size, limit) + : offset !== undefined + ? offset + limit + : minValues !== undefined + ? currentOffset + limit + : limit + this.orderedWindow.ensureSize(requestedPrefix) + let requiresUnboundedRefinement = this.orderedWindow.requiresFullRefinement + const changes = + !this.isBufferingForTruncate && !this.stalePublication + ? this.reconcileOrderedWindow() + : [] + + if (changes.length > 0) this.callback(changes) + + // A zero window establishes no remote demand, but it must still create the + // ordered coordinator so a later setWindow can load from the same order. + if (limit === 0) { + onLoadSubsetResult?.(true, { + where, + orderBy, + limit: 0, + subscription: this, + }) + return } - // Track row count for offset-based pagination (before sending to callback) - // Use the current count as the offset for this load - const currentOffset = this.limitedSnapshotRowCount - - // Add keys to sentKeys BEFORE calling callback to prevent race condition. - // If a change event arrives while the callback is executing, it will see - // the keys already in sentKeys and filter out duplicates correctly. - for (const change of changes) { - this.sentKeys.add(change.key) + if (!retainedPublication && this.orderedWindow.coversActiveWindow) { + // No adapter request was made. Use an impossible zero-window demand so + // direct tracking can finish without claiming another demand's outcome. + onLoadSubsetResult?.(true, { + where, + orderBy, + limit: 0, + subscription: this, + }) + return } - this.callback(changes) - - // Update the row count and last key after sending (for next call's offset/cursor) - this.limitedSnapshotRowCount = Math.max( - this.limitedSnapshotRowCount, - currentOffset + changes.length, + const boundary = activeReplacement + ? this.orderedWindow.requestBoundary() + : retainedPublication + ? this.orderedBoundary() + : this.orderedWindow.requestBoundary() + const cursorValues = + boundary?.values ?? (replayOwnsContinuation ? undefined : minValues) + const builtCursor = this.buildOrderedCursorExpressions( + orderBy, + cursorValues, + boundary?.key ?? + (replayOwnsContinuation + ? undefined + : this.orderedPublication?.boundary?.key), ) - if (changes.length > 0) { - this.lastSentKey = changes[changes.length - 1]!.key - } - - // Build cursor expressions for sync layer loadSubset - // The cursor expressions are separate from the main where clause - // so the sync layer can choose cursor-based or offset-based pagination - let cursorExpressions: - | { - whereFrom: BasicExpression - whereCurrent: BasicExpression - lastKey?: string | number - } - | undefined - - if (minValues !== undefined && minValues.length > 0) { - const whereFromCursor = buildCursor(orderBy, minValues) - - if (whereFromCursor) { - const { expression } = orderBy[0]! - const cursorMinValue = minValues[0] - - // Build the whereCurrent expression for the first orderBy column - // For Date values, we need to handle precision differences between JS (ms) and backends (μs) - // A JS Date represents a 1ms range, so we query for all values within that range - let whereCurrentCursor: BasicExpression - if (cursorMinValue instanceof Date) { - const cursorMinValuePlus1ms = new Date(cursorMinValue.getTime() + 1) - whereCurrentCursor = and( - gte(expression, new Value(cursorMinValue)), - lt(expression, new Value(cursorMinValuePlus1ms)), - ) - } else { - whereCurrentCursor = eq(expression, new Value(cursorMinValue)) - } - - cursorExpressions = { - whereFrom: whereFromCursor, - whereCurrent: whereCurrentCursor, - lastKey: this.lastSentKey, - } - } + if (builtCursor.requiresUnboundedRefinement) { + requiresUnboundedRefinement = true } // Request the sync layer to load more data // don't await it, we will load the data into the collection when it comes in // Note: `where` does NOT include cursor expressions - they are passed separately // The sync layer can choose to use cursor-based or offset-based pagination - const loadOptions: LoadSubsetOptions = { - where, // Main filter only, no cursor - limit, - orderBy, - cursor: cursorExpressions, // Cursor expressions passed separately - offset: offset ?? currentOffset, // Use provided offset, or auto-tracked offset - subscription: this, - } + const loadOptions: LoadSubsetOptions = requiresUnboundedRefinement + ? { + where, + orderBy, + subscription: this, + } + : refreshPrefix + ? { + where, + limit: requestedPrefix, + orderBy, + offset: 0, + subscription: this, + } + : { + where, // Main filter only, no cursor + limit, + orderBy, + cursor: builtCursor.cursor, // Cursor expressions passed separately + // Replay continuation is owned by the replacement generation or + // retained publication, never by stale caller hints. + offset: replayOwnsContinuation + ? currentOffset + : (offset ?? currentOffset), + subscription: this, + } - const { demand, result: syncResult } = this.startSubsetDemand(loadOptions) + const { + demand, + acquisition, + result: syncResult, + replayContext: startedReplayContext, + } = this.startSubsetDemand(loadOptions, { + requestedPrefix, + hadBoundary: boundary !== undefined || refreshPrefix, + requiresUnboundedRefinement, + revision: this.orderedWindow.coverageRevision, + }) - // Pass the raw loadSubset result to the caller for external tracking - onLoadSubsetResult?.(syncResult, demand.options) - this.observeLoadSubsetResult( + // A synchronous continuation can complete ordered coverage. Retain its + // callback before applying that evidence so callback failure can still + // restore the originating replay publication. + const replayTracksCallback = + this.retainReplayResultCallback(startedReplayContext) + + // Ordered evidence must settle before the replay barrier. The barrier and + // its status observer in turn precede arbitrary result callbacks, so a + // callback can retire authority without erasing pending work. + this.observeOrderedCoverage(syncResult, demand, acquisition) + const replayTracksResult = this.trackDemandStartedDuringReplay( + demand, syncResult, - demand.options, - shouldTrackLoadSubsetPromise, + startedReplayContext, ) + if (replayTracksResult) { + this.observeLoadSubsetResult( + syncResult, + demand.options, + shouldTrackLoadSubsetPromise, + () => false, + ) + } + if (this.ignoreObsoleteSubsetResult(demand, syncResult)) { + // Match unordered acquisition semantics: work released during adapter + // entry cannot report a result, affect readiness, or establish coverage. + this.releaseReplayResultCallback(replayTracksCallback) + return + } + demand.onLoadSubsetResult = onLoadSubsetResult + + // Pass the raw loadSubset result to the caller for external tracking + try { + this.invokeReplayResultCallback(replayTracksResult, demand.options, () => + onLoadSubsetResult?.(syncResult, demand.options), + ) + } finally { + this.releaseReplayResultCallback(replayTracksCallback) + } + if (this.ignoreObsoleteSubsetResult(demand, syncResult)) return + if (!replayTracksResult) { + this.observeLoadSubsetResult( + syncResult, + demand.options, + shouldTrackLoadSubsetPromise, + ) + } + } + + private observeOrderedCoverage( + result: LoadSubsetRequestResult, + demand: SubsetDemand, + acquisition: SubsetAcquisition, + shouldApply: () => boolean = () => true, + ): void { + const ordered = acquisition.ordered + const window = this.orderedWindow + if (!ordered || !window) return + + const mayApply = () => + shouldApply() && + this.isActiveDemand(demand) && + !acquisition.options.signal?.aborted + + const apply = (outcome?: AppliedLoadSubsetOutcome) => { + if (!mayApply()) return + + // The settled outcome is the caller-relative acquisition evidence. Its + // applied keys remain useful even when the source cannot prove an extent, + // while such unknown evidence is intentionally absent from the reusable + // coverage antichain. WindowState filters a shared covering acquisition's + // physical keys through this subscription's predicate and total order. + const rowKeys = outcome?.appliedRowKeys + const exhausted = outcome?.extent === `exhausted` + + if (outcome !== undefined && rowKeys === undefined && !exhausted) { + window.recordLocalRequestSatisfaction(ordered.requestedPrefix) + } else if (!ordered.hadBoundary && !ordered.requiresUnboundedRefinement) { + window.recordInitialCoverage(rowKeys, exhausted) + } else { + window.recordContinuationCoverage( + rowKeys, + exhausted, + ordered.requestedPrefix, + ordered.revision, + ) + } + + if (this.isBufferingForTruncate || this.stalePublication) { + const session = this.truncateReplaySession + if (session) this.checkTruncateReplayComplete(session) + return + } + const changes = this.reconcileOrderedWindow() + if (changes.length > 0) this.callback(changes) + } + + if (result instanceof Promise) { + void result.then(apply, () => {}) + } else { + if (!mayApply()) return + const hasSubsetLoader = this.collection._sync.syncLoadSubsetFn !== null + if (!hasSubsetLoader) { + // Eager sources are already complete. + window.recordContinuationCoverage( + undefined, + true, + ordered.requestedPrefix, + ordered.revision, + ) + } else { + const retainedOutcome = this.collection._sync.getLoadSubsetOutcome( + acquisition.options, + ) + if (retainedOutcome) { + apply(retainedOutcome) + return + } + window.recordLocalRequestSatisfaction(ordered.requestedPrefix) + } + if (this.isBufferingForTruncate || this.stalePublication) { + const session = this.truncateReplaySession + if (session) this.checkTruncateReplayComplete(session) + return + } + const changes = this.reconcileOrderedWindow() + if (changes.length > 0) this.callback(changes) + } } // TODO: also add similar test but that checks that it can also load it from the collection's loadSubset function @@ -1182,17 +2707,22 @@ export class CollectionSubscription private reconcileStalePublishedChanges( changes: Array>, ): Array> { - if (this.stalePublishedRows.size === 0) return changes + const staleRows = this.stalePublication?.publishedRows + if (!staleRows) return changes + if (staleRows.size === 0) { + this.stalePublication = undefined + return changes + } const reconciled: Array> = [] for (const change of changes) { - const previous = this.stalePublishedRows.get(change.key) + const previous = staleRows.get(change.key) if (previous === undefined) { reconciled.push(change) continue } - this.stalePublishedRows.delete(change.key) + staleRows.delete(change.key) if (change.type === `delete`) { reconciled.push({ ...change, @@ -1207,6 +2737,7 @@ export class CollectionSubscription }) } } + if (staleRows.size === 0) this.stalePublication = undefined return reconciled } @@ -1236,16 +2767,6 @@ export class CollectionSubscription this.sentKeys.add(change.key) } } - - // Keep the limited snapshot offset in sync with keys we've actually sent. - // This matters when loadSubset resolves asynchronously and requestLimitedSnapshot - // didn't have local rows to count yet. - if (this.orderByIndex) { - this.limitedSnapshotRowCount = Math.max( - this.limitedSnapshotRowCount, - this.sentKeys.size, - ) - } } /** @@ -1258,44 +2779,187 @@ export class CollectionSubscription } unsubscribe() { - let firstCleanupError: unknown + if ( + this.unsubscribeInProgress || + this.clearListenersAfterReplayErrors || + this.replayTeardownPending + ) { + return + } + const deferReplayFinalization = Boolean( + this.truncateReplaySession && this.hasActiveSubsetAdapterBoundary(), + ) + if (deferReplayFinalization) this.replayTeardownPending = true + this.unsubscribeInProgress = true + try { + this.unsubscribeOnce(deferReplayFinalization) + } finally { + this.unsubscribeInProgress = false + if (deferReplayFinalization) this.scheduleReplayTeardownFinalization() + } + } + + private hasActiveSubsetAdapterBoundary(): boolean { + return Boolean( + this.activeSubsetAcquisition || + this.activeSubsetCleanupBoundary || + this.activeReplayResultCallback, + ) + } + + /** Publish retained failures before final replay teardown becomes terminal. */ + private scheduleReplayTeardownFinalization(): void { + if (this.replayTeardownFinalizationScheduled) return + this.replayTeardownFinalizationScheduled = true + queueMicrotask(() => { + // Adapter code may return an already-rejected Promise. Give its observer + // one turn to retain the failure before the teardown pass runs. + queueMicrotask(() => { + this.replayTeardownFinalizationScheduled = false + if (!this.replayTeardownPending) return + if ( + this.hasActiveSubsetAdapterBoundary() || + this.unsubscribeInProgress || + this.clearListenersAfterReplayErrors + ) { + this.scheduleReplayTeardownFinalization() + return + } + this.finishReplayTeardown() + }) + }) + } + + private unsubscribeOnce(deferReplayFinalization = false): void { + // Teardown is a permanent acquisition boundary. Adapter cleanup and + // unsubscribe listeners may reenter public methods, but they cannot create + // work that escapes the cleanup pass already in progress. + this.unsubscribed = true + this.reportReplayFailuresBeforeTeardown() + const boundaryOptions = this.subsetFailureBoundaryOptions() + const cleanupFailures: Array<{ + error: unknown + occurrence?: SubsetFailureOccurrence + }> = [] + const recordCleanupError = (error: unknown) => { + cleanupFailures.push( + boundaryOptions + ? { + error, + occurrence: this.createSubsetFailureOccurrence( + boundaryOptions, + error, + ), + } + : { error }, + ) + } // Clean up truncate event listener try { this.truncateCleanup?.() } catch (error) { - firstCleanupError = error + recordCleanupError(error) } this.truncateCleanup = undefined - // Stop any buffered replay from publishing after unsubscription. - this.truncateReplaySession = undefined - this.stalePublishedRows.clear() + if (!deferReplayFinalization) { + // Stop any buffered replay from publishing after unsubscription. + this.truncateReplaySession = undefined + this.stalePublication = undefined + this.orderedPublication = undefined + } // Release the current adapter acquisition for each logical subset demand. - const failedDemands: Array = [] - for (const demand of this.subsetDemands) { - try { - this.releaseSubsetDemand(demand) - } catch (error) { - firstCleanupError ??= error - failedDemands.push(demand) + for (const demand of [...this.subsetDemands]) { + demand.active = false + const cleanup = this.captureSubsetCleanupFailures(demand.options, () => + this.releaseSubsetDemand(demand), + ) + if (cleanup.failures) { + cleanupFailures.push( + ...cleanup.failures.map((occurrence) => ({ + error: occurrence.error, + occurrence, + })), + ) + } + } + this.subsetDemands = this.subsetDemands.filter( + (demand) => + !demand.releaseSettled || demand.pendingReplayAcquisitions.size > 0, + ) + + if (!deferReplayFinalization) { + for (const error of this.finishTerminalTeardown()) { + recordCleanupError(error) } } - this.subsetDemands = failedDemands - try { - this.emitInner(`unsubscribed`, { - type: `unsubscribed`, - subscription: this, + if (cleanupFailures.length > 0) { + const cleanupError = createSubsetCleanupError( + cleanupFailures.map(({ error }) => error), + ) + const propagatedError = this.propagatedSubsetFailure(cleanupError, { + callbackBoundaryOnly: true, }) - } catch (error) { - firstCleanupError ??= error + if (!Object.is(propagatedError, cleanupError)) { + const occurrences = cleanupFailures.flatMap(({ occurrence }) => + occurrence ? [occurrence] : [], + ) + if (occurrences.length !== cleanupFailures.length) throw cleanupError + this.noteSubsetFailureGroup(undefined, { + propagatedError, + failures: occurrences, + }) + throw propagatedError + } + throw cleanupError + } + } + + private finishReplayTeardown(): void { + this.reportReplayFailuresBeforeTeardown() + this.truncateReplaySession = undefined + this.stalePublication = undefined + this.orderedPublication = undefined + const listenerErrors = this.finishTerminalTeardown() + this.replayTeardownPending = false + if (listenerErrors.length === 0) return + + const terminalError = createSubsetCleanupError(listenerErrors) + // The reentrant unsubscribe call has already returned. Preserve terminal + // listener failures through the ordinary asynchronous event-error channel. + queueMicrotask(() => { + throw terminalError + }) + } + + private finishTerminalTeardown(): ReadonlyArray { + const listenerErrors: Array = [] + try { + if (!this.terminalEventDispatched) { + // Cleanup debt may require later unsubscribe passes, but terminal + // publication is one lifecycle edge for the subscription. + this.terminalEventDispatched = true + listenerErrors.push( + ...this.emitInnerCollectErrors(`unsubscribed`, { + type: `unsubscribed`, + subscription: this, + }), + ) + } } finally { // Clear all event listeners to prevent memory leaks - this.clearListeners() + if (this.replayErrorReportDepth > 0) { + // Retained replay failures form one ordered report batch. Reentrant + // teardown may release ownership now, but it cannot erase later + // occurrences that were already retained before the first dispatch. + this.clearListenersAfterReplayErrors = true + } else { + this.clearListeners() + } } - - if (firstCleanupError !== undefined) throw firstCleanupError + return listenerErrors } } diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts index 3c4ffe0f0..b14766205 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -359,6 +359,7 @@ export class CollectionSyncManager< return pendingTransaction.applied.promise } + pendingTransaction.requestSignal = signal pendingTransaction.committed = true const cancel = () => { diff --git a/packages/db/src/errors.ts b/packages/db/src/errors.ts index 12c6753d3..c7337c0d0 100644 --- a/packages/db/src/errors.ts +++ b/packages/db/src/errors.ts @@ -814,3 +814,17 @@ export class SetWindowRequiresOrderByError extends QueryCompilationError { ) } } + +/** + * Diagnostic recorded when an ordered source says more rows exist but gives + * core neither a new cursor nor a larger demanded prefix to request. + */ +export class OrderedLoadNoProgressError extends TanStackDBError { + constructor(sourceId: string, demandedPrefix: number) { + super( + `Source '${sourceId}' made no ordered progress toward prefix ${demandedPrefix}: ` + + `the last continuing page established no new cursor.`, + ) + this.name = `OrderedLoadNoProgressError` + } +} diff --git a/packages/db/src/event-emitter.ts b/packages/db/src/event-emitter.ts index 6d7ad90aa..549fbd967 100644 --- a/packages/db/src/event-emitter.ts +++ b/packages/db/src/event-emitter.ts @@ -7,6 +7,13 @@ export class EventEmitter> { keyof TEvents, Set<(event: TEvents[keyof TEvents]) => void> >() + private onceWrappers = new Map< + keyof TEvents, + Map< + (event: TEvents[keyof TEvents]) => void, + Set<(event: TEvents[keyof TEvents]) => void> + > + >() /** * Subscribe to an event @@ -38,10 +45,37 @@ export class EventEmitter> { event: T, callback: (event: TEvents[T]) => void, ): () => void { - const unsubscribe = this.on(event, (eventPayload) => { - callback(eventPayload) + const original = callback as (event: TEvents[keyof TEvents]) => void + const wrapper = (eventPayload: TEvents[T]) => { unsubscribe() - }) + callback(eventPayload) + } + const registered = wrapper as (event: TEvents[keyof TEvents]) => void + let wrappersByCallback = this.onceWrappers.get(event) + if (!wrappersByCallback) { + wrappersByCallback = new Map() + this.onceWrappers.set(event, wrappersByCallback) + } + let wrappers = wrappersByCallback.get(original) + if (!wrappers) { + wrappers = new Set() + wrappersByCallback.set(original, wrappers) + } + wrappers.add(registered) + + const removeListener = this.on(event, wrapper) + const unsubscribe = () => { + removeListener() + const currentWrappersByCallback = this.onceWrappers.get(event) + const currentWrappers = currentWrappersByCallback?.get(original) + currentWrappers?.delete(registered) + if (currentWrappers?.size === 0) { + currentWrappersByCallback?.delete(original) + } + if (currentWrappersByCallback?.size === 0) { + this.onceWrappers.delete(event) + } + } return unsubscribe } @@ -54,7 +88,17 @@ export class EventEmitter> { event: T, callback: (event: TEvents[T]) => void, ): void { - this.listeners.get(event)?.delete(callback as (event: any) => void) + const original = callback as (event: TEvents[keyof TEvents]) => void + const listeners = this.listeners.get(event) + listeners?.delete(original) + const wrappersByCallback = this.onceWrappers.get(event) + const wrappers = wrappersByCallback?.get(original) + if (!wrappers) return + for (const wrapper of wrappers) listeners?.delete(wrapper) + wrappersByCallback?.delete(original) + if (wrappersByCallback?.size === 0) { + this.onceWrappers.delete(event) + } } /** @@ -109,10 +153,33 @@ export class EventEmitter> { }) } + /** + * Emit an event and return every listener failure to the caller. + * + * Teardown paths use this when listener failures must join a synchronous + * cleanup result. Ordinary event delivery keeps its asynchronous surfacing + * behavior through emitInner. + */ + protected emitInnerCollectErrors( + event: T, + eventPayload: TEvents[T], + ): ReadonlyArray { + const errors: Array = [] + this.listeners.get(event)?.forEach((listener) => { + try { + listener(eventPayload) + } catch (error) { + errors.push(error) + } + }) + return errors + } + /** * Clear all listeners */ protected clearListeners(): void { this.listeners.clear() + this.onceWrappers.clear() } } diff --git a/packages/db/src/load-subset-request-provenance.ts b/packages/db/src/load-subset-request-provenance.ts new file mode 100644 index 000000000..54aa62ba2 --- /dev/null +++ b/packages/db/src/load-subset-request-provenance.ts @@ -0,0 +1,68 @@ +export type SyncRequestProvenance = Readonly<{ + hasOrdinarySource: boolean + requestSignals: ReadonlySet +}> + +const requestProvenance = new WeakMap() +const parentSignals = new WeakMap>() + +/** Attach every source that produced the change's final row version. */ +export function setSyncRequestProvenance( + change: object, + provenance: SyncRequestProvenance | undefined, +): void { + if (provenance !== undefined) requestProvenance.set(change, provenance) +} + +/** Preserve internal request provenance when a change is enriched for readers. */ +export function copySyncRequestProvenance( + source: object, + target: object, +): void { + setSyncRequestProvenance(target, requestProvenance.get(source)) +} + +/** Read the sources that produced the change's final row version. */ +export function getSyncRequestProvenance( + change: object, +): SyncRequestProvenance | undefined { + return requestProvenance.get(change) +} + +/** Record that shared physical work is owned by one logical request signal. */ +export function attachLoadSubsetRequestSignal( + physicalSignal: AbortSignal | undefined, + logicalSignal: AbortSignal | undefined, +): void { + if ( + physicalSignal === undefined || + logicalSignal === undefined || + physicalSignal === logicalSignal + ) { + return + } + let parents = parentSignals.get(physicalSignal) + if (parents === undefined) { + parents = new Set() + parentSignals.set(physicalSignal, parents) + } + parents.add(logicalSignal) +} + +/** Test exact signal identity through any nested shared-request wrappers. */ +export function isLoadSubsetRequestSignalFor( + physicalSignal: AbortSignal, + logicalSignal: AbortSignal, +): boolean { + const pending = [physicalSignal] + const visited = new Set() + while (pending.length > 0) { + const signal = pending.pop()! + if (signal === logicalSignal) return true + if (visited.has(signal)) continue + visited.add(signal) + const parents = parentSignals.get(signal) + if (parents !== undefined) pending.push(...parents) + } + return false +} diff --git a/packages/db/src/query/compiler/order-by.ts b/packages/db/src/query/compiler/order-by.ts index 8595d0ff2..0166cf44f 100644 --- a/packages/db/src/query/compiler/order-by.ts +++ b/packages/db/src/query/compiler/order-by.ts @@ -6,6 +6,7 @@ import { defaultComparator, makeComparator } from '../../utils/comparison.js' import { PropRef, collectCollectionSources, followRef } from '../ir.js' import { ensureIndexForField } from '../../indexes/auto-index.js' import { findIndexForField } from '../../utils/index-optimization.js' +import { resolveCompareOptions, resolveOrderBy } from '../total-order.js' import { compileExpression } from './evaluators.js' import { replaceAggregatesByRefs } from './group-by.js' import type { CompareOptions } from '../builder/types.js' @@ -33,8 +34,6 @@ export type OrderByOptimizationInfo = { ) => number /** Extracts all orderBy column values from a raw row (array for multi-column) */ valueExtractorForRawRow: (row: Record) => unknown - /** Extracts only the first column value - used for index-based cursor */ - firstColumnValueExtractor: (row: Record) => unknown /** Index on the first orderBy column - used for lazy loading */ index?: IndexInterface dataNeeded?: () => number @@ -70,7 +69,6 @@ export function processOrderBy( compareOptions: buildCompareOptions(clause, collection), } }) - // Create a value extractor function for the orderBy operator const valueExtractor = (row: NamespacedRow & { $selected?: any }) => { // The namespaced row contains: @@ -134,14 +132,13 @@ export function processOrderBy( // Skip this optimization when using grouped ordering (includes with limit), // because the limit is per-group, not global — the child collection needs all data loaded. if ( - limit && + limit !== undefined && !groupKeyFn && rawQuery.from.type !== `unionFrom` && rawQuery.from.type !== `unionAll` ) { let index: IndexInterface | undefined let followRefCollection: Collection | undefined - let firstColumnValueExtractor: CompiledSingleRowExpression | undefined let orderByAlias: string = rawQuery.from.alias let orderBySourceId: string | undefined @@ -160,10 +157,10 @@ export function processOrderBy( followRefCollection = followRefResult.collection orderBySourceId = followRefResult.sourceId const fieldName = followRefResult.path[0] - const compareOpts = buildCompareOptions( - firstClause, - followRefCollection, - ) + // The query's first source defines implicit string collation for the + // whole order. Build the source index with that same resolved term so + // provider admission cannot disagree with emitted query order. + const compareOpts = buildCompareOptions(firstClause, collection) if (fieldName) { // Use a single-column comparator for the index, not the @@ -182,12 +179,6 @@ export function processOrderBy( ) } - // First column value extractor - used for index cursor - firstColumnValueExtractor = compileExpression( - new PropRef(followRefResult.path), - true, - ) as CompiledSingleRowExpression - index = findIndexForField( followRefCollection, followRefResult.path, @@ -222,79 +213,58 @@ export function processOrderBy( } } - // Only create comparator and value extractors if the first column is a ref expression - // For aggregate or computed expressions, we can't extract values from raw collection rows - if (!firstColumnValueExtractor) { - // Skip optimization for non-ref expressions (aggregates, computed values, etc.) - // The query will still work, but without lazy loading optimization - } else if (orderBySourceId) { - // Build value extractors for all columns (must all be ref expressions for multi-column) - // Check if all orderBy expressions are ref types (required for multi-column extraction) - const allColumnsAreRefs = orderByClause.every( - (clause) => clause.expression.type === `ref`, + if (orderBySourceId) { + // A provider sees rows from one lexical source. Push only the leading + // order terms owned by that source; a later term from an independent + // join cannot be evaluated against this adapter's row shape. Stop at + // the first foreign or computed term because terms after it are not a + // valid prefix of the query order either. + const sourceTerms: Array<{ + resolved: OrderByClause + extractor: CompiledSingleRowExpression + compare: (left: unknown, right: unknown) => number + }> = [] + const resolvedOrderBy = resolveOrderBy( + orderByClause, + collection.compareOptions, ) + for (let termIndex = 0; termIndex < orderByClause.length; termIndex++) { + const clause = orderByClause[termIndex]! + if (clause.expression.type !== `ref`) break + const followed = followRef(rawQuery, clause.expression, collection) + if (!followed || followed.sourceId !== orderBySourceId) break + const resolved = resolvedOrderBy[termIndex]! + sourceTerms.push({ + resolved, + extractor: compileExpression( + new PropRef(followed.path), + true, + ) as CompiledSingleRowExpression, + compare: makeComparator(resolved.compareOptions), + }) + } + const sourceOrderBy = sourceTerms.map(({ resolved }) => resolved) + const sourceExtractors = sourceTerms.map(({ extractor }) => extractor) - // Create extractors for all columns if they're all refs - const allColumnExtractors: - | Array - | undefined = allColumnsAreRefs - ? orderByClause.map((clause) => { - // We know it's a ref since we checked allColumnsAreRefs - const refExpr = clause.expression as PropRef - const followResult = followRef(rawQuery, refExpr, collection) - if (followResult) { - return compileExpression( - new PropRef(followResult.path), - true, - ) as CompiledSingleRowExpression - } - // Fallback for refs that don't follow - return compileExpression( - clause.expression, - true, - ) as CompiledSingleRowExpression - }) - : undefined - - // Create a comparator for raw rows (used for tracking sent values) - // This compares ALL orderBy columns for proper ordering - const comparator = ( + const compareSourceRows = ( a: Record | null | undefined, b: Record | null | undefined, ) => { - if (orderByClause.length === 1) { - // Single column: extract and compare - const extractedA = a ? firstColumnValueExtractor(a) : a - const extractedB = b ? firstColumnValueExtractor(b) : b - return compare(extractedA, extractedB) - } - if (allColumnExtractors) { - // Multi-column with all refs: extract all values and compare - const extractAll = ( - row: Record | null | undefined, - ) => { - if (!row) return row - return allColumnExtractors.map((extractor) => extractor(row)) - } - return compare(extractAll(a), extractAll(b)) + for (const { extractor, compare: compareTerm } of sourceTerms) { + const result = compareTerm(a ? extractor(a) : a, b ? extractor(b) : b) + if (result !== 0) return result } - // Fallback: can't compare (shouldn't happen since we skip non-ref cases) return 0 } // Create a value extractor for raw rows that extracts ALL orderBy column values // This is used for tracking sent values and building composite cursors const rawRowValueExtractor = (row: Record): unknown => { - if (orderByClause.length === 1) { + if (sourceExtractors.length === 1) { // Single column: return single value - return firstColumnValueExtractor(row) - } - if (allColumnExtractors) { - // Multi-column: return array of all values - return allColumnExtractors.map((extractor) => extractor(row)) + return sourceExtractors[0]!(row) } - // Fallback (shouldn't happen) - return undefined + return sourceExtractors.map((extractor) => extractor(row)) } orderByOptimizationInfo = { @@ -302,11 +272,10 @@ export function processOrderBy( alias: orderByAlias, offset: offset ?? 0, limit, - comparator, + comparator: compareSourceRows, valueExtractorForRawRow: rawRowValueExtractor, - firstColumnValueExtractor: firstColumnValueExtractor, index, - orderBy: orderByClause, + orderBy: sourceOrderBy, } // Ordered loading is owned by one lexical source. A collection can occur @@ -397,13 +366,5 @@ export function buildCompareOptions( clause: OrderByClause, collection: CollectionLike, ): CompareOptions { - if (clause.compareOptions.stringSort !== undefined) { - return clause.compareOptions - } - - return { - ...collection.compareOptions, - direction: clause.compareOptions.direction, - nulls: clause.compareOptions.nulls, - } + return resolveCompareOptions(clause, collection.compareOptions) } diff --git a/packages/db/src/query/effect.ts b/packages/db/src/query/effect.ts index d06ca714b..2390e96ac 100644 --- a/packages/db/src/query/effect.ts +++ b/packages/db/src/query/effect.ts @@ -561,6 +561,9 @@ class EffectPipelineRunner { pendingBuffers.get(sourceId)!.push(changes) } else { this.trackSentValues(sourceId, changes, orderByInfo.comparator) + if (this.subscriptions[sourceId]?.requiresOrderedPrefixRefresh) { + this.lastLoadRequestKey.delete(sourceId) + } const split = [...splitUpdates(changes)] this.handleSourceChanges(sourceId, split) } @@ -624,6 +627,16 @@ class EffectPipelineRunner { this.requestInitialOrderedSnapshot(alias, orderByInfo, subscription) } + if (orderByInfo) { + const truncateUnsubscribe = collection.on(`truncate`, () => { + this.lastLoadRequestKey.delete(sourceId) + this.biggestSentValue.delete(sourceId) + this.sentToD2KeysBySource.get(sourceId)?.clear() + this.pendingOrderedLoadPromise = undefined + }) + this.unsubscribeCallbacks.add(truncateUnsubscribe) + } + // Listen for status changes on source collections const statusUnsubscribe = collection.on(`status:change`, (event) => { if (this.disposed) return @@ -684,6 +697,9 @@ class EffectPipelineRunner { for (const changes of buffer) { if (orderByInfo) { this.trackSentValues(sourceId, changes, orderByInfo.comparator) + if (this.subscriptions[sourceId]?.requiresOrderedPrefixRefresh) { + this.lastLoadRequestKey.delete(sourceId) + } const split = [...splitUpdates(changes)] this.sendChangesToD2(sourceId, split) } else { @@ -720,6 +736,7 @@ class EffectPipelineRunner { plan: LazyDemandPlan, keys: Set, ): void { + const errorVersion = subscription.lastErrorVersion let update try { update = this.demand.setDemand(subscription, plan, keys) @@ -727,7 +744,12 @@ class EffectPipelineRunner { // 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 ( + subscription.lastErrorVersion === errorVersion || + !Object.is(subscription.lastError, error) + ) { + throw error + } if (this.starting) throw error return } @@ -942,6 +964,8 @@ class EffectPipelineRunner { limit: offset + limit, orderBy: normalizedOrderBy, trackLoadSubsetPromise: false, + onLoadSubsetResult: (result) => + this.trackOrderedLoad(result, orderByInfo.sourceId), }) } else { subscription.requestSnapshot({ @@ -973,16 +997,56 @@ class EffectPipelineRunner { )) { if (!orderByInfo.dataNeeded || !orderByInfo.index) continue + const subscription = this.subscriptions[orderByInfo.sourceId] + if (!subscription) continue + subscription.ensureOrderedWindowSize( + orderByInfo.offset + orderByInfo.limit, + ) + if (subscription.hasOrderedCoverageForActiveWindow) { + continue + } + if (this.pendingOrderedLoadPromise) { // Wait for in-flight loads to complete before requesting more continue } - const n = orderByInfo.dataNeeded() - if (n > 0) { - this.loadNextItems(orderByInfo, n) + const n = Math.max( + orderByInfo.dataNeeded(), + subscription.orderedRowsNeeded, + ) + this.loadNextItems(orderByInfo, Math.max(1, n)) + } + } + + private trackOrderedLoad( + result: LoadSubsetRequestResult, + sourceId: string, + ): void { + const continueAfterFulfillment = () => { + if (this.disposed) return + if (this.subscriptions[sourceId]?.requiresOrderedPrefixRefresh) { + this.lastLoadRequestKey.delete(sourceId) + } + this.loadMoreIfNeeded() + } + if (!(result instanceof Promise)) { + // A synchronous truncate replay notifies this observer before replay + // setup completes. Continue on the next microtask so every replacement + // demand is registered before the consumer asks for another page. + queueMicrotask(continueAfterFulfillment) + return + } + this.pendingOrderedLoadPromise = result + const finish = () => { + if (this.pendingOrderedLoadPromise === result) { + this.pendingOrderedLoadPromise = undefined } } + void result.then(() => { + finish() + continueAfterFulfillment() + }, finish) } /** @@ -1000,36 +1064,34 @@ class EffectPipelineRunner { const cursor = computeOrderedLoadCursor( orderByInfo, - this.biggestSentValue.get(sourceId), + subscription.orderedBoundaryRow, this.lastLoadRequestKey.get(sourceId), alias, n, + subscription.orderedRetainedWindowSize, + subscription.orderedBoundaryKey, ) if (!cursor) return // Duplicate request — skip this.lastLoadRequestKey.set(sourceId, cursor.loadRequestKey) + const errorVersion = subscription.lastErrorVersion try { subscription.requestLimitedSnapshot({ orderBy: cursor.normalizedOrderBy, limit: n, minValues: cursor.minValues, trackLoadSubsetPromise: false, - onLoadSubsetResult: (loadResult: LoadSubsetRequestResult) => { - // 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) - } - }, + onLoadSubsetResult: (loadResult: LoadSubsetRequestResult) => + this.trackOrderedLoad(loadResult, sourceId), }) } catch (error) { - if (subscription.lastError !== error) throw error + if ( + subscription.lastErrorVersion === errorVersion || + !Object.is(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. diff --git a/packages/db/src/query/live/ARCHITECTURE.md b/packages/db/src/query/live/ARCHITECTURE.md index 2aaeeb425..401bfe341 100644 --- a/packages/db/src/query/live/ARCHITECTURE.md +++ b/packages/db/src/query/live/ARCHITECTURE.md @@ -92,6 +92,7 @@ operators and a few boundary adapters: | Run the graph and publish root rows | `packages/db/src/query/live/collection-config-builder.ts` | | Publish Collection-valued buckets | `packages/db/src/query/live/bucket-facade-adapter.ts` | | Start and release asynchronous demand | `packages/db/src/query/live/subset-demand-controller.ts`, `packages/db/src/collection/subscription.ts` | +| Reconcile ordered source windows | `packages/db/src/query/total-order.ts`, `packages/db/src/query/live/window-state.ts` | Queries without includes keep the original compiled pipeline and do not pay for facade state. The one exception is a joined query with a custom public-key @@ -373,6 +374,309 @@ tie-breaker, normally the child public key. An order-only change is a bucket-value change for arrays, singletons, concatenation, and Collection layout. +At the asynchronous source boundary, `TotalOrder` resolves every direction, +null-placement, and string-collation option and appends the collection public +key. `WindowState` uses that order for the retained prefix, admission, refill, +and continuation boundary. Independent join demands may retain extra source +rows, but those rows do not move the ordered boundary. The D2 top-K operator +uses the same query terms and public-key tie-breaker for emitted layout. + +An adapter receives only the leading order terms owned by its lexical source. +Later terms that depend on a joined or derived row stay in D2. Since the public +key tie-breaker is local and is not part of the adapter query, a finite source +prefix is only a candidate prefix. Core expands the complete source-order +boundary class, then applies the public-key tie-break locally. Locale and +reference orders that the predicate IR cannot express fetch the full filtered +region and refine it locally. + +Every continuation boundary comes from rows established by the same ordered +demand. Rows retained for another query, join, or window cannot move it. During +a failed truncate replay, the last complete publication remains the boundary; +a partial replacement snapshot has no continuation provenance. Exact applied +row keys and source extent advance retained coverage. Public readers also keep +that last complete publication while every overlapping replacement attempt is +pending. A successful current replacement publishes its settled ordered +reconciliation once, but only after applied evidence proves the retained prefix +or authoritative exhaustion. A continuing page stays private while its next +acquisition refines that evidence. Retained window size is grow-only for the +life of the subscription, so a smaller request during replacement cannot undo a +larger prefix already requested. Failure of the current ordered demand or any +still-active demand publishes no replacement batch. + +`WindowState` owns current-generation admission and coverage. The subscription +owns the last complete reader-visible publication as one snapshot: its rows, +sent keys, and optional ordered prefix size and total-order boundary. An active +or failed replay retains that snapshot unchanged until a complete replacement +publishes or later source changes reconcile it. After failure, ordered source +changes evolve a candidate set rooted in that public snapshot under the same +predicate, retained size, and `TotalOrder`. A worse-ranked row stays private but +remains available to refill the prefix; rows installed only by the rejected +replacement never enter this set. An empty retained publication is still a +present publication and cannot collapse into absent state after an unrelated +change. Source deltas, retained-window changes, new local snapshots, and demand +release all run this one reducer because each can change the public union. A +single reducer does not erase provenance: it keeps ordered-authorized +candidates separate from rows visible only for another demand. A new local +snapshot may add the latter to the public union, but it cannot promote a row +left by the rejected generation into the ordered prefix, even when that row +matches the ordered predicate. Releasing that demand removes the row again +without changing the ordered boundary. Request-scoped adapter transactions +pass the exact acquisition signal to `commit(signal)`. Core retains that signal +as internal change provenance, so writes for an unordered demand have the same +additional-only provenance as its local snapshot even when they arrive +asynchronously. When a dedupe wrapper replaces logical request signals with a +shared physical lease signal, it records that signal lineage. Provenance tests +follow the lineage through nested wrappers instead of treating physical signal +replacement as new authority. If several same-key transactions collapse into +one visible change, provenance reduces with the row version: value-equal writes +combine their authorities, while a different later version replaces the +earlier authorities. Row metadata writes do not confer row authority because +they do not produce a new row version. The same version rule holds in a failed +publication: an additional-only update or delete of an ordered candidate +revokes that candidate's old-version authority instead of resurrecting it when +the additional demand leaves. An unsettled request does not claim +unrelated transactions merely because their lifetimes overlap. Ordinary live +source changes and ordered acquisitions may evolve the ordered candidate set. +A request-scoped write with no active ordered owner in this subscription stays +additional-only; a peer may keep its shared physical lease alive after the +local logical demand is released, but that cannot grant local ordered +authority. Logical release takes effect before adapter cleanup. If +`unloadSubset` throws, the inactive demand may remain only as cleanup debt; it +cannot filter rows, join replay, accept settlement, or supply authority. The +same rule applies to an aborted replay acquisition retained only so its exact +cleanup can be retried: matching shared-physical signal lineage does not make +that obsolete acquisition current again. A +shared physical owner may keep the row in the core collection, but the ordered +coordinator supplies no public row or continuation boundary after its last +local ordered demand leaves. Retiring that last owner clears the coordinator's +coverage and immediately retracts its exclusive public rows, even while a +truncate replacement remains in flight. Every retained replay baseline is +updated to that same public state, so retired rows cannot suppress a later +same-version insert. With no active ordered owner, ordinary source changes do +not enter the dormant ordered window or create a later cursor. Adapter cleanup +is also a reentrancy boundary: releasing one exact acquisition is idempotent. +`releaseSnapshot(where)` releases the active logical owner; internal demand +controllers also pass the acquisition's stable request signal when they must +retry cleanup for one exact inactive owner among identical predicates. Replay +handoff uses the same guard. If `unloadSubset` reenters release, the old +acquisition retires once and the new acquisition is discarded rather than +installed for the now-inactive demand. Completion removes that demand by object +identity only after its current and pending replay acquisitions have all +settled, so a callback cannot make a stale array position delete a newly-created +owner. A +failed generation also clears its private coverage evidence; a successful +ordered acquisition from that generation cannot suppress the next request when +another demand makes the whole replacement fail. Reader-visible boundaries and +failed-replay offset or cursor restoration derive from this snapshot and ignore +caller offset or cursor hints. A continuation that is still proving the active +replacement instead derives from `WindowState`'s private current-generation +progress and also ignores caller continuation hints; that progress cannot +escape through a public boundary before the replacement publishes. If that +generation has no private progress, its next request starts at offset zero +without a cursor; it must not borrow caller cursor values or the old public key. +This applies both to later continuation requests and to acquisitions rebuilt +from stored demands at truncate start: replay must reconstruct transport state +at the acquisition boundary instead of cloning the retired generation's offset +or cursor. No parallel row-count or last-key fields may approximate either +state. + +Each active demand in that replacement settles on its own, and the replacement +waits for every acquisition it started. The published reconciliation is the +retained ordered prefix plus rows required by every still-active other demand. +Releasing a demand removes its rows from that union, but does not erase that +settlement barrier or turn its cooperative abort into a replacement failure. +Starting a newer attempt aborts every older acquisition. Those obsolete +acquisitions must still settle, but their abort cannot veto a successful current +attempt. Source deltas that race the current replacement stay private until +reconciliation, then join the retained prefix when their order places them +there. A superseded attempt's rows stay private, and only the current +reconciliation may publish. Teardown discards the whole replacement epoch, so +later source writes and late settlements cannot reach public readers. Here a +publication means a change to reader-visible state; an empty transport callback +does not count as one. + +Replay settlement bookkeeping precedes observable status and error callbacks. +Acquisition replacement, ordered evidence, attempt completion, and the resulting +publication or restoration all finish before `status:ready` or +`loadSubset:error` listeners run. Reentrant listener work therefore starts in a +stable publication epoch; it cannot enter a private buffer that the same +settlement is about to discard. + +That barrier belongs to the whole replay attempt, not to one request. Errors are +retained until every attempt acquisition settles. An acquisition started by a +result callback while replay is active joins the same attempt, including its +sync throw or async rejection. Callback-created replacement work therefore +cannot leave a private epoch open after status returns to ready. +Enrollment precedes the new acquisition's own result callback. A callback may +retire its logical owner and revoke its publication or failure authority, but +the physical settlement remains part of the attempt barrier. +If callback-driven cleanup throws, replay records an attempt failure and still +finishes setup. The exact cleanup debt remains retryable; the cleanup error is +reported only after the last complete publication has been restored. +Replay captures that attempt identity before adapter entry and carries it +through settlement and result callbacks. Reentrant adapter or callback work may +start a newer attempt, but an older acquisition's failure cannot veto that +newer attempt's complete replacement. +A result callback is itself part of the captured attempt barrier. In +particular, synchronous ordered evidence cannot publish a post-setup +continuation until that callback returns; a callback or cleanup failure first +restores the prior complete publication, then reports its error. +If a nested acquisition has already attributed a failure occurrence to its +captured attempt, propagation through the containing callback does not create a +second attribution or error event. Adapter boundaries, not thrown-value +identity, distinguish occurrences: a later cleanup remains a separate failure +even when it throws the same value. An internal propagation token marks a true +rethrow across nested cleanup boundaries; it never replaces the public error +payload. A callback frame retains every boundary occurrence, so `undefined`, +`NaN`, primitives, and objects follow the same law without using payload +equality as boundary identity. +The callback frame finalizes its unique retained occurrences whether the +callback returns or throws. Catching a nested failure cannot make a replay +successful. A later distinct throw adds one callback occurrence after the +nested occurrences, while rethrowing the internal propagation token does not. +Public teardown follows the same rule: it aggregates original failure payloads +at the outermost boundary and carries occurrence records through a containing +cleanup or replay callback. Internal propagation tokens never become public +error payloads or members of a public aggregate. +Nested replay callback frames pass recognized failure groups to their +containing frame. Once a frame attributes an occurrence, containing frames may +recognize its propagation token but must not report the occurrence again. +The same rule crosses recursive acquisition starts. An intermediate +`loadSubset` that lets a nested `requestSnapshot` carrier escape must roll back +its own tentative owner and rethrow that carrier unchanged. It does not create +a failure for its own options; only the innermost adapter boundary originated +the occurrence. Promise adoption follows the same rule: if an asynchronous +intermediate acquisition rejects with that carrier, its settlement observer +does not turn the carrier into a second public failure. This is a general +promise-observer law, not a replay-only exception; cleanup and ordinary demand +paths must consume the private carrier in the same way while still completing +their status bookkeeping. +Ordinary recursive acquisition follows this law even when no cleanup or replay +callback is active. The live acquisition chain itself supplies the causal +authority: a nested failure names its unsuspended containing acquisitions as +adopters. The outermost ordinary synchronous request unwraps the carrier before +returning control to its caller. +Initial demand and replay replacement adapter entry use the same acquisition +frame boundary. Replay attempt ownership changes when the failure may publish; +it does not change which adapter boundary originated the failure. +A cleanup failure raised reentrantly inside the acquisition being started stays +as its raw payload while adapter code can catch it. The active acquisition +frame retains that occurrence, so letting the same failure escape does not +turn cleanup into a second load failure; a newly thrown value remains a new +adapter occurrence. +Carrier authority is acquisition-scoped. It records the exact containing +acquisitions that were active above the originating failure, and only those +acquisitions may consume it as adopted propagation. If adapter code retains a +carrier and later throws or rejects it from an unrelated acquisition, that is +a new boundary occurrence against the later options; the core unwraps the +original payload before reporting or throwing it. Class identity alone is not +causal provenance, and private carriers never cross a public boundary. +The carrier proves only propagation that remains inside the synchronous +callback boundary or is adopted by a promise created there. If adapter code +suspends first and starts another acquisition later, the core observes two +adapter boundaries and reports both failures. Equal payloads cannot prove that +one occurrence caused the other, so the core never deduplicates them by value. +Teardown dispatches `unsubscribed` listeners synchronously and collects their +throws after adapter cleanup failures. Ordinary event delivery keeps its +asynchronous listener-error behavior. A retained replay error batch completes +against its current listener set even if the first listener reenters teardown; +teardown defers only its global listener clear until that batch ends. Explicit +`off` and `once` removal still take effect between events. A once-listener is +indexed by both its wrapper and original callback, so `off(event, original)` +can remove it before invocation. A second unsubscribe request during that +deferred-clear interval cannot redispatch the terminal event; a later explicit +call after the batch may still retry cleanup debt. Cleanup retries never +redispatch `unsubscribed`, including to a listener registered after the first +teardown pass; terminal publication is one lifetime edge. +If teardown is requested inside an adapter-entry, cleanup, or result-callback +frame while replay is active, public acquisition authority closes immediately +and adapter cleanup still runs synchronously. Replay-session discard, the +terminal event, and listener clearing wait until the active frame stack and +already-settled promise adoption have attributed their failures. Those failures +publish once against their exact originating options before the terminal event; +failed cleanup remains retryable. Outside replay, nested teardown keeps its +ordinary synchronous aggregation contract. +If teardown cleanup fails while replay adapter entry remains active, that +acquisition frame retains the exact cleanup occurrence even when adapter code +catches the teardown throw and returns success. A successful adapter return +cannot erase a nested failure which the subscription already observed. +The same retention applies to a throwing acquisition exit. Rethrowing the +private carrier reports only the nested occurrence; throwing a distinct value +or the same public payload without that carrier adds a new outer occurrence +after every earlier nested occurrence. +`onLoadSubsetResult`, `requestSnapshot`, and `releaseSnapshot` are internal +composition APIs. A nested synchronous failure may use the private propagation +token across that callback; internal code must rethrow the caught value +unchanged. Before callback-triggered teardown discards a replay session, the +subscription merges failures already queued by sibling acquisitions with +occurrences retained by every active callback frame. It reports each unique +occurrence in creation order against its exact options before clearing the +session. An occurrence that is both queued and reachable through a callback +frame still reports once. Teardown ignores reentrant `unsubscribe()` calls +while one pass is in progress, but a later call may still retry retained +adapter cleanup debt. +One logical release may cross both a pending replacement acquisition and its +original acquisition. If several cleanup boundaries fail, the callback frame +retains them as one propagated group and reports every occurrence against its +exact acquisition options after restoration. Effect and Live Collection error +classification compares both a monotonic error occurrence and SameValue +payload identity. This distinguishes no reported error from a reported +`undefined`, while a reported `NaN` is not a new graph failure merely because +`NaN !== NaN`. +Automatic replay handoff follows the same boundary law. A failed release of the +old acquisition and a failed discard of its replacement are two reportable +occurrences, each tied to its own exact options object. Reentrant owner release +cannot make replacement cleanup appear to belong to the old acquisition. The +same rule crosses logical demands: if one adapter cleanup reentrantly releases +another demand, an automatic-cleanup frame carries every nested occurrence +through the outer callback without merging them into an aggregate event or +relabeling them as the outer acquisition. This composes recursively. An +intermediate cleanup cannot replace a deeper acquisition's provenance merely +by propagating the same payload, catching it, or throwing another error after +it; each originating cleanup remains one ordered occurrence. +A requested limit, a settled promise, or the number of requests does not. +Applied keys carry two distinct facts. Every applied source key may advance the +continuation cursor, including a row excluded by the subscription predicate. +Only applied rows admitted to that subscription's retained result prefix count +toward its covered size. Thus a short continuing page cannot turn a requested +prefix into achieved coverage, but an excluded row can still move the next +request past source data that core has already inspected. +Predicate-invisible source changes do not invalidate that visible prefix. When +an applied receipt establishes those rows, they still remain exact source +provenance and may move the continuation boundary. + +Automatic continuation is monotonic. Its identity is the retained demanded +prefix plus the exact total-order boundary, including the public key. Core may +start another request only when that prefix grows or that boundary moves. If a +continuing page establishes neither fact, core leaves the window uncovered, +does not repeat the same request, and records a nonfatal no-progress diagnostic +in `lastSubsetError`. + +Live Collections and Effects keep separate consumer-local continuation state, +but obey the same identity and reset law. A settled request remains the +no-progress guard until its demanded prefix or total-order boundary changes; +settlement alone must not permit a busy loop. Prefix refinement and truncate +revoke an active guard. A rejected Effect source is not retried in place: the +source error disposes that Effect, and teardown clears all of its guard state +before a replacement consumer can start. The consumer-parity oracle runs the +same hidden-boundary history through both implementations so neither can +silently drift from this law. + +A truncate replay remains part of the original logical demand. Its replacement +acquisition must therefore notify the same result observer that tracked the +initial acquisition. Ordered consumers treat that replay as their in-flight +load, so they cannot race it with a duplicate refill. If the replay settles +without covering the retained prefix, its observer may start exactly one +continuation after all replacement acquisitions have settled. + +An outcome-free completion (`true` or `Promise`) supplies no reusable row +provenance, source extent, or CoverageFact. Its exact request has still settled, +so the owning subscription may admit only the current local prefix. A short +page remains uncovered and triggers another pass. The admitted local boundary +may distinguish those immediate passes, but it is scheduling state, not a +transport cursor. If the window later grows, core refreshes the required prefix +from the start instead of continuing from those rows as a cursor boundary. + A bare child query is a Collection-valued include. It exposes one stable public Collection facade per active bucket in that edge: @@ -435,10 +739,35 @@ only after every attached owner has released it. A Collection subscription installs each logical subset owner before it calls the source adapter. Reentrant release during `loadSubset` must therefore see and -release that exact acquisition. A synchronous `loadSubset` throw that did not -follow a failed release rolls the tentative owner back without calling -`unloadSubset`; a failed release keeps the owner so a later cleanup can retry the -same acquisition identity. +release that exact acquisition. It also registers the caller's original +predicate before adapter entry, because the transport predicate may combine it +with the subscription predicate. After adapter return, both ordered and +unordered requests recheck logical ownership before they report results, track +loading state, establish coverage, or scan local state; a demand released +during adapter code cannot publish a later snapshot. A synchronous `loadSubset` +throw that did not follow a failed release rolls the tentative owner back before +it emits the error and without calling `unloadSubset`; a failed release keeps +the owner so a later cleanup can retry the same acquisition identity. +Result callbacks are also arbitrary reentrancy boundaries. After invoking one, +the request checks the same exact owner again before it tracks status, applies +coverage, or scans local rows. A callback may release or unsubscribe; obsolete +promises are then observed only to consume a possible rejection. + +A failed publication may retain rows owned only by unordered demands after the +last ordered owner leaves. A later ordered incarnation starts with an empty +ordered candidate set over that retained additional baseline. Its source rows +still enter the ordered admission path, so it publishes only the proven top-K +union the active unordered rows; a nonempty stale baseline cannot route them +through generic delivery. + +Unsubscription closes the acquisition boundary before adapter cleanup starts. +An `unloadSubset` callback or unsubscribe listener may reenter public request +methods, but those methods cannot start a new acquisition after teardown has +begun. Teardown attempts every owned acquisition. It rethrows one failure +unchanged, or an `AggregateError` whose ordered `errors` list retains every +failure occurrence when several boundaries fail. Repeated unsubscribe calls +may still retry each retained cleanup debt against the same acquisition +options. Its semantic contract is: @@ -525,7 +854,9 @@ ownership before it releases the prior lease. This handoff is one ownership transition from the Collection's point of view: replacing a row with the same key cannot let old-owner garbage collection delete the new value. A failed or obsolete replacement leaves the old lease in place and retires only the new -attempt. +attempt. If logical release reenters while the old lease is being retired, the +handoff must not install the replacement. It releases that replacement exactly +once and collects the inactive demand after all late cleanup succeeds. An imperative load operation reports caller-relative evidence, not merely the promises started while it was active. If a successful operation starts no new @@ -692,20 +1023,22 @@ create recursive Collection machinery. ## Executable contracts -| Contract | Test suite | -| --------------------------------------------------------------------------- | ------------------------------------------------------------------------- | -| State equivalence, route lifecycle, transition history, and batch partition | `packages/db/tests/query/includes-oracle.property.test.ts` | -| Joined multiplicity, alias identity, and null-key normalization | `packages/db/tests/query/includes-query-shape-oracle.test.ts` | -| Demand, cancellation, and progressive timing | `packages/db/tests/query/includes-temporal-oracle.test.ts` | -| Optimistic confirmation, rollback, and later reactivity | `packages/db/tests/query/includes-optimistic-oracle.property.test.ts` | -| Coherent layered publication | `packages/db/tests/query/includes-publication-oracle.test.ts` | -| Collection facades, event coherence, and route activation | `packages/db/tests/query/includes-collection-oracle.property.test.ts` | -| Correlated physical work | `packages/db/tests/query/includes-work-counter-oracle.test.ts` | -| Route-context discovery and transport across recursive and join boundaries | `packages/db/tests/query/includes-context-transport-oracle.test.ts` | -| Coverage leases, acquisitions, fact compaction, and row provenance | `packages/db/tests/query/coverage-registry-oracle.property.test.ts` | -| Applied coverage publication through the Collection sync boundary | `packages/db/tests/load-subset-outcome.test.ts` | -| Query-db ownership | `packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts` | -| Reachable nested shape | `packages/query-db-collection/tests/includes-work-counter-oracle.test.ts` | +| Contract | Test suite | +| --------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| State equivalence, route lifecycle, transition history, and batch partition | `packages/db/tests/query/includes-oracle.property.test.ts` | +| Joined multiplicity, alias identity, and null-key normalization | `packages/db/tests/query/includes-query-shape-oracle.test.ts` | +| Demand, cancellation, and progressive timing | `packages/db/tests/query/includes-temporal-oracle.test.ts` | +| Optimistic confirmation, rollback, and later reactivity | `packages/db/tests/query/includes-optimistic-oracle.property.test.ts` | +| Coherent layered publication | `packages/db/tests/query/includes-publication-oracle.test.ts` | +| Collection facades, event coherence, and route activation | `packages/db/tests/query/includes-collection-oracle.property.test.ts` | +| Correlated physical work | `packages/db/tests/query/includes-work-counter-oracle.test.ts` | +| Route-context discovery and transport across recursive and join boundaries | `packages/db/tests/query/includes-context-transport-oracle.test.ts` | +| Ordered source coverage, total boundaries, and window transitions | `packages/db/tests/query/pagination-oracle.property.test.ts` | +| Truncate replacement, retained publication, and boundary provenance | `packages/db/tests/collection-subscription-replay-oracle.property.test.ts` | +| Coverage leases, acquisitions, fact compaction, and row provenance | `packages/db/tests/query/coverage-registry-oracle.property.test.ts` | +| Applied coverage publication through the Collection sync boundary | `packages/db/tests/load-subset-outcome.test.ts` | +| Query-db ownership | `packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts` | +| Reachable nested shape | `packages/query-db-collection/tests/includes-work-counter-oracle.test.ts` | Each oracle identifies the first divergent checkpoint and compares either the whole result or one exact structural difference. Correlated-materialization diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index 08a04573e..dad987852 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -1,3 +1,4 @@ +import { OrderedLoadNoProgressError } from '../../errors.js' import { normalizeExpressionPaths, normalizeOrderByPaths, @@ -8,7 +9,6 @@ import { filterDuplicateInserts, sendChangesToInput, splitUpdates, - trackBiggestSentValue, } from './utils.js' import { SubsetDemandController } from './subset-demand-controller.js' import type { Collection } from '../../collection/index.js' @@ -35,13 +35,11 @@ export class CollectionSubscriber< TContext extends Context, TResult extends object = GetResult, > { - // Keep track of the biggest value we've sent so far (needed for orderBy optimization) - private biggest: any = undefined - // Track the most recent ordered load request key (cursor + window). // This avoids infinite loops from cached data re-writes while still allowing // window moves or new keys at the same cursor value to trigger new requests. private lastLoadRequestKey: string | undefined + private lastNoProgressRequestKey: string | undefined // Track deferred promises for subscription loading states private subscriptionLoadingPromises = new Map< @@ -221,6 +219,7 @@ export class CollectionSubscriber< plan: LazyDemandPlan, keys: Set, ): void { + const errorVersion = subscription.lastErrorVersion let update try { update = this.demand.setDemand(subscription, plan, keys) @@ -229,7 +228,12 @@ export class CollectionSubscriber< // 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 + if ( + subscription.lastErrorVersion === errorVersion || + !Object.is(subscription.lastError, error) + ) { + throw error + } const isInitialSync = this.collectionConfigBuilder.liveQueryCollection?.status === `loading` const generation = this.collectionConfigBuilder.beginDemand(plan.id) @@ -364,14 +368,18 @@ export class CollectionSubscriber< this.pendingOrderedLoadPromise = undefined } } - void result.then(finish, finish) + void result.then(() => { + finish() + const subscription = subscriptionHolder.current + if (subscription) this.loadMoreIfNeeded(subscription) + }, finish) } onLoadSubsetResult(result, demand) } this.orderedLoadSubsetResult = handleLoadSubsetResult - // Use a holder to forward-reference subscription in the callback + // Use a holder to forward-reference subscription in callbacks. const subscriptionHolder: { current?: CollectionSubscription } = {} const sendChangesInRange = ( @@ -379,8 +387,6 @@ export class CollectionSubscriber< ) => { const changesArray = Array.isArray(changes) ? changes : [...changes] - this.trackSentValues(changesArray, orderByInfo.comparator) - // Split live updates into a delete of the old value and an insert of the new value const splittedChanges = splitUpdates(changesArray) this.sendChangesToPipelineWithTracking( @@ -403,8 +409,8 @@ export class CollectionSubscriber< // This ensures that after a must-refetch/truncate, we don't use stale cursor data // and allow re-inserts of previously sent keys const truncateUnsubscribe = this.collection.on(`truncate`, () => { - this.biggest = undefined this.lastLoadRequestKey = undefined + this.lastNoProgressRequestKey = undefined this.pendingOrderedLoadPromise = undefined this.sentToD2Keys.clear() }) @@ -412,6 +418,16 @@ export class CollectionSubscriber< // Clean up truncate listener when subscription is unsubscribed subscription.on(`unsubscribed`, () => { truncateUnsubscribe() + subscriptionHolder.current = undefined + this.lastLoadRequestKey = undefined + this.lastNoProgressRequestKey = undefined + + // Ordered continuations belong to this subscription session. A settled + // load from a cleaned session must not refill through a later session. + if (this.orderedLoadSubsetResult === handleLoadSubsetResult) { + this.orderedLoadSubsetResult = undefined + this.pendingOrderedLoadPromise = undefined + } }) // Normalize the orderBy clauses such that the references are relative to the collection @@ -456,7 +472,7 @@ export class CollectionSubscriber< return true } - const { dataNeeded, index } = orderByInfo + const { dataNeeded, index, offset, limit } = orderByInfo if (!dataNeeded || !index) { // dataNeeded is not set when there's no index (e.g., non-ref expression @@ -465,27 +481,37 @@ export class CollectionSubscriber< 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) { - 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, - this.sourceId, - ) - 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. + subscription.ensureOrderedWindowSize(offset + limit) + if (subscription.hasOrderedCoverageForActiveWindow) { + return true + } + + 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, + this.sourceId, + ) + return true + } + + const n = Math.max(dataNeeded(), subscription.orderedRowsNeeded) + const errorVersion = subscription.lastErrorVersion + try { + // Local rows may fill the visible window without proving its remote + // prefix. One row is enough to request the boundary equivalence class. + this.loadNextItems(Math.max(1, n), subscription) + } catch (error) { + if ( + subscription.lastErrorVersion === errorVersion || + !Object.is(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 } @@ -528,15 +554,29 @@ export class CollectionSubscriber< const cursor = computeOrderedLoadCursor( orderByInfo, - this.biggest, + subscription.orderedBoundaryRow, this.lastLoadRequestKey, this.alias, n, + subscription.orderedRetainedWindowSize, + subscription.orderedBoundaryKey, ) - if (!cursor) return // Duplicate request — skip + if (!cursor) { + if (this.lastNoProgressRequestKey !== this.lastLoadRequestKey) { + this.lastNoProgressRequestKey = this.lastLoadRequestKey + this.collectionConfigBuilder.recordSubsetError( + new OrderedLoadNoProgressError( + this.sourceId, + subscription.orderedRetainedWindowSize, + ), + ) + } + return + } const loadRequestKey = cursor.loadRequestKey this.lastLoadRequestKey = loadRequestKey + this.lastNoProgressRequestKey = undefined // Take the `n` items after the biggest sent value // Omit offset so requestLimitedSnapshot can advance based on @@ -549,7 +589,7 @@ export class CollectionSubscriber< trackLoadSubsetPromise: false, onLoadSubsetResult: (result, demand) => { if (result instanceof Promise) { - void result.then(undefined, () => { + void result.catch(() => { if (this.lastLoadRequestKey === loadRequestKey) { this.lastLoadRequestKey = undefined } @@ -584,22 +624,6 @@ export class CollectionSubscriber< return undefined } - private trackSentValues( - changes: Array>, - comparator: (a: any, b: any) => number, - ): void { - const result = trackBiggestSentValue( - changes, - this.biggest, - this.sentToD2Keys, - comparator, - ) - this.biggest = result.biggest - if (result.shouldResetLoadKey) { - this.lastLoadRequestKey = undefined - } - } - private ensureLoadingPromise(subscription: CollectionSubscription) { if (this.subscriptionLoadingPromises.has(subscription)) { return diff --git a/packages/db/src/query/live/subset-demand-controller.ts b/packages/db/src/query/live/subset-demand-controller.ts index 7f30131f4..a753086a9 100644 --- a/packages/db/src/query/live/subset-demand-controller.ts +++ b/packages/db/src/query/live/subset-demand-controller.ts @@ -65,7 +65,10 @@ export class SubsetDemandController { } segment.abortController.abort() - subscription.releaseSnapshot(segment.where) + subscription.releaseSnapshot( + segment.where, + segment.abortController.signal, + ) } const coveredKeys = new Set( diff --git a/packages/db/src/query/live/utils.ts b/packages/db/src/query/live/utils.ts index f45177a32..b70842c39 100644 --- a/packages/db/src/query/live/utils.ts +++ b/packages/db/src/query/live/utils.ts @@ -257,6 +257,8 @@ export function computeOrderedLoadCursor( lastLoadRequestKey: string | undefined, alias: string, limit: number, + demandedPrefix = limit, + boundaryKey?: string | number, ): | { minValues: Array | undefined @@ -280,11 +282,14 @@ export function computeOrderedLoadCursor( : [extractedValues] } - // Deduplicate: skip if we already issued an identical load request + // Refill work may change its incidental page size as result rows arrive. + // Deduplicate by semantic progress instead: retained demand plus the exact + // source boundary, including its public key. const loadRequestKey = serializeValue({ minValues: minValues ?? null, + boundaryKey: boundaryKey ?? null, offset, - limit, + demandedPrefix, }) if (lastLoadRequestKey === loadRequestKey) { return undefined diff --git a/packages/db/src/query/live/window-state.ts b/packages/db/src/query/live/window-state.ts new file mode 100644 index 000000000..11335e9c2 --- /dev/null +++ b/packages/db/src/query/live/window-state.ts @@ -0,0 +1,416 @@ +import { deepEquals } from '../../utils.js' +import { compileSingleRowExpression } from '../compiler/evaluators.js' +import { TotalOrder } from '../total-order.js' +import type { CollectionImpl } from '../../collection/index.js' +import type { ChangeMessage } from '../../types.js' +import type { BasicExpression, OrderBy } from '../ir.js' +import type { TotalOrderBoundary } from '../total-order.js' + +/** + * Owns the active ordered demand and its retained local coverage. Rows outside + * the retained prefix stay in the source collection until a later window + * admits them; they never become accidental top-K candidates. + */ +export class WindowState< + TRow extends object = object, + TKey extends string | number = string | number, +> { + readonly totalOrder: TotalOrder + private activeSize: number + private retainedSize: number + private coveredSize = 0 + private hasFullCoverage = false + private needsFullRefinement = false + private needsPrefixRefresh = false + private hasInitialCoverage = false + private hasUnsettledInitialMutation = false + private revision = 0 + private readonly matchesWhere: (row: TRow) => boolean + private readonly candidateKeys = new Set() + private readonly provenanceKeys = new Set() + private readonly admittedKeys = new Set() + + constructor( + private readonly collection: CollectionImpl, + orderBy: OrderBy, + private readonly where: BasicExpression | undefined, + targetSize: number, + ) { + this.totalOrder = new TotalOrder(orderBy, collection) + const evaluateWhere = where && compileSingleRowExpression(where) + this.matchesWhere = evaluateWhere + ? (row) => evaluateWhere(row as Record) === true + : () => true + this.activeSize = targetSize + this.retainedSize = targetSize + } + + ensureSize(size: number): void { + this.activeSize = size + this.retainedSize = Math.max(this.retainedSize, size) + } + + get size(): number { + return this.activeSize + } + + get retainedPrefixSize(): number { + return this.retainedSize + } + + get localPrefixSize(): number { + return this.readPrefix().length + } + + /** The exact rows in the current retained ordered publication. */ + publicationEntries(): Array { + return this.readPrefix().map(({ key, value }) => [key, value] as const) + } + + get coversActiveWindow(): boolean { + return this.hasFullCoverage || this.coveredSize >= this.activeSize + } + + get coveredPrefixSize(): number { + return this.coveredSize + } + + /** A replacement can publish only after proving its retained prefix. */ + get coversRetainedWindow(): boolean { + return this.hasFullCoverage || this.coveredSize >= this.retainedSize + } + + get requiresFullRefinement(): boolean { + return this.needsFullRefinement + } + + get requiresPrefixRefresh(): boolean { + return this.needsPrefixRefresh + } + + get coverageRevision(): number { + return this.revision + } + + rowsNeeded(): number { + return Math.max(0, this.activeSize - this.localPrefixSize) + } + + /** Discard source-generation evidence before a truncate replacement. */ + resetCoverage(): void { + this.revision++ + this.coveredSize = 0 + this.hasFullCoverage = false + this.needsFullRefinement = false + this.needsPrefixRefresh = false + this.hasInitialCoverage = false + this.hasUnsettledInitialMutation = false + this.candidateKeys.clear() + this.provenanceKeys.clear() + this.admittedKeys.clear() + } + + recordInitialCoverage( + rowKeys: ReadonlyArray | undefined, + exhausted: boolean, + ): void { + this.hasInitialCoverage = true + if (exhausted) { + this.hasUnsettledInitialMutation = false + this.establishFullCoverage() + return + } + if (rowKeys === undefined) { + this.hasUnsettledInitialMutation = false + this.candidateKeys.clear() + this.provenanceKeys.clear() + this.needsFullRefinement = true + return + } + const appliedKeys = new Set(rowKeys) + if ( + this.hasUnsettledInitialMutation || + [...this.candidateKeys].some((key) => !appliedKeys.has(key)) + ) { + this.needsPrefixRefresh = true + } + this.hasUnsettledInitialMutation = false + // Changes may arrive after the establishing writes commit but before the + // adapter promise settles. Keep those staged keys alongside the receipt. + for (const key of rowKeys) this.candidateKeys.add(key) + } + + recordContinuationCoverage( + rowKeys: ReadonlyArray | undefined, + exhausted: boolean, + requestedPrefix: number, + requestRevision: number, + ): void { + this.hasInitialCoverage = true + if (exhausted) { + this.establishFullCoverage() + return + } + if (rowKeys === undefined) { + this.candidateKeys.clear() + this.provenanceKeys.clear() + this.coveredSize = 0 + this.needsFullRefinement = true + this.needsPrefixRefresh = false + return + } + const refreshInvalidatedPrefix = this.revision !== requestRevision + for (const key of this.candidateKeys) { + this.admittedKeys.add(key) + this.provenanceKeys.add(key) + } + this.candidateKeys.clear() + for (const key of rowKeys) { + this.admittedKeys.add(key) + this.provenanceKeys.add(key) + } + if (refreshInvalidatedPrefix) { + // Live changes that raced the continuation are visible, but its original + // request no longer proves the new prefix. Keep the result admitted and + // reacquire from the start before using any of it as a boundary. + this.coveredSize = 0 + } else { + // A requested prefix is intent, not evidence. A short continuing page + // proves only the rows that this ordered demand has actually admitted. + this.coveredSize = Math.max( + this.coveredSize, + Math.min(requestedPrefix, this.localPrefixSize), + ) + this.needsPrefixRefresh = false + } + } + + /** + * An outcome-free completion without applied row keys still says this exact + * request has settled. Admit only the current local prefix. A later expansion + * must refresh from the start because these rows are not a reusable cursor + * proof. + */ + recordLocalRequestSatisfaction(requestedPrefix: number): void { + this.candidateKeys.clear() + this.provenanceKeys.clear() + this.admittedKeys.clear() + for (const change of this.readRows(undefined, requestedPrefix)) { + this.admittedKeys.add(change.key) + } + // Outcome-free completions (`true` and Promise) do not prove + // exhaustion. Only count rows that are now present, so a short synchronous + // page can request another pass until the active prefix is actually filled. + this.coveredSize = Math.min(requestedPrefix, this.admittedKeys.size) + this.needsFullRefinement = false + this.needsPrefixRefresh = true + } + + admitChanges(changes: ReadonlyArray>): void { + if (this.hasFullCoverage) return + + // Initial applied rows remain candidates until their boundary equivalence + // class is refined. Live source changes during that request still belong + // to the same ordered prefix and must survive its later settlement. + if (this.admittedKeys.size === 0) { + if (this.hasInitialCoverage) { + if (this.updateKnownPrefix(this.candidateKeys, changes)) { + this.revision++ + this.coveredSize = 0 + this.provenanceKeys.clear() + this.needsFullRefinement = false + this.needsPrefixRefresh = true + } + return + } + for (const change of changes) { + if (change.type !== `insert` || this.candidateKeys.has(change.key)) { + this.hasUnsettledInitialMutation = true + } + if (change.type === `delete`) this.candidateKeys.delete(change.key) + else this.candidateKeys.add(change.key) + } + return + } + + const visibleChanges = changes.filter( + ({ value, previousValue }) => + this.matchesWhere(value) || + (previousValue !== undefined && this.matchesWhere(previousValue)), + ) + if ( + visibleChanges.length > 0 && + this.updateKnownPrefix(this.admittedKeys, visibleChanges) + ) { + this.revision++ + // A live change may update the visible prefix at once, but an applied + // snapshot fact does not prove the new remote boundary. Reacquire from + // the start instead of continuing from a row whose provenance changed. + this.coveredSize = 0 + this.candidateKeys.clear() + this.provenanceKeys.clear() + this.needsFullRefinement = false + this.needsPrefixRefresh = true + } + } + + private updateKnownPrefix( + knownKeys: Set, + changes: ReadonlyArray>, + ): boolean { + let invalidated = false + const possiblePrefix = new Set(knownKeys) + for (const change of changes) { + if (change.type === `delete`) { + possiblePrefix.delete(change.key) + if (knownKeys.has(change.key)) invalidated = true + continue + } + + if (knownKeys.has(change.key)) { + invalidated = true + continue + } + possiblePrefix.add(change.key) + } + + // Evaluate the final batch once. The retained prefix, not only the active + // window, must survive a temporary shrink so a later expansion is exact. + const retainedPrefix = new Set( + this.readRows(possiblePrefix, this.retainedSize).map(({ key }) => key), + ) + if ( + retainedPrefix.size !== knownKeys.size || + [...retainedPrefix].some((key) => !knownKeys.has(key)) + ) { + invalidated = true + } + knownKeys.clear() + for (const key of retainedPrefix) knownKeys.add(key) + return invalidated + } + + boundary( + staleRows?: ReadonlyMap, + ): TotalOrderBoundary | undefined { + // A failed truncate replay may have installed only part of the next + // snapshot in the source collection. Its boundary is not a continuation + // point. Keep using the last complete publication until a replay settles. + if (staleRows) { + const fallback = [...staleRows] + .sort((left, right) => this.totalOrder.compareEntries(left, right)) + .at(-1) + return fallback && this.totalOrder.boundary(fallback[1], fallback[0]) + } + + const lastPrefixRow = this.readPrefix().at(-1) + if (lastPrefixRow) { + return this.totalOrder.boundary(lastPrefixRow.value, lastPrefixRow.key) + } + return undefined + } + + requestBoundary(): TotalOrderBoundary | undefined { + // Applied source rows prove cursor progress even when this subscription's + // predicate excludes them. Keep that source boundary separate from the + // eligible rows admitted to the visible result prefix. + const hasContinuationProvenance = this.provenanceKeys.size > 0 + const rows = this.readSourceRows( + this.hasFullCoverage + ? undefined + : hasContinuationProvenance + ? this.provenanceKeys + : this.candidateKeys, + this.hasFullCoverage || !hasContinuationProvenance + ? this.retainedSize + : undefined, + ) + const lastRow = rows.at(-1) + return lastRow && this.totalOrder.boundary(lastRow.value, lastRow.key) + } + + /** + * Distinguishes automatic refill passes without claiming a reusable cursor. + * Outcome-free loads can move this local boundary while requestBoundary() + * stays empty and forces the adapter request to refresh from the start. + */ + progressBoundary(): TotalOrderBoundary | undefined { + return this.requestBoundary() ?? this.boundary() + } + + reconcile( + publishedRows: ReadonlyMap, + retainOutsideWindow?: (row: TRow) => boolean, + ): Array> { + const snapshot = this.readPrefix() + + const desired = new Map() + for (const change of snapshot) desired.set(change.key, change.value) + if (retainOutsideWindow) { + for (const [key, value] of this.collection.entries()) { + if (retainOutsideWindow(value)) desired.set(key, value) + } + } + + const changes: Array> = [] + for (const [key, previousValue] of publishedRows) { + const value = desired.get(key) + if (value === undefined) { + changes.push({ type: `delete`, key, value: previousValue }) + } else if (!deepEquals(previousValue, value)) { + changes.push({ type: `update`, key, value, previousValue }) + } + } + for (const [key, value] of desired) { + if (!publishedRows.has(key)) changes.push({ type: `insert`, key, value }) + } + return changes + } + + private readPrefix(): Array> { + if (this.admittedKeys.size === 0 && !this.hasFullCoverage) return [] + return this.readRows( + this.hasFullCoverage ? undefined : this.admittedKeys, + this.retainedSize, + ) + } + + private establishFullCoverage(): void { + this.hasFullCoverage = true + this.needsFullRefinement = false + this.needsPrefixRefresh = false + this.coveredSize = Number.POSITIVE_INFINITY + this.candidateKeys.clear() + this.provenanceKeys.clear() + this.admittedKeys.clear() + } + + private readRows( + allowedKeys: ReadonlySet | undefined, + limit?: number, + ): Array> { + const rows = this.collection.currentStateAsChanges({ + ...(this.where && { where: this.where }), + orderBy: this.totalOrder.orderBy, + }) as Array> | undefined + const allowed = + allowedKeys === undefined + ? (rows ?? []) + : (rows ?? []).filter((change) => allowedKeys.has(change.key)) + return limit === undefined ? allowed : allowed.slice(0, limit) + } + + private readSourceRows( + allowedKeys: ReadonlySet | undefined, + limit?: number, + ): Array> { + const rows = this.collection.currentStateAsChanges({ + orderBy: this.totalOrder.orderBy, + }) as Array> | undefined + const allowed = + allowedKeys === undefined + ? (rows ?? []) + : (rows ?? []).filter((change) => allowedKeys.has(change.key)) + return limit === undefined ? allowed : allowed.slice(0, limit) + } +} diff --git a/packages/db/src/query/subset-dedupe.ts b/packages/db/src/query/subset-dedupe.ts index d36ce0892..f342382e7 100644 --- a/packages/db/src/query/subset-dedupe.ts +++ b/packages/db/src/query/subset-dedupe.ts @@ -1,3 +1,4 @@ +import { attachLoadSubsetRequestSignal } from '../load-subset-request-provenance.js' import { isLoadSubsetRequestSubsumedBy, isWhereSubset, @@ -359,6 +360,7 @@ function createSharedAbortLease( } const attach = (signal: AbortSignal | undefined) => { + attachLoadSubsetRequestSignal(controller?.signal, signal) if (!signal) { hasUnabortableOwner = true return diff --git a/packages/db/src/query/total-order.ts b/packages/db/src/query/total-order.ts new file mode 100644 index 000000000..5a4629d69 --- /dev/null +++ b/packages/db/src/query/total-order.ts @@ -0,0 +1,96 @@ +import { compareKeys } from '@tanstack/db-ivm' +import { makeComparator } from '../utils/comparison.js' +import { compileSingleRowExpression } from './compiler/evaluators.js' +import type { CollectionLike, StringCollationConfig } from '../types.js' +import type { CompareOptions } from './builder/types.js' +import type { OrderBy, OrderByClause } from './ir.js' + +export type TotalOrderBoundary = + { + key: TKey + values: ReadonlyArray + } + +/** Resolve every comparison option which can affect ordered membership. */ +export function resolveOrderBy( + orderBy: OrderBy, + defaults: StringCollationConfig, +): OrderBy { + return orderBy.map((clause) => ({ + expression: clause.expression, + compareOptions: resolveCompareOptions(clause, defaults), + })) +} + +export function resolveCompareOptions( + clause: OrderByClause, + defaults: StringCollationConfig, +): CompareOptions { + if (clause.compareOptions.stringSort !== undefined) { + return clause.compareOptions + } + + return { + ...defaults, + direction: clause.compareOptions.direction, + nulls: clause.compareOptions.nulls, + } +} + +/** + * One executable total order for source rows. Query terms compare first; the + * public collection key is the final, ascending deterministic tie-breaker. + */ +export class TotalOrder< + TRow extends object = object, + TKey extends string | number = string | number, +> { + readonly orderBy: OrderBy + private readonly terms: ReadonlyArray<{ + extract: (row: TRow) => unknown + compare: (left: unknown, right: unknown) => number + }> + + constructor(orderBy: OrderBy, collection: CollectionLike) { + this.orderBy = resolveOrderBy(orderBy, collection.compareOptions) + this.terms = this.orderBy.map((clause) => ({ + extract: compileSingleRowExpression(clause.expression) as ( + row: TRow, + ) => unknown, + compare: makeComparator(clause.compareOptions), + })) + } + + values(row: TRow): Array { + return this.terms.map(({ extract }) => extract(row)) + } + + boundary(row: TRow, key: TKey): TotalOrderBoundary { + return { key, values: this.values(row) } + } + + compareEntries( + left: readonly [TKey, TRow], + right: readonly [TKey, TRow], + ): number { + for (const { extract, compare } of this.terms) { + const result = compare(extract(left[1]), extract(right[1])) + if (result !== 0) return result + } + return compareKeys(left[0], right[0]) + } + + compareBoundary( + left: TotalOrderBoundary, + right: TotalOrderBoundary, + ): number { + for (let index = 0; index < this.terms.length; index++) { + const result = this.terms[index]!.compare( + left.values[index], + right.values[index], + ) + if (result !== 0) return result + } + return compareKeys(left.key, right.key) + } +} diff --git a/packages/db/src/utils/comparison.ts b/packages/db/src/utils/comparison.ts index 2d76f699b..26aa39943 100644 --- a/packages/db/src/utils/comparison.ts +++ b/packages/db/src/utils/comparison.ts @@ -171,6 +171,9 @@ const UINT8ARRAY_NORMALIZE_THRESHOLD = 128 * and "start from the key undefined" (actual undefined value in the tree). */ export const UNDEFINED_SENTINEL = `__TS_DB_BTREE_UNDEFINED_VALUE__` +const UNORDERABLE_BTREE_SENTINEL = Object.freeze({ + kind: `tanstack-db-unorderable`, +}) /** * Normalize a value for comparison and Map key usage @@ -223,7 +226,8 @@ export function normalizeForBTree(value: any): any { if (value === undefined) { return UNDEFINED_SENTINEL } - return normalizeValue(value) + const normalized = normalizeValue(value) + return Number.isNaN(normalized) ? UNORDERABLE_BTREE_SENTINEL : normalized } /** @@ -233,11 +237,11 @@ export function areSameValueZeroEqual(a: unknown, b: unknown): boolean { return a === b || (Number.isNaN(a) && Number.isNaN(b)) } -/** - * Converts the `UNDEFINED_SENTINEL` back to `undefined`. - * Needed such that the sentinel is converted back to `undefined` before comparison. - */ +/** Converts BTree sentinels back to values understood by the comparator. */ export function denormalizeUndefined(value: any): any { + if (value === UNORDERABLE_BTREE_SENTINEL) { + return Number.NaN + } if (value === UNDEFINED_SENTINEL) { return undefined } diff --git a/packages/db/src/utils/cursor.ts b/packages/db/src/utils/cursor.ts index 322a37470..bf057cd04 100644 --- a/packages/db/src/utils/cursor.ts +++ b/packages/db/src/utils/cursor.ts @@ -1,4 +1,13 @@ -import { and, eq, gt, lt, or } from '../query/builder/functions.js' +import { + and, + eq, + gt, + isNull, + isUndefined, + lt, + not, + or, +} from '../query/builder/functions.js' import { Value } from '../query/ir.js' import type { BasicExpression, OrderBy } from '../query/ir.js' @@ -26,13 +35,6 @@ export function buildCursor( return undefined } - // For single column, just use simple gt/lt - if (orderBy.length === 1) { - const { expression, compareOptions } = orderBy[0]! - const operator = compareOptions.direction === `asc` ? gt : lt - return operator(expression, new Value(values[0])) - } - // For multi-column, build the composite cursor: // or( // gt(col1, v1), @@ -51,12 +53,11 @@ export function buildCursor( for (let j = 0; j < i; j++) { const prevClause = orderBy[j]! const prevValue = values[j] - eqConditions.push(eq(prevClause.expression, new Value(prevValue))) + eqConditions.push(buildCursorEquality(prevClause.expression, prevValue)) } // Add the comparison for the current column (respecting direction) - const operator = clause.compareOptions.direction === `asc` ? gt : lt - const comparison = operator(clause.expression, new Value(value)) + const comparison = cursorAfter(clause, value) if (eqConditions.length === 0) { // First column: just the comparison @@ -76,3 +77,60 @@ export function buildCursor( // Use reduce to combine with or() which expects exactly 2 args return clauses.reduce((acc, clause) => or(acc, clause)) } + +/** + * Whether the public predicate IR can express this boundary's comparison. + * Unsupported values must use an unbounded boundary fetch and local TotalOrder + * refinement rather than a plausible but different provider order. + */ +export function canExpressCursorOrder( + orderBy: OrderBy, + values: ReadonlyArray, +): boolean { + return orderBy.every((clause, index) => { + const value = values[index] + if (value == null) return true + if (value instanceof Date) return Number.isFinite(value.getTime()) + if (typeof value === `string`) { + return clause.compareOptions.stringSort === `lexical` + } + return ( + typeof value === `number` || + typeof value === `bigint` || + typeof value === `boolean` + ) + }) +} + +function cursorNullish(expression: BasicExpression): BasicExpression { + return or(isNull(expression), isUndefined(expression)) +} + +export function buildCursorEquality( + expression: BasicExpression, + value: unknown, +): BasicExpression { + return value == null + ? cursorNullish(expression) + : eq(expression, new Value(value)) +} + +function cursorAfter( + clause: OrderBy[number], + value: unknown, +): BasicExpression { + const { expression, compareOptions } = clause + const nullish = cursorNullish(expression) + + if (value == null) { + return compareOptions.nulls === `first` ? not(nullish) : new Value(false) + } + + const compare = + compareOptions.direction === `asc` + ? gt(expression, new Value(value)) + : lt(expression, new Value(value)) + return compareOptions.nulls === `last` + ? or(compare, nullish) + : and(not(nullish), compare) +} diff --git a/packages/db/tests/collection-events.test.ts b/packages/db/tests/collection-events.test.ts index 3e3221b43..532d0e1dc 100644 --- a/packages/db/tests/collection-events.test.ts +++ b/packages/db/tests/collection-events.test.ts @@ -1,8 +1,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createCollection } from '../src/collection/index.js' +import { EventEmitter } from '../src/event-emitter.js' import { BTreeIndex } from '../src/indexes/btree-index.js' import type { Collection } from '../src/collection/index.js' +class TestEventEmitter extends EventEmitter<{ event: { id: number } }> { + emit(id: number): void { + this.emitInner(`event`, { id }) + } + + clear(): void { + this.clearListeners() + } +} + describe(`Collection Events System`, () => { let collection: Collection let mockSync: ReturnType @@ -256,6 +267,111 @@ describe(`Collection Events System`, () => { unsubscribe() }) + + it(`removes a once listener before invoking a throwing callback`, () => { + const emitter = new TestEventEmitter() + const failure = new Error(`once listener failed`) + const deferredMicrotasks: Array = [] + const queueMicrotaskSpy = vi + .spyOn(globalThis, `queueMicrotask`) + .mockImplementation((callback) => deferredMicrotasks.push(callback)) + const listener = vi.fn(() => { + throw failure + }) + + try { + emitter.once(`event`, listener) + emitter.emit(1) + emitter.emit(2) + + expect(listener).toHaveBeenCalledTimes(1) + expect(deferredMicrotasks).toHaveLength(1) + expect(() => deferredMicrotasks[0]!()).toThrow(failure) + } finally { + queueMicrotaskSpy.mockRestore() + } + }) + + it(`removes a pending once listener through off`, () => { + const emitter = new TestEventEmitter() + const calls: Array = [] + const onceListener = vi.fn(() => calls.push(`once`)) + emitter.on(`event`, () => { + calls.push(`off`) + emitter.off(`event`, onceListener) + }) + emitter.once(`event`, onceListener) + + emitter.emit(1) + + expect(calls).toEqual([`off`]) + expect(onceListener).not.toHaveBeenCalled() + }) + + it(`removes a pending once listener through its returned unsubscribe`, () => { + const emitter = new TestEventEmitter() + const onceListener = vi.fn() + const unsubscribe = emitter.once(`event`, onceListener) + + unsubscribe() + emitter.emit(1) + + expect(onceListener).not.toHaveBeenCalled() + }) + + it(`removes every pending once registration for the same callback`, () => { + const emitter = new TestEventEmitter() + const onceListener = vi.fn() + emitter.once(`event`, onceListener) + emitter.once(`event`, onceListener) + + emitter.off(`event`, onceListener) + emitter.emit(1) + + expect(onceListener).not.toHaveBeenCalled() + }) + + it(`removes a once listener before a reentrant emission`, () => { + const emitter = new TestEventEmitter() + const observed: Array = [] + emitter.once(`event`, ({ id }) => { + observed.push(id) + emitter.emit(2) + }) + + emitter.emit(1) + + expect(observed).toEqual([1]) + }) + + it(`clears ordinary and once listeners together`, () => { + const emitter = new TestEventEmitter() + const ordinaryListener = vi.fn() + const onceListener = vi.fn() + emitter.on(`event`, ordinaryListener) + emitter.once(`event`, onceListener) + + emitter.clear() + emitter.emit(1) + + expect(ordinaryListener).not.toHaveBeenCalled() + expect(onceListener).not.toHaveBeenCalled() + }) + + it(`exposes the same pending-once removal law through Collection`, () => { + const calls: Array = [] + const onceListener = vi.fn(() => calls.push(`once`)) + collection.on(`status:change`, () => { + calls.push(`off`) + collection.off(`status:change`, onceListener) + }) + collection.once(`status:change`, onceListener) + + collection.startSyncImmediate() + + expect(calls).toEqual([`off`]) + expect(onceListener).not.toHaveBeenCalled() + }) }) describe(`Event Structure`, () => { diff --git a/packages/db/tests/collection-subscription-replay-oracle.property.test.ts b/packages/db/tests/collection-subscription-replay-oracle.property.test.ts index b11d2622e..ecec05249 100644 --- a/packages/db/tests/collection-subscription-replay-oracle.property.test.ts +++ b/packages/db/tests/collection-subscription-replay-oracle.property.test.ts @@ -1,12 +1,14 @@ import { fc, test as fcTest } from '@fast-check/vitest' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } 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 { attachLoadSubsetRequestSignal } from '../src/load-subset-request-provenance.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 { projectAtomicOrderedPublicationState } from './load-subset-full-flow-model.js' import { oracleRandomParameters, readOracleRunConfig } from './oracle-config.js' import { flushPromises } from './utils.js' import type { Collection } from '../src/collection/index.js' @@ -14,7 +16,9 @@ import type { OrderBy } from '../src/query/ir.js' import type { ChangeMessageOrDeleteKeyMessage, LoadSubsetOptions, + SyncMetadataApi, } from '../src/types.js' +import type { LoadSubsetFullFlowEvent } from './load-subset-full-flow-model.js' type ReplayRow = { id: `one` | `two` @@ -118,6 +122,244 @@ type PendingReplay = { settled: boolean } +type NestedCleanupEdge = Readonly<{ + targets: ReadonlyArray + catchFailures: boolean +}> + +type NestedCleanupGraph = Readonly<{ + id: string + ids: ReadonlyArray + edges: ReadonlyMap + failures: ReadonlyMap +}> + +async function exerciseNestedCleanupGraph({ + id, + ids, + edges, + failures, +}: NestedCleanupGraph) { + type Row = { id: string } + let begin!: () => void + let write!: (message: ChangeMessageOrDeleteKeyMessage) => void + let commit!: () => true | Promise + let truncate!: () => void + const wheres = ids.map( + (rowId) => new Func(`eq`, [new PropRef([`id`]), new Value(rowId)]), + ) + const replays = ids.map(() => createDeferred()) + const loads: Array = [] + const unloads: Array = [] + const visitedEdges = new Set() + const failedOptions = new Set() + type TestSubscription = ReturnType< + ReturnType>[`subscribeChanges`] + > + const owner: { current?: TestSubscription } = {} + const collection = createCollection({ + id, + 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) => { + loads.push(options) + const index = loads.length - 1 + if (index < ids.length) { + begin() + write({ type: `insert`, value: { id: ids[index]! } }) + commit() + return true + } + return replays[index - ids.length]!.promise + }, + unloadSubset: (options) => { + unloads.push(options) + const index = loads.indexOf(options) + const edge = edges.get(index) + if (edge && !visitedEdges.has(index)) { + visitedEdges.add(index) + for (const target of edge.targets) { + if (edge.catchFailures) { + try { + owner.current!.releaseSnapshot(wheres[target]!) + } catch { + // The graph decides whether this cleanup later throws its + // own failure or completes after handling nested failures. + } + } else { + owner.current!.releaseSnapshot(wheres[target]!) + } + } + } + const failure = failures.get(index) + if (failures.has(index) && !failedOptions.has(options)) { + failedOptions.add(options) + throw failure + } + }, + } + }, + }, + }) + const visible = new Set() + const reported: Array<{ error: unknown; optionsIndex: number }> = [] + const subscription = collection.subscribeChanges((changes) => { + for (const change of changes) { + if (change.type === `delete`) visible.delete(String(change.key)) + else visible.add(String(change.key)) + } + }) + owner.current = subscription + subscription.on(`loadSubset:error`, ({ error, options }) => + reported.push({ error, optionsIndex: loads.indexOf(options) }), + ) + + try { + for (const where of wheres) subscription.requestSnapshot({ where }) + begin() + truncate() + commit() + await flushPromises() + + for (const replay of replays) replay.resolve() + await flushPromises() + + const beforeRetry = unloads.map((options) => loads.indexOf(options)) + const status = subscription.status + const publishedIds = [...visible].sort() + subscription.unsubscribe() + const afterRetry = unloads.map((options) => loads.indexOf(options)) + return { reported, beforeRetry, afterRetry, status, publishedIds } + } finally { + subscription.unsubscribe() + await collection.cleanup() + } +} + +type ReplayCallbackCleanupMode = `return` | `rethrow` | `distinct` | `same` + +async function exerciseReplayCallbackCleanup({ + id, + nestedFailure, + outerFailure, + mode, +}: { + id: string + nestedFailure: unknown + outerFailure: unknown + mode: ReplayCallbackCleanupMode +}) { + type Row = { id: string; version: number } + const whereA = new Func(`eq`, [new PropRef([`id`]), new Value(`a`)]) + const whereB = new Func(`eq`, [new PropRef([`id`]), new Value(`b`)]) + let begin!: () => void + let write!: (message: ChangeMessageOrDeleteKeyMessage) => void + let commit!: () => true | Promise + let truncate!: () => void + const loads: Array = [] + const unloads: Array = [] + let failedB = false + let cleanupArmed = false + let callbackCount = 0 + const collection = createCollection({ + id, + 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) => { + loads.push(options) + const rowId = loads.length % 2 === 1 ? `b` : `a` + begin() + write({ + type: `insert`, + value: { id: rowId, version: loads.length }, + }) + commit() + return true + }, + unloadSubset: (options) => { + unloads.push(options) + if (cleanupArmed && options.where === whereB && !failedB) { + failedB = true + throw nestedFailure + } + }, + } + }, + }, + }) + const visible = new Map() + const reported: Array<{ error: unknown; optionsIndex: number }> = [] + const subscription = collection.subscribeChanges((changes) => { + for (const change of changes) { + if (change.type === `delete`) visible.delete(String(change.key)) + else visible.set(String(change.key), change.value) + } + }) + subscription.on(`loadSubset:error`, ({ error, options }) => + reported.push({ error, optionsIndex: loads.indexOf(options) }), + ) + + try { + subscription.requestSnapshot({ where: whereB }) + subscription.requestSnapshot({ + where: whereA, + onLoadSubsetResult: () => { + callbackCount++ + if (callbackCount !== 2) return + cleanupArmed = true + let caught: unknown + try { + subscription.releaseSnapshot(whereB) + } catch (error) { + caught = error + } + if (mode === `rethrow`) throw caught + if (mode === `distinct`) throw outerFailure + if (mode === `same`) throw nestedFailure + }, + }) + await flushPromises() + + begin() + truncate() + commit() + await flushPromises() + + const visibleVersions = [...visible] + .map(([rowId, row]) => [rowId, row.version] as const) + .sort(([left], [right]) => left.localeCompare(right)) + const beforeRetry = unloads.map((options) => loads.indexOf(options)) + subscription.unsubscribe() + const afterRetry = unloads.map((options) => loads.indexOf(options)) + return { + reported, + visibleVersions, + beforeRetry, + afterRetry, + status: subscription.status, + } + } finally { + subscription.unsubscribe() + await collection.cleanup() + } +} + const rowArbitrary: fc.Arbitrary = fc.record({ id: fc.constantFrom(`one` as const, `two` as const), value: fc.integer({ min: -2, max: 2 }), @@ -394,6 +636,18 @@ function expectSameSubsetRequest( expect(actual.offset).toBe(expected.offset) } +function expectReplayRequestToRestart( + actual: LoadSubsetOptions, + stored: LoadSubsetOptions, + expectedOffset = 0, +): void { + expect(actual.where).toBe(stored.where) + expect(actual.orderBy).toBe(stored.orderBy) + expect(actual.limit).toBe(stored.limit) + expect(actual.cursor).toBeUndefined() + expect(actual.offset).toBe(expectedOffset) +} + async function runReplayScenario(scenario: ReplayScenario): Promise { let begin!: () => void let write!: ( @@ -614,6 +868,7 @@ async function runReplayScenario(scenario: ReplayScenario): Promise { pending: Set currentAttemptIndex: number publicationCount: number + errors: Array } | undefined @@ -659,7 +914,7 @@ async function runReplayScenario(scenario: ReplayScenario): Promise { pending.deferred.resolve() } else { if (isCurrent) { - lastReportedError = pending.error + session.errors.push(pending.error) } else { expect(pending.signal?.aborted).toBe(true) } @@ -710,6 +965,7 @@ async function runReplayScenario(scenario: ReplayScenario): Promise { expect(publicationCount).toBe(session.publicationCount) } expectedPublicationCount = publicationCount + lastReportedError = session.errors.at(-1) ?? lastReportedError modelSession = undefined } else { expect(publicationCount).toBe(session.publicationCount) @@ -726,6 +982,7 @@ async function runReplayScenario(scenario: ReplayScenario): Promise { pending: new Set(), currentAttemptIndex: attemptIndex, publicationCount: expectedPublicationCount, + errors: [], } modelSession.currentAttemptIndex = attemptIndex @@ -1539,6 +1796,7 @@ async function runOptimisticReplayScenario( const { multiplier, replaySeed } = readOracleRunConfig() const generatedRuns = 30 * multiplier +const generatedTimeout = 5_000 * multiplier describe(`CollectionSubscription replay oracle`, () => { it(`aborts an in-flight initial acquisition before its replay replaces it`, async () => { @@ -1617,17 +1875,23 @@ describe(`CollectionSubscription replay oracle`, () => { } }) - it(`uses the published replacement as the baseline of a reentrant replay`, async () => { + it(`ignores ordered coverage from an initial acquisition retired by replay`, async () => { + type Outcome = { + hasMore: boolean + appliedRowKeys: ReadonlyArray + } let begin!: () => void let write!: ( message: ChangeMessageOrDeleteKeyMessage, ) => void let commit!: () => void let truncate!: () => void - let loadCount = 0 - const replayLoads: Array>> = [] + const loads: Array<{ + options: LoadSubsetOptions + deferred: ReturnType> + }> = [] const collection = createCollection({ - id: `reentrant-replay`, + id: `retired-initial-ordered-coverage`, getKey: (row) => row.id, syncMode: `on-demand`, sync: { @@ -1638,17 +1902,9 @@ describe(`CollectionSubscription replay oracle`, () => { 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) + loadSubset: (options) => { + const deferred = createDeferred() + loads.push({ options, deferred }) return deferred.promise }, unloadSubset: () => {}, @@ -1656,175 +1912,6310 @@ describe(`CollectionSubscription replay oracle`, () => { }, }, }) - const visible = new Map() - let startedNestedReplay = false + const index = collection.createIndex((row) => row.value, { + indexType: BTreeIndex, + }) + const orderBy: OrderBy = [ + { + expression: new PropRef([`value`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ] + const visible = new Set() 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() + const key = change.key as ReplayRow[`id`] + if (change.type === `delete`) visible.delete(key) + else visible.add(key) } }) + subscription.setOrderByIndex(index) try { - subscription.requestSnapshot({ optimizedOnly: false }) + subscription.requestLimitedSnapshot({ orderBy, limit: 1 }) + begin() + write({ type: `insert`, value: { id: `one`, value: 1 } }) + commit() + begin() truncate() commit() await flushPromises() + expect(loads).toHaveLength(2) + expect(loads[0]?.options.signal?.aborted).toBe(true) + begin() - write({ type: `insert`, value: { id: `one`, value: 2 } }) + write({ type: `insert`, value: { id: `two`, value: 2 } }) commit() - replayLoads[0]?.resolve() + loads[1]?.deferred.resolve({ + hasMore: true, + appliedRowKeys: [`two`], + }) await flushPromises() - expect(startedNestedReplay).toBe(true) + expect([...visible]).toEqual([]) + expect(subscription.orderedBoundaryKey).toBeUndefined() - replayLoads[1]?.reject(new Error(`nested replay failed`)) + loads[0]?.deferred.resolve({ + hasMore: false, + appliedRowKeys: [`one`], + }) await flushPromises() - begin() - write({ type: `insert`, value: { id: `one`, value: 1 } }) - commit() - expect(sortedRows(visible)).toEqual([{ id: `one`, value: 1 }]) + expect([...visible]).toEqual([]) + expect(subscription.orderedBoundaryKey).toBeUndefined() } finally { + for (const load of loads) { + load.deferred.resolve({ hasMore: false, appliedRowKeys: [] }) + } subscription.unsubscribe() await collection.cleanup() } }) - it(`ignores an aborted released demand while publishing the remaining replay`, async () => { + it(`preserves unbounded locale refinement when replaying a demand`, async () => { + type LocaleRow = { id: string; label: string } 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`, + const loads: Array = [] + const collection = createCollection({ + id: `unbounded-locale-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 + loads.push(options) + return true }, 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 index = collection.createIndex((row) => row.label, { + indexType: BTreeIndex, }) - const demandOne = new Func(`eq`, [new PropRef([`id`]), new Value(`one`)]) - const demandTwo = new Func(`eq`, [new PropRef([`id`]), new Value(`two`)]) + const orderBy: OrderBy = [ + { + expression: new PropRef([`label`]), + compareOptions: { + direction: `asc`, + nulls: `first`, + stringSort: `locale`, + locale: `en-US`, + localeOptions: { numeric: true }, + }, + }, + ] + const subscription = collection.subscribeChanges(() => {}) + subscription.setOrderByIndex(index) try { - subscription.requestSnapshot({ where: demandOne }) - subscription.requestSnapshot({ where: demandTwo }) - begin() - truncate() - commit() - await flushPromises() + subscription.requestLimitedSnapshot({ + orderBy, + limit: 1, + minValues: [`item2`], + }) + expect(loads).toHaveLength(1) + expect(loads[0]?.limit).toBeUndefined() + expect(loads[0]?.offset).toBeUndefined() + expect(loads[0]?.cursor).toBeUndefined() - 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 } }) + truncate() commit() - replays[1]?.deferred.resolve() await flushPromises() - expect(sortedRows(visible)).toEqual([{ id: `two`, value: 2 }]) - expect(subscription.lastError).toBeUndefined() + expect(loads).toHaveLength(2) + expect(loads[1]?.limit).toBeUndefined() + expect(loads[1]?.offset).toBeUndefined() + expect(loads[1]?.cursor).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 - } + it.each([`return`, `resolve`] as const)( + `does not publish ordered coverage after reentrant snapshot release: %s`, + async (resultKind) => { let begin!: () => void let write!: ( - message: ChangeMessageOrDeleteKeyMessage, + 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 }, + let releaseDuringLoad = () => {} + const loads: Array = [] + const where = new Func(`eq`, [new PropRef([`value`]), new Value(1)]) + const collection = createCollection({ + id: `reentrant-ordered-release-${resultKind}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + params.markReady() + return { + loadSubset: (options) => { + loads.push(options) + if (loads.length === 1) { + begin() + write({ type: `insert`, value: { id: `one`, value: 1 } }) + commit() + releaseDuringLoad() + } + return resultKind === `return` ? true : Promise.resolve() + }, + unloadSubset: () => {}, + } + }, + }, + }) + const index = collection.createIndex((row) => row.value, { + indexType: BTreeIndex, + }) + const orderBy: OrderBy = [ + { + expression: new PropRef([`value`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ] + const published: Array = [] + const subscription = collection.subscribeChanges( + (changes) => { + published.push(...changes) + }, + { whereExpression: where }, + ) + subscription.setOrderByIndex(index) + releaseDuringLoad = () => subscription.releaseSnapshot(where) + + try { + subscription.requestLimitedSnapshot({ orderBy, limit: 1 }) + await flushPromises() + + expect(loads).toHaveLength(1) + expect(published).toEqual([]) + + subscription.requestLimitedSnapshot({ orderBy, limit: 1 }) + await flushPromises() + expect(loads).toHaveLength(2) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }, + ) + + it.each([`return`, `resolve`, `reject`] as const)( + `does not report an ordered acquisition released during adapter entry: %s`, + async (resultKind) => { + type Row = { id: string; rank: number } + const result = createDeferred() + const loads: Array = [] + const unloads: Array = [] + let releaseDuringLoad = () => {} + const collection = createCollection({ + id: `reentrant-ordered-result-${resultKind}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: (options) => { + loads.push(options) + releaseDuringLoad() + return resultKind === `return` ? true : result.promise + }, + unloadSubset: (options) => { + unloads.push(options) + }, + } + }, + }, + }) + const where = new Func(`gte`, [new PropRef([`rank`]), new Value(0)]) + const orderBy: OrderBy = [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ] + const index = collection.createIndex((row) => row.rank, { + indexType: BTreeIndex, + }) + const subscription = collection.subscribeChanges(() => {}, { + whereExpression: where, + }) + subscription.setOrderByIndex(index) + releaseDuringLoad = () => subscription.releaseSnapshot(where) + let resultCallbackCount = 0 + + try { + subscription.requestLimitedSnapshot({ + orderBy, + limit: 1, + onLoadSubsetResult: () => { + resultCallbackCount++ + }, + }) + + expect(loads).toHaveLength(1) + expect(unloads).toEqual(loads) + expect(loads[0]?.signal?.aborted).toBe(true) + expect(resultCallbackCount).toBe(0) + expect(subscription.status).toBe(`ready`) + + if (resultKind === `reject`) { + result.reject(new Error(`obsolete ordered request`)) + } else { + result.resolve() + } + await flushPromises() + + expect(resultCallbackCount).toBe(0) + expect(subscription.status).toBe(`ready`) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }, + ) + + it.each( + ([false, true] as const).flatMap((combinedPredicate) => + ([`where`, `exact`] as const).flatMap((releaseMode) => + ([`return`, `resolve`] as const).map( + (resultKind) => [combinedPredicate, releaseMode, resultKind] as const, + ), + ), + ), + )( + `does not publish an unordered snapshot after reentrant release: combined=%s release=%s result=%s`, + async (combinedPredicate, releaseMode, resultKind) => { + type Row = { id: string; value: number } + let releaseDuringLoad = () => {} + const loads: Array = [] + const unloads: Array = [] + const requestWhere = new Func(`eq`, [new PropRef([`id`]), new Value(`a`)]) + const subscriptionWhere = combinedPredicate + ? new Func(`gte`, [new PropRef([`value`]), new Value(0)]) + : undefined + const callerAbort = new AbortController() + const collection = createCollection({ + id: `reentrant-unordered-release-${combinedPredicate}-${releaseMode}-${resultKind}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + params.begin() + params.write({ + type: `insert`, + value: { id: `a`, value: 1 }, + }) + params.commit() + params.markReady() + return { + loadSubset: (options) => { + loads.push(options) + releaseDuringLoad() + return resultKind === `return` ? true : Promise.resolve() + }, + unloadSubset: (options) => { + unloads.push(options) + }, + } + }, + }, + }) + // Start sync and retain its ordinary source row independently of the + // demand under test. The tested request must not publish that local row + // after its own acquisition releases inside loadSubset. + const sourceOwner = collection.subscribeChanges(() => {}, { + includeInitialState: false, + }) + await flushPromises() + let publicationCount = 0 + const subscription = collection.subscribeChanges( + (changes) => { + publicationCount += changes.length + }, + { whereExpression: subscriptionWhere }, + ) + releaseDuringLoad = () => + subscription.releaseSnapshot( + requestWhere, + releaseMode === `exact` ? callerAbort.signal : undefined, + ) + + try { + const requested = subscription.requestSnapshot({ + where: requestWhere, + signal: callerAbort.signal, + }) + await flushPromises() + + expect(requested).toBe(false) + expect(loads).toHaveLength(1) + expect(unloads).toEqual(loads) + expect(loads[0]?.signal?.aborted).toBe(true) + expect(publicationCount).toBe(0) + } finally { + subscription.unsubscribe() + sourceOwner.unsubscribe() + await collection.cleanup() + } + }, + ) + + it.each([`release`, `unsubscribe`] as const)( + `rolls back a synchronous start failure before reentrant error handling: %s`, + async (reentrantAction) => { + type Row = { id: string } + const failure = new Error(`load failed before acquisition`) + const loads: Array = [] + const unloads: Array = [] + const collection = createCollection({ + id: `sync-start-failure-reentrant-${reentrantAction}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: (options) => { + loads.push(options) + throw failure + }, + unloadSubset: (options) => { + unloads.push(options) + }, + } + }, + }, + }) + const where = new Func(`eq`, [new PropRef([`id`]), new Value(`a`)]) + const subscription = collection.subscribeChanges(() => {}) + subscription.on(`loadSubset:error`, () => { + if (reentrantAction === `release`) subscription.releaseSnapshot(where) + else subscription.unsubscribe() + }) + + try { + expect(() => subscription.requestSnapshot({ where })).toThrow(failure) + expect(loads).toHaveLength(1) + expect(unloads).toEqual([]) + expect(loads[0]?.signal?.aborted).toBe(true) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }, + ) + + it.each( + ([`release-where`, `release-exact`, `unsubscribe`] as const).flatMap( + (action) => + ([`return`, `resolve`, `reject`] as const).map( + (resultKind) => [action, resultKind] as const, + ), + ), + )( + `does not continue an unordered snapshot after result-callback ownership loss: %s %s`, + async (action, resultKind) => { + type Row = { id: string; value: number } + const result = createDeferred() + const loads: Array = [] + const unloads: Array = [] + const callerAbort = new AbortController() + const collection = createCollection({ + id: `unordered-result-callback-${action}-${resultKind}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + params.begin() + params.write({ type: `insert`, value: { id: `a`, value: 1 } }) + params.commit() + params.markReady() + return { + loadSubset: (options) => { + loads.push(options) + return resultKind === `return` ? true : result.promise + }, + unloadSubset: (options) => { + unloads.push(options) + }, + } + }, + }, + }) + const sourceOwner = collection.subscribeChanges(() => {}, { + includeInitialState: false, + }) + await flushPromises() + const requestWhere = new Func(`eq`, [new PropRef([`id`]), new Value(`a`)]) + const subscriptionWhere = new Func(`gte`, [ + new PropRef([`value`]), + new Value(0), + ]) + let publicationCount = 0 + const statuses: Array = [] + const subscription = collection.subscribeChanges( + (changes) => { + publicationCount += changes.length + }, + { whereExpression: subscriptionWhere }, + ) + subscription.on(`status:change`, ({ status }) => statuses.push(status)) + + try { + const requested = subscription.requestSnapshot({ + where: requestWhere, + signal: callerAbort.signal, + onLoadSubsetResult: () => { + if (action === `unsubscribe`) { + subscription.unsubscribe() + } else { + subscription.releaseSnapshot( + requestWhere, + action === `release-exact` ? callerAbort.signal : undefined, + ) + } + }, + }) + + expect(requested).toBe(false) + expect(loads).toHaveLength(1) + expect(unloads).toEqual(loads) + expect(loads[0]?.signal?.aborted).toBe(true) + expect(publicationCount).toBe(0) + expect(statuses).toEqual([]) + + if (resultKind === `reject`) { + result.reject(new Error(`obsolete unordered result`)) + } else { + result.resolve() + } + await flushPromises() + + expect(publicationCount).toBe(0) + expect(statuses).toEqual([]) + expect(subscription.status).toBe(`ready`) + } finally { + subscription.unsubscribe() + sourceOwner.unsubscribe() + await collection.cleanup() + } + }, + ) + + it.each( + ([`release`, `unsubscribe`] as const).flatMap((action) => + ([`return`, `resolve`, `reject`] as const).map( + (resultKind) => [action, resultKind] as const, + ), + ), + )( + `does not track an ordered result after its callback releases ownership: %s %s`, + async (action, resultKind) => { + type Row = { id: string; rank: number } + const result = createDeferred() + const loads: Array = [] + const unloads: Array = [] + const collection = createCollection({ + id: `ordered-result-callback-${action}-${resultKind}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: (options) => { + loads.push(options) + return resultKind === `return` ? true : result.promise + }, + unloadSubset: (options) => { + unloads.push(options) + }, + } + }, + }, + }) + const where = new Func(`gte`, [new PropRef([`rank`]), new Value(0)]) + const orderBy: OrderBy = [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ] + const index = collection.createIndex((row) => row.rank, { + indexType: BTreeIndex, + }) + const statuses: Array = [] + const subscription = collection.subscribeChanges(() => {}, { + whereExpression: where, + }) + subscription.setOrderByIndex(index) + subscription.on(`status:change`, ({ status }) => statuses.push(status)) + let resultCallbackCount = 0 + + try { + subscription.requestLimitedSnapshot({ + orderBy, + limit: 1, + onLoadSubsetResult: () => { + resultCallbackCount++ + if (action === `release`) subscription.releaseSnapshot(where) + else subscription.unsubscribe() + }, + }) + + expect(resultCallbackCount).toBe(1) + expect(loads).toHaveLength(1) + expect(unloads).toEqual(loads) + expect(loads[0]?.signal?.aborted).toBe(true) + expect(statuses).toEqual([]) + + if (resultKind === `reject`) { + result.reject(new Error(`obsolete ordered result`)) + } else { + result.resolve() + } + await flushPromises() + + expect(resultCallbackCount).toBe(1) + expect(statuses).toEqual([]) + expect(subscription.status).toBe(`ready`) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }, + ) + + it.each([`asc`, `desc`] as const)( + `keeps failed ordered replay deltas inside the retained top-K window: %s`, + async (direction) => { + type Row = { id: `a` | `b` | `z`; rank: number } + type Outcome = { + hasMore: boolean + appliedRowKeys: ReadonlyArray + } + let begin!: () => void + let write!: ( + message: ChangeMessageOrDeleteKeyMessage, + ) => void + let commit!: () => void + let truncate!: () => void + let loadCount = 0 + const replay = createDeferred() + const collection = createCollection({ + id: `failed-ordered-top-k-delta-${direction}`, + 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: `a`, rank: 1 } }) + commit() + return Promise.resolve({ + hasMore: false, + appliedRowKeys: [`a`] as const, + }) + } + return replay.promise + }, + unloadSubset: () => {}, + } + }, + }, + }) + const ascendingIndex = collection.createIndex((row) => row.rank, { + indexType: BTreeIndex, + }) + const index = + direction === `asc` ? ascendingIndex : new ReverseIndex(ascendingIndex) + const orderBy: OrderBy = [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction, nulls: `first` }, + }, + ] + const visible = new Set() + const batches: Array> = [] + const subscription = collection.subscribeChanges((changes) => { + batches.push(changes.map(({ key }) => key as Row[`id`])) + for (const change of changes) { + const key = change.key as Row[`id`] + if (change.type === `delete`) visible.delete(key) + else visible.add(key) + } + }) + subscription.setOrderByIndex(index) + + try { + subscription.requestLimitedSnapshot({ orderBy, limit: 1 }) + await flushPromises() + expect([...visible]).toEqual([`a`]) + + begin() + truncate() + commit() + await flushPromises() + replay.reject(new Error(`ordered replay failed`)) + await flushPromises() + + const batchesBeforeDelta = batches.length + // Reconfirm the retained public row in the new source generation. This + // must not emit a duplicate, but it makes a later source delete real. + begin() + write({ type: `insert`, value: { id: `a`, rank: 1 } }) + commit() + begin() + write({ + type: `insert`, + value: { id: `z`, rank: direction === `asc` ? 100 : -100 }, + }) + commit() + + expect([...visible]).toEqual([`a`]) + expect(batches).toHaveLength(batchesBeforeDelta) + expect(subscription.orderedBoundaryKey).toBe(`a`) + + begin() + write({ + type: `insert`, + value: { id: `b`, rank: direction === `asc` ? 0 : 2 }, + }) + commit() + expect([...visible]).toEqual([`b`]) + expect(subscription.orderedBoundaryKey).toBe(`b`) + + begin() + write({ type: `delete`, key: `b` }) + commit() + expect([...visible]).toEqual([`a`]) + expect(subscription.orderedBoundaryKey).toBe(`a`) + + begin() + write({ type: `delete`, key: `a` }) + commit() + expect([...visible]).toEqual([`z`]) + expect(subscription.orderedBoundaryKey).toBe(`z`) + + begin() + write({ type: `insert`, value: { id: `a`, rank: 1 } }) + commit() + expect([...visible]).toEqual([`a`]) + + subscription.ensureOrderedWindowSize(2) + expect([...visible]).toEqual([`a`, `z`]) + expect(subscription.orderedBoundaryKey).toBe(`z`) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }, + ) + + it(`retains an empty failed ordered publication across invisible deltas`, async () => { + type Row = { + id: `private` | `invisible` + rank: number + route: `visible` | `invisible` + } + type Outcome = { + hasMore: boolean + appliedRowKeys: ReadonlyArray + } + let begin!: () => void + let write!: ( + message: ChangeMessageOrDeleteKeyMessage, + ) => void + let commit!: () => void + let truncate!: () => void + let loadCount = 0 + const replay = createDeferred() + const laterLoad = createDeferred() + const loadOptions: Array = [] + const collection = createCollection({ + id: `empty-failed-ordered-invisible-delta`, + 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++ + loadOptions.push(options) + if (loadCount === 1) { + return Promise.resolve({ + hasMore: false, + appliedRowKeys: [] as const, + }) + } + return loadCount === 2 ? replay.promise : laterLoad.promise + }, + unloadSubset: () => {}, + } + }, + }, + }) + const index = collection.createIndex((row) => row.rank, { + indexType: BTreeIndex, + }) + const orderBy: OrderBy = [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ] + const where = new Func(`eq`, [new PropRef([`route`]), new Value(`visible`)]) + let publishedChangeCount = 0 + const subscription = collection.subscribeChanges( + (changes) => { + publishedChangeCount += changes.length + }, + { whereExpression: where }, + ) + subscription.setOrderByIndex(index) + + try { + subscription.requestLimitedSnapshot({ orderBy, limit: 1 }) + await flushPromises() + expect(publishedChangeCount).toBe(0) + expect(subscription.orderedBoundaryKey).toBeUndefined() + + begin() + truncate() + commit() + await flushPromises() + begin() + write({ + type: `insert`, + value: { id: `private`, rank: 10, route: `visible` }, + }) + commit() + replay.reject(new Error(`ordered replay failed`)) + await flushPromises() + + begin() + write({ + type: `insert`, + value: { id: `invisible`, rank: 0, route: `invisible` }, + }) + commit() + + subscription.requestLimitedSnapshot({ orderBy, limit: 1 }) + expect(loadOptions).toHaveLength(3) + expect(loadOptions[2]).toMatchObject({ offset: 0 }) + expect(loadOptions[2]?.cursor).toBeUndefined() + expect(subscription.orderedBoundaryKey).toBeUndefined() + expect(publishedChangeCount).toBe(0) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it.each([ + `sync`, + `async`, + `ordinary`, + `deduplicated`, + `mixed-equal-batch`, + `mixed-replacement-batch`, + `mixed-metadata-batch`, + `mixed-request-metadata-batch`, + `deduplicated-after-release`, + `deduplicated-after-failed-release`, + ] as const)( + `reconciles failed ordered publications for coverage and sibling-demand changes: %s`, + async (writeTiming) => { + type Row = { id: `a` | `x` | `y`; rank: number } + type Outcome = { + hasMore: boolean + appliedRowKeys: ReadonlyArray + } + let begin!: () => void + let write!: ( + message: ChangeMessageOrDeleteKeyMessage, + ) => void + let commit!: (signal?: AbortSignal) => true | Promise + let truncate!: () => void + let metadata!: SyncMetadataApi + let loadCount = 0 + let throwOnUnload = false + const loadOptions: Array = [] + const replayLoads: Array>> = [] + let siblingLoad: ReturnType> | undefined + let deduplicatedOptions: LoadSubsetOptions | undefined + const publishSiblingRow = (signal: AbortSignal | undefined) => { + const outcome = { + hasMore: false, + appliedRowKeys: [`x`] as const, + } + begin() + write({ type: `update`, value: { id: `x`, rank: -1 } }) + commit(signal) + return outcome + } + const deduplicatedSiblingLoad = new DeduplicatedLoadSubset({ + loadSubset: (options) => { + deduplicatedOptions = options + if ( + writeTiming === `mixed-equal-batch` || + writeTiming === `mixed-replacement-batch` || + writeTiming === `mixed-metadata-batch` || + writeTiming === `mixed-request-metadata-batch` || + writeTiming === `deduplicated-after-release` || + writeTiming === `deduplicated-after-failed-release` + ) { + siblingLoad = createDeferred() + return siblingLoad.promise + } + return Promise.resolve(publishSiblingRow(options.signal)) + }, + }) + const collection = createCollection({ + id: `failed-ordered-sibling-demand`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + truncate = params.truncate + metadata = params.metadata! + params.markReady() + return { + loadSubset: (options) => { + loadOptions.push(options) + loadCount++ + if (loadCount === 1) { + begin() + write({ type: `insert`, value: { id: `a`, rank: 1 } }) + commit() + return Promise.resolve({ + hasMore: false, + appliedRowKeys: [`a`] as const, + }) + } + if ( + loadCount === 5 || + ((writeTiming === `deduplicated-after-release` || + writeTiming === `deduplicated-after-failed-release`) && + loadCount === 6) + ) { + if (writeTiming === `ordinary`) { + siblingLoad = createDeferred() + return siblingLoad.promise + } + if ( + writeTiming === `deduplicated` || + writeTiming === `mixed-equal-batch` || + writeTiming === `mixed-replacement-batch` || + writeTiming === `mixed-metadata-batch` || + writeTiming === `mixed-request-metadata-batch` || + writeTiming === `deduplicated-after-release` || + writeTiming === `deduplicated-after-failed-release` + ) { + return deduplicatedSiblingLoad.loadSubset(options) + } + return writeTiming === `sync` + ? Promise.resolve(publishSiblingRow(options.signal)) + : Promise.resolve().then(() => + publishSiblingRow(options.signal), + ) + } + if (loadCount === 2 || loadCount > 5) { + return Promise.resolve({ + hasMore: false, + appliedRowKeys: [] as const, + }) + } + const deferred = createDeferred() + replayLoads.push(deferred) + return deferred.promise + }, + unloadSubset: () => { + if (throwOnUnload) throw new Error(`release failed`) + }, + } + }, + }, + }) + const index = collection.createIndex((row) => row.rank, { + indexType: BTreeIndex, + }) + const orderBy: OrderBy = [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ] + const seedSiblingWhere = new Func(`eq`, [ + new PropRef([`id`]), + new Value(`a`), + ]) + const visible = new Set() + const subscription = collection.subscribeChanges((changes) => { + for (const change of changes) { + const key = change.key as Row[`id`] + if (change.type === `delete`) visible.delete(key) + else visible.add(key) + } + }) + subscription.setOrderByIndex(index) + let peerSubscription: + | ReturnType + | undefined + + try { + subscription.requestLimitedSnapshot({ orderBy, limit: 1 }) + subscription.requestSnapshot({ where: seedSiblingWhere }) + await flushPromises() + expect([...visible]).toEqual([`a`]) + + begin() + truncate() + commit() + await flushPromises() + expect(replayLoads).toHaveLength(2) + + begin() + write({ type: `insert`, value: { id: `x`, rank: 0 } }) + commit() + replayLoads[0]?.resolve({ + hasMore: false, + appliedRowKeys: [`x`], + }) + replayLoads[1]?.reject(new Error(`sibling replay failed`)) + await flushPromises() + + expect([...visible]).toEqual([`a`]) + expect.soft(subscription.hasOrderedCoverageForActiveWindow).toBe(false) + expect.soft(subscription.orderedBoundaryKey).toBe(`a`) + + const xWhere = new Func(`eq`, [new PropRef([`id`]), new Value(`x`)]) + subscription.requestSnapshot({ where: xWhere }) + await flushPromises() + if (writeTiming === `ordinary`) { + begin() + write({ type: `update`, value: { id: `x`, rank: -1 } }) + commit() + siblingLoad?.reject(new Error(`sibling acquisition failed`)) + await flushPromises() + } else if ( + writeTiming === `mixed-equal-batch` || + writeTiming === `mixed-replacement-batch` || + writeTiming === `mixed-metadata-batch` || + writeTiming === `mixed-request-metadata-batch` + ) { + const hold = createDeferred() + const transaction = createTransaction({ + mutationFn: () => hold.promise, + }) + transaction.mutate(() => collection.insert({ id: `y`, rank: 1_000 })) + await flushPromises() + + begin() + write({ type: `update`, value: { id: `x`, rank: -1 } }) + const firstReceipt = commit( + writeTiming === `mixed-metadata-batch` + ? deduplicatedOptions?.signal + : undefined, + ) + begin() + if ( + writeTiming === `mixed-metadata-batch` || + writeTiming === `mixed-request-metadata-batch` + ) { + metadata.row.set(`x`, { + source: + writeTiming === `mixed-metadata-batch` + ? `ordinary-metadata` + : `request-metadata`, + }) + } else { + write({ + type: `update`, + value: { + id: `x`, + rank: writeTiming === `mixed-equal-batch` ? -1 : -2, + }, + }) + } + const secondReceipt = commit( + writeTiming === `mixed-metadata-batch` + ? undefined + : deduplicatedOptions?.signal, + ) + + hold.resolve() + await transaction.isPersisted.promise + if (firstReceipt !== true) await firstReceipt + if (secondReceipt !== true) await secondReceipt + siblingLoad?.resolve({ hasMore: false, appliedRowKeys: [`x`] }) + await flushPromises() + } else if ( + writeTiming === `deduplicated-after-release` || + writeTiming === `deduplicated-after-failed-release` + ) { + const localLogicalSignal = loadOptions.at(-1)?.signal + peerSubscription = collection.subscribeChanges(() => {}) + peerSubscription.requestSnapshot({ where: xWhere }) + await flushPromises() + + const hold = createDeferred() + const transaction = createTransaction({ + mutationFn: () => hold.promise, + }) + transaction.mutate(() => collection.insert({ id: `y`, rank: 1_000 })) + await flushPromises() + + begin() + write({ type: `update`, value: { id: `x`, rank: -1 } }) + const requestReceipt = commit(deduplicatedOptions?.signal) + if (writeTiming === `deduplicated-after-failed-release`) { + throwOnUnload = true + expect(() => subscription.releaseSnapshot(xWhere)).toThrow( + `release failed`, + ) + throwOnUnload = false + expect(localLogicalSignal?.aborted).toBe(true) + expect(deduplicatedOptions?.signal?.aborted).toBe(false) + } else { + subscription.releaseSnapshot(xWhere) + } + + hold.resolve() + await transaction.isPersisted.promise + if (requestReceipt !== true) await requestReceipt + siblingLoad?.resolve({ hasMore: false, appliedRowKeys: [`x`] }) + await flushPromises() + } + const hasOrdinaryAuthority = + writeTiming === `ordinary` || + writeTiming === `mixed-equal-batch` || + writeTiming === `mixed-request-metadata-batch` + const expectedBoundary = hasOrdinaryAuthority ? `x` : `a` + const releasedBeforeApplication = + writeTiming === `deduplicated-after-release` || + writeTiming === `deduplicated-after-failed-release` + expect + .soft([...visible].sort()) + .toEqual(releasedBeforeApplication ? [`a`] : [`a`, `x`]) + expect.soft(subscription.orderedBoundaryKey).toBe(expectedBoundary) + + subscription.releaseSnapshot(xWhere) + expect + .soft([...visible].sort()) + .toEqual(hasOrdinaryAuthority ? [`a`, `x`] : [`a`]) + expect.soft(subscription.orderedBoundaryKey).toBe(expectedBoundary) + + subscription.requestSnapshot({ where: xWhere }) + await flushPromises() + expect([...visible].sort()).toEqual([`a`, `x`]) + + begin() + write({ type: `insert`, value: { id: `y`, rank: 200 } }) + commit() + expect.soft([...visible].sort()).toEqual([`a`, `x`]) + + subscription.requestLimitedSnapshot({ orderBy, limit: 1 }) + await flushPromises() + expect.soft(loadOptions.at(-1)).toMatchObject({ + offset: 1, + cursor: { lastKey: expectedBoundary }, + }) + } finally { + throwOnUnload = false + subscription.unsubscribe() + peerSubscription?.unsubscribe() + await collection.cleanup() + } + }, + ) + + it(`does not grant ordered authority to an aborted replay retained for cleanup`, async () => { + type Row = { id: string; rank: number } + type Outcome = { + hasMore: boolean + appliedRowKeys: ReadonlyArray + } + let begin!: () => void + let write!: (message: ChangeMessageOrDeleteKeyMessage) => void + let commit!: (signal?: AbortSignal) => true | Promise + let truncate!: () => void + const replay = createDeferred() + const loads: Array = [] + const physical = new AbortController() + let failCleanup = false + const collection = createCollection({ + id: `aborted-replay-cleanup-authority`, + 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) => { + loads.push(options) + if (loads.length === 1) { + begin() + write({ type: `insert`, value: { id: `a`, rank: 1 } }) + commit(options.signal) + return Promise.resolve({ + hasMore: false, + appliedRowKeys: [`a`] as const, + }) + } + attachLoadSubsetRequestSignal(physical.signal, options.signal) + return replay.promise + }, + unloadSubset: () => { + if (failCleanup) throw new Error(`cleanup failed`) + }, + } + }, + }, + }) + const where = new Func(`gte`, [new PropRef([`rank`]), new Value(0)]) + const orderBy: OrderBy = [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ] + const index = collection.createIndex((row) => row.rank, { + indexType: BTreeIndex, + }) + const visible = new Map() + const subscription = collection.subscribeChanges( + (changes) => { + for (const change of changes) { + const key = String(change.key) + if (change.type === `delete`) visible.delete(key) + else visible.set(key, change.value) + } + }, + { whereExpression: where }, + ) + subscription.setOrderByIndex(index) + + try { + subscription.requestLimitedSnapshot({ orderBy, limit: 1 }) + await flushPromises() + + begin() + truncate() + commit() + await flushPromises() + failCleanup = true + replay.resolve({ hasMore: false, appliedRowKeys: [] }) + await flushPromises() + + expect([...visible.keys()]).toEqual([`a`]) + expect(subscription.orderedBoundaryKey).toBe(`a`) + expect(loads[1]?.signal?.aborted).toBe(true) + expect(physical.signal.aborted).toBe(false) + + begin() + write({ type: `insert`, value: { id: `x`, rank: 0 } }) + const receipt = commit(physical.signal) + if (receipt !== true) await receipt + await flushPromises() + + expect([...visible.keys()]).toEqual([`a`]) + expect(subscription.orderedBoundaryKey).toBe(`a`) + } finally { + failCleanup = false + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it(`publishes a replay replacement before reporting ready`, async () => { + type Row = { id: string; rank: number } + type Outcome = { + hasMore: boolean + appliedRowKeys: ReadonlyArray + } + let begin!: () => void + let write!: (message: ChangeMessageOrDeleteKeyMessage) => void + let commit!: (signal?: AbortSignal) => true | Promise + let truncate!: () => void + const replay = createDeferred() + const loads: Array = [] + const collection = createCollection({ + id: `replay-ready-after-publication`, + 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) => { + loads.push(options) + if (loads.length === 1) { + begin() + write({ type: `insert`, value: { id: `a`, rank: 1 } }) + commit(options.signal) + return Promise.resolve({ + hasMore: false, + appliedRowKeys: [`a`] as const, + }) + } + return replay.promise + }, + unloadSubset: () => {}, + } + }, + }, + }) + const orderBy: OrderBy = [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ] + const index = collection.createIndex((row) => row.rank, { + indexType: BTreeIndex, + }) + const visible = new Map() + const readyObservations: Array<{ + keys: ReadonlyArray + boundary: string | number | undefined + }> = [] + const subscription = collection.subscribeChanges((changes) => { + for (const change of changes) { + const key = String(change.key) + if (change.type === `delete`) visible.delete(key) + else visible.set(key, change.value) + } + }) + subscription.setOrderByIndex(index) + subscription.on(`status:ready`, () => { + readyObservations.push({ + keys: [...visible.keys()], + boundary: subscription.orderedBoundaryKey, + }) + }) + + try { + subscription.requestLimitedSnapshot({ orderBy, limit: 1 }) + await flushPromises() + expect([...visible.keys()]).toEqual([`a`]) + readyObservations.length = 0 + + begin() + truncate() + commit() + await flushPromises() + begin() + write({ type: `insert`, value: { id: `x`, rank: 0 } }) + const receipt = commit(loads[1]?.signal) + if (receipt !== true) await receipt + + replay.resolve({ hasMore: false, appliedRowKeys: [`x`] }) + await flushPromises() + + expect(readyObservations).toEqual([{ keys: [`x`], boundary: `x` }]) + expect([...visible.keys()]).toEqual([`x`]) + expect(subscription.orderedBoundaryKey).toBe(`x`) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it.each( + ([`throw`, `reject`] as const).flatMap((failureKind) => + ([`reentrant`, `next-turn`] as const).map( + (listenerTiming) => [failureKind, listenerTiming] as const, + ), + ), + )( + `preserves demand started by a replay error listener: %s %s`, + async (failureKind, listenerTiming) => { + type Row = { id: string; rank: number } + type Outcome = { + hasMore: boolean + appliedRowKeys: ReadonlyArray + } + let begin!: () => void + let write!: ( + message: ChangeMessageOrDeleteKeyMessage, + ) => void + let commit!: (signal?: AbortSignal) => true | Promise + let truncate!: () => void + const replay = createDeferred() + const loads: Array = [] + const collection = createCollection({ + id: `replay-error-demand-${failureKind}-${listenerTiming}`, + 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) => { + loads.push(options) + if (loads.length === 1) { + begin() + write({ type: `insert`, value: { id: `a`, rank: 1 } }) + commit(options.signal) + return Promise.resolve({ + hasMore: false, + appliedRowKeys: [`a`] as const, + }) + } + if (loads.length === 2) { + if (failureKind === `throw`) { + throw new Error(`replay failed`) + } + return replay.promise + } + begin() + write({ type: `insert`, value: { id: `x`, rank: 0 } }) + commit(options.signal) + return true + }, + unloadSubset: () => {}, + } + }, + }, + }) + const orderedWhere = new Func(`gte`, [ + new PropRef([`rank`]), + new Value(0), + ]) + const additionalWhere = new Func(`eq`, [ + new PropRef([`id`]), + new Value(`x`), + ]) + const orderBy: OrderBy = [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ] + const index = collection.createIndex((row) => row.rank, { + indexType: BTreeIndex, + }) + const visible = new Map() + const subscription = collection.subscribeChanges( + (changes) => { + for (const change of changes) { + const key = String(change.key) + if (change.type === `delete`) visible.delete(key) + else visible.set(key, change.value) + } + }, + { whereExpression: orderedWhere }, + ) + subscription.setOrderByIndex(index) + let errorCount = 0 + subscription.on(`loadSubset:error`, () => { + errorCount++ + const requestAdditional = () => { + subscription.requestSnapshot({ + where: additionalWhere, + optimizedOnly: false, + }) + } + if (listenerTiming === `next-turn`) queueMicrotask(requestAdditional) + else requestAdditional() + }) + + try { + subscription.requestLimitedSnapshot({ orderBy, limit: 1 }) + await flushPromises() + begin() + truncate() + commit() + await flushPromises() + if (failureKind === `reject`) { + replay.reject(new Error(`replay failed`)) + } + await flushPromises() + + expect(errorCount).toBe(1) + expect([...visible.keys()].sort()).toEqual([`a`, `x`]) + expect(subscription.orderedBoundaryKey).toBe(`a`) + + subscription.releaseSnapshot(additionalWhere) + expect([...visible.keys()]).toEqual([`a`]) + expect(subscription.orderedBoundaryKey).toBe(`a`) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }, + ) + + it.each( + ([`first`, `last`] as const).flatMap((rejectionOrder) => + ([`reentrant`, `next-turn`] as const).map( + (listenerTiming) => [rejectionOrder, listenerTiming] as const, + ), + ), + )( + `restores a multi-demand replay before reporting its error: %s %s`, + async (rejectionOrder, listenerTiming) => { + type Row = { id: string; value: number } + const whereA = new Func(`eq`, [new PropRef([`id`]), new Value(`a`)]) + const whereB = new Func(`eq`, [new PropRef([`id`]), new Value(`b`)]) + const whereX = new Func(`eq`, [new PropRef([`id`]), new Value(`x`)]) + const replayA = createDeferred() + const replayB = createDeferred() + let replaying = false + let begin!: () => void + let write!: ( + message: ChangeMessageOrDeleteKeyMessage, + ) => void + let commit!: (signal?: AbortSignal) => true | Promise + let truncate!: () => void + const loads: Array = [] + const collection = createCollection({ + id: `multi-demand-replay-error-${rejectionOrder}-${listenerTiming}`, + 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) => { + loads.push(options) + if (options.where === whereX) { + begin() + write({ type: `insert`, value: { id: `x`, value: 3 } }) + return commit(options.signal) + } + if (replaying) { + return options.where === whereA + ? replayA.promise + : replayB.promise + } + const id = options.where === whereA ? `a` : `b` + begin() + write({ + type: `insert`, + value: { id, value: id === `a` ? 1 : 2 }, + }) + return commit(options.signal) + }, + unloadSubset: () => {}, + } + }, + }, + }) + const visible = new Map() + const errorObservations: Array> = [] + const subscription = collection.subscribeChanges((changes) => { + for (const change of changes) { + const key = String(change.key) + if (change.type === `delete`) visible.delete(key) + else visible.set(key, change.value) + } + }) + subscription.on(`loadSubset:error`, () => { + const recover = () => { + subscription.requestSnapshot({ where: whereX, optimizedOnly: false }) + errorObservations.push([...visible.keys()].sort()) + } + if (listenerTiming === `next-turn`) queueMicrotask(recover) + else recover() + }) + + try { + subscription.requestSnapshot({ where: whereA }) + subscription.requestSnapshot({ where: whereB }) + await flushPromises() + expect([...visible.keys()].sort()).toEqual([`a`, `b`]) + + replaying = true + begin() + truncate() + commit() + await flushPromises() + + if (rejectionOrder === `first`) { + replayA.reject(new Error(`first replay demand failed`)) + await flushPromises() + expect(errorObservations).toEqual([]) + replayB.resolve() + } else { + replayB.resolve() + await flushPromises() + expect(errorObservations).toEqual([]) + replayA.reject(new Error(`last replay demand failed`)) + } + await flushPromises() + + expect(errorObservations).toEqual([[`a`, `b`, `x`]]) + expect([...visible.keys()].sort()).toEqual([`a`, `b`, `x`]) + expect(loads).toHaveLength(5) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }, + ) + + it.each([`return`, `throw`, `resolve`, `reject`] as const)( + `settles callback-created ordered replay replacement in the same epoch: %s`, + async (replacementResult) => { + type Row = { id: string; rank: number } + type Outcome = { + hasMore: boolean + appliedRowKeys: ReadonlyArray + } + const where = new Func(`gte`, [new PropRef([`rank`]), new Value(0)]) + const whereX = new Func(`eq`, [new PropRef([`id`]), new Value(`x`)]) + const orderBy: OrderBy = [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ] + const replacement = createDeferred() + let begin!: () => void + let write!: ( + message: ChangeMessageOrDeleteKeyMessage, + ) => void + let commit!: (signal?: AbortSignal) => true | Promise + let truncate!: () => void + const loads: Array = [] + const collection = createCollection({ + id: `callback-replay-replacement-${replacementResult}`, + 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) => { + loads.push(options) + if (!options.orderBy) { + begin() + write({ type: `insert`, value: { id: `x`, rank: 2 } }) + commit(options.signal) + return true + } + if (loads.length === 1) { + begin() + write({ type: `insert`, value: { id: `a`, rank: 1 } }) + commit(options.signal) + return Promise.resolve({ + hasMore: false, + appliedRowKeys: [`a`] as const, + }) + } + if (loads.length === 2) return true + + begin() + write({ type: `insert`, value: { id: `y`, rank: 1 } }) + commit(options.signal) + if (replacementResult === `return`) return true + if (replacementResult === `throw`) { + throw new Error(`replacement failed`) + } + return replacement.promise + }, + unloadSubset: () => {}, + } + }, + }, + }) + const index = collection.createIndex((row) => row.rank, { + indexType: BTreeIndex, + }) + const visible = new Map() + const errorObservations: Array> = [] + let callbackCount = 0 + const subscription = collection.subscribeChanges( + (changes) => { + for (const change of changes) { + const key = String(change.key) + if (change.type === `delete`) visible.delete(key) + else visible.set(key, change.value) + } + }, + { whereExpression: where }, + ) + subscription.setOrderByIndex(index) + subscription.on(`loadSubset:error`, () => { + subscription.requestSnapshot({ where: whereX, optimizedOnly: false }) + errorObservations.push([...visible.keys()].sort()) + }) + + try { + subscription.requestLimitedSnapshot({ + orderBy, + limit: 1, + onLoadSubsetResult: () => { + callbackCount++ + if (callbackCount !== 2) return + subscription.releaseSnapshot(where) + subscription.requestLimitedSnapshot({ orderBy, limit: 1 }) + }, + }) + await flushPromises() + begin() + truncate() + commit() + await flushPromises() + + if (replacementResult === `resolve`) { + replacement.resolve({ hasMore: false, appliedRowKeys: [`y`] }) + } else if (replacementResult === `reject`) { + replacement.reject(new Error(`replacement failed`)) + } + await flushPromises() + + if (replacementResult === `return` || replacementResult === `resolve`) { + expect(errorObservations).toEqual([]) + expect([...visible.keys()]).toEqual([`y`]) + expect(subscription.orderedBoundaryKey).toBe(`y`) + + begin() + write({ type: `insert`, value: { id: `z`, rank: 0 } }) + commit() + await flushPromises() + expect([...visible.keys()]).toEqual([`z`]) + expect(subscription.orderedBoundaryKey).toBe(`z`) + } else { + expect(errorObservations).toEqual([[`x`]]) + expect([...visible.keys()]).toEqual([`x`]) + } + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }, + ) + + it.each( + ([`unordered`, `ordered`] as const).flatMap((demandKind) => + ([`error`, `nan`, `non-latest`] as const).map( + (failureValue) => [demandKind, failureValue] as const, + ), + ), + )( + `reports one error when a callback-created start failure propagates: %s %s`, + async (demandKind, failureValue) => { + type Row = { id: string; rank: number; version: number } + const whereOuter = new Func(`eq`, [new PropRef([`id`]), new Value(`a`)]) + const whereNested = new Func(`eq`, [ + new PropRef([`id`]), + new Value(`nested`), + ]) + const whereNestedSecond = new Func(`eq`, [ + new PropRef([`id`]), + new Value(`nested-second`), + ]) + const orderBy: OrderBy = [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ] + const startError: unknown = + failureValue === `nan` + ? Number.NaN + : new Error(`callback-created start failed`) + const secondStartError = new Error(`second callback-created start failed`) + let begin!: () => void + let write!: ( + message: ChangeMessageOrDeleteKeyMessage, + ) => void + let commit!: (signal?: AbortSignal) => true | Promise + let truncate!: () => void + let outerLoadCount = 0 + let callbackCount = 0 + const nestedOptions: Array = [] + const collection = createCollection({ + id: `propagated-callback-start-failure-${demandKind}-${failureValue}`, + 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 (options.where === whereNested) { + nestedOptions.push(options) + throw startError + } + if (options.where === whereNestedSecond) { + nestedOptions.push(options) + throw secondStartError + } + outerLoadCount++ + begin() + write({ + type: `insert`, + value: { id: `a`, rank: 1, version: outerLoadCount }, + }) + commit(options.signal) + return outerLoadCount === 1 ? Promise.resolve() : true + }, + unloadSubset: () => {}, + } + }, + }, + }) + const index = collection.createIndex((row) => row.rank, { + indexType: BTreeIndex, + }) + const visible = new Map() + const errors: Array<{ error: unknown; options: LoadSubsetOptions }> = [] + const subscription = collection.subscribeChanges((changes) => { + for (const change of changes) { + const key = String(change.key) + if (change.type === `delete`) visible.delete(key) + else visible.set(key, change.value) + } + }) + subscription.setOrderByIndex(index) + subscription.on(`loadSubset:error`, ({ error, options }) => + errors.push({ error, options }), + ) + const onLoadSubsetResult = () => { + callbackCount++ + if (callbackCount !== 2) return + if (failureValue !== `non-latest`) { + subscription.requestSnapshot({ where: whereNested }) + return + } + let propagatedStartFailure: unknown + try { + subscription.requestSnapshot({ where: whereNested }) + } catch (error) { + propagatedStartFailure = error + } + try { + subscription.requestSnapshot({ where: whereNestedSecond }) + } catch { + // Both attributed failures remain attached to their own options. + } + throw propagatedStartFailure + } + + try { + if (demandKind === `ordered`) { + subscription.requestLimitedSnapshot({ + orderBy, + limit: 1, + onLoadSubsetResult, + }) + } else { + subscription.requestSnapshot({ + where: whereOuter, + onLoadSubsetResult, + }) + } + await flushPromises() + expect(visible.get(`a`)?.version).toBe(1) + + begin() + truncate() + commit() + await flushPromises() + + const expectedErrors = + failureValue === `non-latest` + ? [startError, secondStartError] + : [startError] + expect(errors).toHaveLength(expectedErrors.length) + for (const [observationIndex, error] of expectedErrors.entries()) { + expect(Object.is(errors[observationIndex]?.error, error)).toBe(true) + expect(errors[observationIndex]?.options).toBe( + nestedOptions[observationIndex], + ) + } + expect(subscription.status).toBe(`ready`) + expect(visible.get(`a`)?.version).toBe(1) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }, + ) + + it.each( + ( + [`ordinary`, `cleanup`, `replay-entry`, `replay-callback`] as const + ).flatMap((originContext) => + ([`sync`, `async`] as const).map( + (propagation) => [originContext, propagation] as const, + ), + ), + )( + `reports one originating failure through recursive %s starts: %s`, + async (originContext, propagation) => { + type Row = { id: string } + const whereOuter = new Func(`eq`, [new PropRef([`id`]), new Value(`a`)]) + const whereMiddle = new Func(`eq`, [ + new PropRef([`id`]), + new Value(`middle`), + ]) + const whereInner = new Func(`eq`, [ + new PropRef([`id`]), + new Value(`inner`), + ]) + const failure = new Error(`recursive callback-created start failed`) + const errors: Array<{ error: unknown; options: LoadSubsetOptions }> = [] + let begin!: () => void + let commit!: () => true | Promise + let truncate!: () => void + let callbackCount = 0 + let outerLoadCount = 0 + let innerOptions: LoadSubsetOptions | undefined + const collection = createCollection({ + id: `recursive-start-failure-${originContext}-${propagation}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + begin = params.begin + commit = params.commit + truncate = params.truncate + params.markReady() + return { + loadSubset: (options) => { + if (options.where === whereInner) { + innerOptions = options + throw failure + } + if (options.where === whereOuter) { + outerLoadCount++ + if ( + originContext === `replay-entry` && + outerLoadCount === 2 + ) { + if (propagation === `async`) { + return (async () => { + requestInner() + await Promise.resolve() + })() + } + requestInner() + } + } + if (options.where === whereMiddle) { + if (propagation === `async`) { + return (async () => { + requestInner() + await Promise.resolve() + })() + } + requestInner() + } + return true + }, + unloadSubset: (options) => { + if ( + originContext === `cleanup` && + options.where === whereOuter + ) { + requestMiddle() + } + }, + } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}) + const requestInner = () => + subscription.requestSnapshot({ where: whereInner }) + const requestMiddle = () => + subscription.requestSnapshot({ where: whereMiddle }) + subscription.on(`loadSubset:error`, ({ error, options }) => { + errors.push({ error, options }) + }) + + try { + let thrown: unknown + try { + if (originContext === `ordinary`) { + requestMiddle() + } else if (originContext === `cleanup`) { + subscription.requestSnapshot({ where: whereOuter }) + subscription.releaseSnapshot(whereOuter) + } else { + subscription.requestSnapshot({ + where: whereOuter, + onLoadSubsetResult: () => { + callbackCount++ + if ( + originContext === `replay-callback` && + callbackCount === 2 + ) { + requestMiddle() + } + }, + }) + begin() + truncate() + commit() + } + } catch (error) { + thrown = error + } + await flushPromises() + + if ( + originContext === `cleanup` || + (originContext === `ordinary` && propagation === `sync`) + ) { + expect(Object.is(thrown, failure)).toBe(true) + } else { + expect(thrown).toBeUndefined() + } + expect(errors).toEqual([{ error: failure, options: innerOptions }]) + expect(subscription.lastError).toBe(failure) + expect(subscription.lastErrorVersion).toBe(1) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }, + ) + + it.each( + ([`unordered`, `ordered`] as const).flatMap((demandKind) => + ( + [ + `distinct-error`, + `shared-error`, + `undefined`, + `nan`, + `string`, + ] as const + ).map((failureValues) => [demandKind, failureValues] as const), + ), + )( + `attributes nested start and exact cleanup as separate callback failures: %s %s`, + async (demandKind, failureValues) => { + type Row = { id: string; rank: number; version: number } + const whereOuter = new Func(`eq`, [new PropRef([`id`]), new Value(`a`)]) + const whereNested = new Func(`eq`, [ + new PropRef([`id`]), + new Value(`nested`), + ]) + const whereCleanup = new Func(`eq`, [ + new PropRef([`id`]), + new Value(`cleanup`), + ]) + const orderBy: OrderBy = [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ] + const startError: unknown = + failureValues === `undefined` + ? undefined + : failureValues === `nan` + ? Number.NaN + : failureValues === `string` + ? `shared failure` + : new Error(`nested start failed`) + const cleanupError: unknown = + failureValues === `distinct-error` + ? new Error(`exact cleanup failed`) + : startError + let begin!: () => void + let write!: ( + message: ChangeMessageOrDeleteKeyMessage, + ) => void + let commit!: (signal?: AbortSignal) => true | Promise + let truncate!: () => void + let outerLoadCount = 0 + let callbackCount = 0 + let cleanupArmed = false + let cleanupThrowCount = 0 + let nestedOptions: LoadSubsetOptions | undefined + let cleanupOptions: LoadSubsetOptions | undefined + const collection = createCollection({ + id: `callback-failure-occurrence-${demandKind}-${failureValues}`, + 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 (options.where === whereNested) { + nestedOptions = options + throw startError + } + if ( + options.where === whereOuter || + options.orderBy !== undefined + ) { + outerLoadCount++ + begin() + write({ + type: `insert`, + value: { + id: `a`, + rank: 1, + version: outerLoadCount, + }, + }) + commit(options.signal) + } + return true + }, + unloadSubset: (options) => { + if ( + cleanupArmed && + options.where === whereCleanup && + cleanupThrowCount === 0 + ) { + cleanupThrowCount++ + cleanupOptions = options + throw cleanupError + } + }, + } + }, + }, + }) + const index = collection.createIndex((row) => row.rank, { + indexType: BTreeIndex, + }) + const visible = new Map() + const errors: Array<{ error: unknown; options: LoadSubsetOptions }> = [] + const subscription = collection.subscribeChanges((changes) => { + for (const change of changes) { + const key = String(change.key) + if (change.type === `delete`) visible.delete(key) + else visible.set(key, change.value) + } + }) + subscription.setOrderByIndex(index) + subscription.on(`loadSubset:error`, ({ error, options }) => + errors.push({ error, options }), + ) + + const onLoadSubsetResult = () => { + callbackCount++ + if (callbackCount !== 2) return + try { + subscription.requestSnapshot({ where: whereNested }) + } catch { + // The callback frame retains the attributed start failure while the + // later cleanup supplies the propagated boundary token. + } + cleanupArmed = true + subscription.releaseSnapshot(whereCleanup) + } + + try { + subscription.requestSnapshot({ where: whereCleanup }) + if (demandKind === `ordered`) { + subscription.requestLimitedSnapshot({ + orderBy, + limit: 1, + onLoadSubsetResult, + }) + } else { + subscription.requestSnapshot({ + where: whereOuter, + onLoadSubsetResult, + }) + } + await flushPromises() + expect(visible.get(`a`)?.version).toBe(1) + + begin() + truncate() + commit() + await flushPromises() + + expect(errors).toHaveLength(2) + expect(Object.is(errors[0]?.error, startError)).toBe(true) + expect(errors[0]?.options).toBe(nestedOptions) + expect(Object.is(errors[1]?.error, cleanupError)).toBe(true) + expect(errors[1]?.options).toBe(cleanupOptions) + expect(cleanupThrowCount).toBe(1) + expect(subscription.status).toBe(`ready`) + expect(visible.get(`a`)?.version).toBe(1) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }, + ) + + it.each( + ([`unordered`, `ordered`] as const).flatMap((demandKind) => + ([`distinct`, `shared`] as const).map( + (failureValues) => [demandKind, failureValues] as const, + ), + ), + )( + `reports every acquisition cleanup failure from one replay callback release: %s %s`, + async (demandKind, failureValues) => { + type Row = { id: string; rank: number; version: number } + const where = new Func(`eq`, [new PropRef([`id`]), new Value(`a`)]) + const orderBy: OrderBy = [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ] + const replay = createDeferred() + const sharedFailure = new Error(`shared cleanup failure`) + const replayFailure = + failureValues === `shared` + ? sharedFailure + : new Error(`replay cleanup failed`) + const initialFailure = + failureValues === `shared` + ? sharedFailure + : new Error(`initial cleanup failed`) + let begin!: () => void + let write!: ( + message: ChangeMessageOrDeleteKeyMessage, + ) => void + let commit!: (signal?: AbortSignal) => true | Promise + let truncate!: () => void + let loadCount = 0 + let callbackCount = 0 + const loads: Array = [] + const unloads: Array = [] + const failedOnce = new Set() + const collection = createCollection({ + id: `multi-cleanup-callback-${demandKind}-${failureValues}`, + 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) => { + loads.push(options) + loadCount++ + if (loadCount === 1) { + begin() + write({ + type: `insert`, + value: { id: `a`, rank: 1, version: 1 }, + }) + commit(options.signal) + return Promise.resolve() + } + return replay.promise + }, + unloadSubset: (options) => { + unloads.push(options) + if (failedOnce.has(options)) return + failedOnce.add(options) + if (options === loads[1]) throw replayFailure + if (options === loads[0]) throw initialFailure + }, + } + }, + }, + }) + const visible = new Map() + const errorObservations: Array<{ + error: unknown + options: LoadSubsetOptions + visibleVersion: number | undefined + }> = [] + const subscription = collection.subscribeChanges( + (changes) => { + for (const change of changes) { + const key = String(change.key) + if (change.type === `delete`) visible.delete(key) + else visible.set(key, change.value) + } + }, + { whereExpression: where }, + ) + subscription.on(`loadSubset:error`, ({ error, options }) => { + errorObservations.push({ + error, + options, + visibleVersion: visible.get(`a`)?.version, + }) + }) + if (demandKind === `ordered`) { + const index = collection.createIndex((row) => row.rank, { + indexType: BTreeIndex, + }) + subscription.setOrderByIndex(index) + } + const onLoadSubsetResult = () => { + callbackCount++ + if (callbackCount === 2) subscription.releaseSnapshot(where) + } + + try { + if (demandKind === `ordered`) { + subscription.requestLimitedSnapshot({ + orderBy, + limit: 1, + onLoadSubsetResult, + }) + } else { + subscription.requestSnapshot({ where, onLoadSubsetResult }) + } + await flushPromises() + expect(visible.get(`a`)?.version).toBe(1) + + begin() + truncate() + commit() + await flushPromises() + replay.resolve() + await flushPromises() + await flushPromises() + + expect(errorObservations).toHaveLength(2) + expect(Object.is(errorObservations[0]?.error, replayFailure)).toBe(true) + expect(errorObservations[0]?.options).toBe(loads[1]) + expect(Object.is(errorObservations[1]?.error, initialFailure)).toBe( + true, + ) + expect(errorObservations[1]?.options).toBe(loads[0]) + const finalVisibleVersion = visible.get(`a`)?.version + expect( + errorObservations.map(({ visibleVersion }) => visibleVersion), + ).toEqual([finalVisibleVersion, finalVisibleVersion]) + expect(subscription.status).toBe(`ready`) + + subscription.unsubscribe() + expect(unloads.filter((options) => options === loads[1])).toHaveLength( + 2, + ) + expect(unloads.filter((options) => options === loads[0])).toHaveLength( + 2, + ) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }, + ) + + it.each([`distinct`, `shared`, `undefined`] as const)( + `aggregates every public unsubscribe cleanup failure and retries exact acquisitions: %s`, + async (failureValues) => { + type Row = { id: string } + const whereA = new Func(`eq`, [new PropRef([`id`]), new Value(`a`)]) + const whereB = new Func(`eq`, [new PropRef([`id`]), new Value(`b`)]) + const sharedFailure = new Error(`shared unsubscribe failure`) + const failures: ReadonlyArray = + failureValues === `undefined` + ? [undefined, undefined] + : failureValues === `shared` + ? [sharedFailure, sharedFailure] + : [ + new Error(`first unsubscribe failure`), + new Error(`second unsubscribe failure`), + ] + const loads: Array = [] + const unloads: Array = [] + const failedOnce = new Set() + const collection = createCollection({ + id: `aggregate-unsubscribe-cleanup-${failureValues}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: (options) => { + loads.push(options) + return true + }, + unloadSubset: (options) => { + unloads.push(options) + if (failedOnce.has(options)) return + failedOnce.add(options) + const index = loads.indexOf(options) + if (index !== -1) throw failures[index] + }, + } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}) + + try { + subscription.requestSnapshot({ where: whereA }) + subscription.requestSnapshot({ where: whereB }) + expect(loads).toHaveLength(2) + + let didThrow = false + let thrownValue: unknown + try { + subscription.unsubscribe() + } catch (error) { + didThrow = true + thrownValue = error + } + + expect(didThrow).toBe(true) + expect(thrownValue).toBeInstanceOf(AggregateError) + const aggregateErrors = (thrownValue as AggregateError).errors + expect(aggregateErrors).toHaveLength(2) + expect(Object.is(aggregateErrors[0], failures[0])).toBe(true) + expect(Object.is(aggregateErrors[1], failures[1])).toBe(true) + expect(unloads).toEqual(loads) + + expect(() => subscription.unsubscribe()).not.toThrow() + expect(unloads).toEqual([...loads, ...loads]) + } finally { + await collection.cleanup() + } + }, + ) + + it(`surfaces undefined teardown failure and retries its exact cleanup`, async () => { + type Row = { id: string } + const where = new Func(`eq`, [new PropRef([`id`]), new Value(`a`)]) + let loadedOptions: LoadSubsetOptions | undefined + const unloads: Array = [] + const collection = createCollection({ + id: `undefined-teardown-failure`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + params.markReady() + return { + loadSubset: (options) => { + loadedOptions = options + return true + }, + unloadSubset: (options) => { + unloads.push(options) + if (unloads.length === 1) throw undefined + }, + } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}) + + try { + subscription.requestSnapshot({ where }) + let didThrow = false + let thrownValue: unknown = Symbol(`not thrown`) + try { + subscription.unsubscribe() + } catch (error) { + didThrow = true + thrownValue = error + } + + expect(didThrow).toBe(true) + expect(thrownValue).toBeUndefined() + expect(unloads).toHaveLength(1) + expect(unloads[0]).toBe(loadedOptions) + + expect(() => subscription.unsubscribe()).not.toThrow() + expect(unloads).toHaveLength(2) + expect(unloads[1]).toBe(loadedOptions) + } finally { + await collection.cleanup() + } + }) + + it.each( + ([`unordered`, `ordered`] as const).flatMap((demandKind) => + ([`resolve`, `reject`] as const).flatMap((settlement) => + ([`succeed`, `throw`] as const).map( + (cleanup) => [demandKind, settlement, cleanup] as const, + ), + ), + ), + )( + `keeps a self-released callback demand in the replay barrier: %s %s %s`, + async (demandKind, settlement, cleanup) => { + type Row = { id: string; value: number } + const subscriptionWhere = new Func(`gte`, [ + new PropRef([`value`]), + new Value(0), + ]) + const whereA = new Func(`eq`, [new PropRef([`id`]), new Value(`a`)]) + const whereB = new Func(`eq`, [new PropRef([`id`]), new Value(`b`)]) + const orderBy: OrderBy = [ + { + expression: new PropRef([`value`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ] + const callbackDemand = createDeferred() + let begin!: () => void + let write!: ( + message: ChangeMessageOrDeleteKeyMessage, + ) => void + let commit!: (signal?: AbortSignal) => true | Promise + let truncate!: () => void + let replaying = false + let originalResultCount = 0 + let callbackDemandOptions: LoadSubsetOptions | undefined + let cleanupFailuresRemaining = cleanup === `throw` ? 1 : 0 + const cleanupError = new Error(`callback cleanup failed`) + const loads: Array = [] + const unloads: Array = [] + const collection = createCollection({ + id: `self-released-callback-demand-${demandKind}-${settlement}`, + 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) => { + loads.push(options) + if (loads.length === 3) { + callbackDemandOptions = options + return callbackDemand.promise + } + begin() + write({ type: `insert`, value: { id: `a`, value: 1 } }) + commit(options.signal) + return replaying ? true : Promise.resolve() + }, + unloadSubset: (options) => { + unloads.push(options) + if ( + options === callbackDemandOptions && + cleanupFailuresRemaining > 0 + ) { + cleanupFailuresRemaining-- + throw cleanupError + } + }, + } + }, + }, + }) + const index = collection.createIndex((row) => row.value, { + indexType: BTreeIndex, + }) + const visible = new Map() + const errors: Array = [] + const subscription = collection.subscribeChanges( + (changes) => { + for (const change of changes) { + const key = String(change.key) + if (change.type === `delete`) visible.delete(key) + else visible.set(key, change.value) + } + }, + { whereExpression: subscriptionWhere }, + ) + subscription.setOrderByIndex(index) + subscription.on(`loadSubset:error`, ({ error }) => errors.push(error)) + + try { + subscription.requestSnapshot({ + where: whereA, + optimizedOnly: false, + onLoadSubsetResult: () => { + originalResultCount++ + if (originalResultCount !== 2) return + if (demandKind === `ordered`) { + subscription.requestLimitedSnapshot({ + orderBy, + limit: 1, + onLoadSubsetResult: () => + subscription.releaseSnapshot(subscriptionWhere), + }) + } else { + subscription.requestSnapshot({ + where: whereB, + optimizedOnly: false, + onLoadSubsetResult: () => subscription.releaseSnapshot(whereB), + }) + } + }, + }) + await flushPromises() + + replaying = true + begin() + truncate() + commit() + await flushPromises() + + expect(callbackDemandOptions?.signal?.aborted).toBe(true) + expect(subscription.status).toBe(`loadingSubset`) + expect([...visible.keys()]).toEqual([`a`]) + + begin() + write({ type: `insert`, value: { id: `z`, value: 3 } }) + commit() + await flushPromises() + expect([...visible.keys()]).toEqual([`a`]) + + if (settlement === `resolve`) callbackDemand.resolve() + else callbackDemand.reject(new Error(`released callback demand`)) + await flushPromises() + + expect(subscription.status).toBe(`ready`) + expect([...visible.keys()]).toEqual([`a`]) + expect(errors).toEqual(cleanup === `throw` ? [cleanupError] : []) + expect( + unloads.filter((options) => options === callbackDemandOptions), + ).toHaveLength(1) + + begin() + write({ type: `insert`, value: { id: `w`, value: 4 } }) + commit() + await flushPromises() + expect([...visible.keys()].sort()).toEqual([`a`, `w`]) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }, + ) + + it.each([`succeed`, `throw`] as const)( + `binds an overlapping callback cleanup error to its originating replay: %s`, + async (cleanup) => { + type Row = { id: string; value: number } + const whereA = new Func(`eq`, [new PropRef([`id`]), new Value(`a`)]) + const whereC = new Func(`eq`, [new PropRef([`id`]), new Value(`c`)]) + let begin!: () => void + let write!: ( + message: ChangeMessageOrDeleteKeyMessage, + ) => void + let commit!: (signal?: AbortSignal) => true | Promise + let truncate!: () => void + let originalCallbackCount = 0 + let callbackDemandOptions: LoadSubsetOptions | undefined + let cleanupFailuresRemaining = cleanup === `throw` ? 1 : 0 + const cleanupError = new Error(`overlapped callback cleanup failed`) + const loads: Array = [] + const unloads: Array = [] + const collection = createCollection({ + id: `overlapping-callback-cleanup-${cleanup}`, + 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) => { + loads.push(options) + if (loads.length === 1) { + begin() + write({ type: `insert`, value: { id: `a`, value: 1 } }) + commit(options.signal) + return Promise.resolve() + } + if (loads.length === 2) return true + if (loads.length === 3) { + callbackDemandOptions = options + return true + } + + begin() + write({ type: `insert`, value: { id: `a`, value: 2 } }) + commit(options.signal) + return true + }, + unloadSubset: (options) => { + unloads.push(options) + if ( + options === callbackDemandOptions && + cleanupFailuresRemaining > 0 + ) { + cleanupFailuresRemaining-- + throw cleanupError + } + }, + } + }, + }, + }) + const visible = new Map() + const errors: Array = [] + const errorObservations: Array> = [] + const subscription = collection.subscribeChanges((changes) => { + for (const change of changes) { + const key = String(change.key) + if (change.type === `delete`) visible.delete(key) + else visible.set(key, change.value) + } + }) + subscription.on(`loadSubset:error`, ({ error }) => { + errors.push(error) + errorObservations.push( + [...visible].map(([key, row]) => [key, row.value] as const), + ) + }) + + try { + subscription.requestSnapshot({ + where: whereA, + onLoadSubsetResult: () => { + originalCallbackCount++ + if (originalCallbackCount !== 2) return + subscription.requestSnapshot({ + where: whereC, + onLoadSubsetResult: () => { + // This overlapping replay becomes current before cleanup of + // the callback-created demand can fail. The failure still + // belongs to the replay that enrolled that demand. + begin() + truncate() + commit() + subscription.releaseSnapshot(whereC) + }, + }) + }, + }) + await flushPromises() + expect([...visible.keys()]).toEqual([`a`]) + expect(visible.get(`a`)?.value).toBe(1) + + begin() + truncate() + commit() + await flushPromises() + await flushPromises() + + expect(subscription.status).toBe(`ready`) + expect([...visible.keys()]).toEqual([`a`]) + expect(visible.get(`a`)?.value).toBe(2) + expect(errors).toEqual(cleanup === `throw` ? [cleanupError] : []) + expect(errorObservations).toEqual( + cleanup === `throw` ? [[[`a`, 2]]] : [], + ) + expect(callbackDemandOptions?.signal?.aborted).toBe(true) + expect( + unloads.filter((options) => options === callbackDemandOptions), + ).toHaveLength(1) + + if (cleanup === `throw`) { + subscription.releaseSnapshot(whereC) + subscription.releaseSnapshot(whereC) + expect( + unloads.filter((options) => options === callbackDemandOptions), + ).toHaveLength(2) + } + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }, + ) + + it.each( + ([`sync`, `async`] as const).flatMap((settlement) => + ( + [`none`, `cleanup-succeed`, `cleanup-throw`, `callback-throw`] as const + ).map((callback) => [settlement, callback] as const), + ), + )( + `settles a post-setup ordered continuation callback before publication: %s %s`, + async (settlement, callback) => { + type Row = { + id: `a` | `b` + rank: number + version: number + } + type Outcome = { + hasMore: boolean + appliedRowKeys: ReadonlyArray + } + const where = new Func(`gte`, [new PropRef([`rank`]), new Value(0)]) + const orderBy: OrderBy = [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ] + const replayPage = createDeferred() + const callbackError = new Error(`continuation callback failed`) + const cleanupError = new Error(`continuation cleanup failed`) + let begin!: () => void + let write!: ( + message: ChangeMessageOrDeleteKeyMessage, + ) => void + let commit!: (signal?: AbortSignal) => true | Promise + let truncate!: () => void + let loadCount = 0 + let initialCallbackCount = 0 + let continuationOptions: LoadSubsetOptions | undefined + let cleanupFailuresRemaining = callback === `cleanup-throw` ? 1 : 0 + let escapedCallbackError: unknown + const unloads: Array = [] + const collection = createCollection({ + id: `post-setup-continuation-${settlement}-${callback}`, + 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++ + begin() + if (loadCount === 1) { + write({ + type: `insert`, + value: { id: `a`, rank: 1, version: 1 }, + }) + write({ + type: `insert`, + value: { id: `b`, rank: 2, version: 1 }, + }) + commit(options.signal) + return Promise.resolve({ + hasMore: false, + appliedRowKeys: [`a`, `b`] as const, + }) + } + if (loadCount === 2) { + write({ + type: `insert`, + value: { id: `a`, rank: 1, version: 2 }, + }) + commit(options.signal) + return replayPage.promise + } + + continuationOptions = options + write({ + type: `insert`, + value: { id: `b`, rank: 2, version: 2 }, + }) + commit(options.signal) + return settlement === `sync` ? true : Promise.resolve() + }, + unloadSubset: (options) => { + unloads.push(options) + if ( + options === continuationOptions && + cleanupFailuresRemaining > 0 + ) { + cleanupFailuresRemaining-- + throw cleanupError + } + }, + } + }, + }, + }) + const index = collection.createIndex((row) => row.rank, { + indexType: BTreeIndex, + }) + const visible = new Map() + const errors: Array = [] + const errorObservations: Array> = [] + const subscription = collection.subscribeChanges( + (changes) => { + for (const change of changes) { + const key = change.key as Row[`id`] + if (change.type === `delete`) visible.delete(key) + else visible.set(key, change.value) + } + }, + { whereExpression: where }, + ) + subscription.setOrderByIndex(index) + subscription.on(`loadSubset:error`, ({ error }) => { + errors.push(error) + errorObservations.push( + [...visible.values()].map((row) => `${row.id}@${row.version}`), + ) + }) + + try { + subscription.requestLimitedSnapshot({ + orderBy, + limit: 2, + onLoadSubsetResult: (result) => { + initialCallbackCount++ + if (initialCallbackCount !== 2 || !(result instanceof Promise)) { + return + } + void result.then(() => { + try { + subscription.requestLimitedSnapshot({ + orderBy, + limit: 2, + onLoadSubsetResult: (_result, options) => { + if (callback === `callback-throw`) throw callbackError + if (callback.startsWith(`cleanup-`)) { + subscription.releaseSnapshot(where, options.signal) + } + }, + }) + } catch (error) { + escapedCallbackError = error + } + }) + }, + }) + await flushPromises() + expect( + [...visible.values()].map((row) => `${row.id}@${row.version}`), + ).toEqual([`a@1`, `b@1`]) + + begin() + truncate() + commit() + await flushPromises() + replayPage.resolve({ hasMore: true, appliedRowKeys: [`a`] }) + await flushPromises() + await flushPromises() + + const publishesReplacement = + callback === `none` || + (callback === `cleanup-succeed` && settlement === `sync`) + expect(subscription.status).toBe(`ready`) + expect(escapedCallbackError).toBeUndefined() + expect( + [...visible.values()].map((row) => `${row.id}@${row.version}`), + ).toEqual(publishesReplacement ? [`a@2`, `b@2`] : [`a@1`, `b@1`]) + const expectedError = + callback === `cleanup-throw` + ? cleanupError + : callback === `callback-throw` + ? callbackError + : undefined + expect(errors).toEqual(expectedError ? [expectedError] : []) + expect(errorObservations).toEqual(expectedError ? [[`a@1`, `b@1`]] : []) + + if (callback.startsWith(`cleanup-`)) { + expect(continuationOptions?.signal?.aborted).toBe(true) + expect( + unloads.filter((options) => options === continuationOptions), + ).toHaveLength(1) + } + if (callback === `cleanup-throw`) { + subscription.releaseSnapshot(where, continuationOptions?.signal) + subscription.releaseSnapshot(where, continuationOptions?.signal) + expect( + unloads.filter((options) => options === continuationOptions), + ).toHaveLength(2) + } + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }, + ) + + it(`revokes ordered authority when an additional demand replaces a candidate version`, async () => { + type Row = { id: `a` | `x`; rank: number } + type Outcome = { + hasMore: boolean + appliedRowKeys: ReadonlyArray + } + let begin!: () => void + let write!: ( + message: ChangeMessageOrDeleteKeyMessage, + ) => void + let commit!: (signal?: AbortSignal) => true | Promise + let truncate!: () => void + let loadCount = 0 + let loadingAdditional = false + let physicalOptions: LoadSubsetOptions | undefined + const loadOptions: Array = [] + const replayLoads: Array>> = [] + const additionalLoad = createDeferred() + const deduplicatedLoad = new DeduplicatedLoadSubset({ + loadSubset: (options) => { + physicalOptions = options + return additionalLoad.promise + }, + }) + const collection = createCollection({ + id: `failed-ordered-candidate-replacement`, + 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) => { + loadOptions.push(options) + if (loadingAdditional) return deduplicatedLoad.loadSubset(options) + loadCount++ + if (loadCount === 1) { + begin() + write({ type: `insert`, value: { id: `a`, rank: 1 } }) + commit() + return Promise.resolve({ + hasMore: false, + appliedRowKeys: [`a`] as const, + }) + } + if (loadCount === 2) { + return Promise.resolve({ + hasMore: false, + appliedRowKeys: [] as const, + }) + } + const deferred = createDeferred() + replayLoads.push(deferred) + return deferred.promise + }, + unloadSubset: () => {}, + } + }, + }, + }) + const index = collection.createIndex((row) => row.rank, { + indexType: BTreeIndex, + }) + const orderBy: OrderBy = [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ] + const orderedWhere = new Func(`gte`, [ + new PropRef([`rank`]), + new Value(-1_000), + ]) + const seedSiblingWhere = new Func(`eq`, [ + new PropRef([`id`]), + new Value(`x`), + ]) + const sameKeyWhere = new Func(`eq`, [new PropRef([`id`]), new Value(`a`)]) + const visible = new Map() + const visibleRows = () => + [...visible.values()].map(({ id, rank }) => ({ id, rank })) + const subscription = collection.subscribeChanges( + (changes) => { + for (const change of changes) { + const key = change.key as Row[`id`] + if (change.type === `delete`) visible.delete(key) + else visible.set(key, change.value) + } + }, + { whereExpression: orderedWhere }, + ) + subscription.setOrderByIndex(index) + + try { + subscription.requestLimitedSnapshot({ orderBy, limit: 1 }) + subscription.requestSnapshot({ where: seedSiblingWhere }) + await flushPromises() + + begin() + truncate() + commit() + await flushPromises() + expect(replayLoads).toHaveLength(2) + + begin() + write({ type: `insert`, value: { id: `x`, rank: 0 } }) + commit() + replayLoads[0]?.resolve({ hasMore: false, appliedRowKeys: [`x`] }) + replayLoads[1]?.reject(new Error(`sibling replay failed`)) + await flushPromises() + expect(visibleRows()).toEqual([{ id: `a`, rank: 1 }]) + + subscription.releaseSnapshot(seedSiblingWhere) + loadingAdditional = true + subscription.requestSnapshot({ where: sameKeyWhere }) + await flushPromises() + + begin() + write({ type: `update`, value: { id: `a`, rank: 100 } }) + const receipt = commit(physicalOptions?.signal) + if (receipt !== true) await receipt + additionalLoad.resolve({ hasMore: false, appliedRowKeys: [`a`] }) + await flushPromises() + + expect(visibleRows()).toEqual([{ id: `a`, rank: 100 }]) + expect(subscription.orderedBoundaryKey).toBeUndefined() + + subscription.requestLimitedSnapshot({ orderBy, limit: 1 }) + await flushPromises() + expect(loadOptions.at(-1)).toMatchObject({ offset: 0 }) + expect(loadOptions.at(-1)?.cursor).toBeUndefined() + + subscription.releaseSnapshot(sameKeyWhere) + expect(visibleRows()).toEqual([]) + expect(subscription.orderedBoundaryKey).toBeUndefined() + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it(`releases ordered publication authority before retrying failed adapter cleanup`, async () => { + type Row = { id: string; rank: number } + let begin!: () => void + let write!: ( + message: ChangeMessageOrDeleteKeyMessage, + ) => void + let commit!: () => void + let loadCount = 0 + let unloadCount = 0 + let failNextUnload = true + const collection = createCollection({ + id: `shared-ordered-release-cleanup-debt`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + params.markReady() + return { + loadSubset: () => { + loadCount++ + if (loadCount === 1) { + begin() + write({ type: `insert`, value: { id: `a`, rank: 1 } }) + commit() + } + return Promise.resolve({ + hasMore: false, + appliedRowKeys: [`a`] as const, + }) + }, + unloadSubset: () => { + unloadCount++ + if (failNextUnload) { + failNextUnload = false + throw new Error(`release failed`) + } + }, + } + }, + }, + }) + const where = new Func(`gte`, [new PropRef([`rank`]), new Value(0)]) + const orderBy: OrderBy = [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ] + const index = collection.createIndex((row) => row.rank, { + indexType: BTreeIndex, + }) + const visible = [new Set(), new Set()] + const createOrderedSubscription = (rows: Set) => { + const subscription = collection.subscribeChanges( + (changes) => { + for (const change of changes) { + const key = String(change.key) + if (change.type === `delete`) rows.delete(key) + else rows.add(key) + } + }, + { whereExpression: where }, + ) + subscription.setOrderByIndex(index) + return subscription + } + const first = createOrderedSubscription(visible[0]!) + const second = createOrderedSubscription(visible[1]!) + + try { + first.requestLimitedSnapshot({ orderBy, limit: 1 }) + second.requestLimitedSnapshot({ orderBy, limit: 1 }) + await flushPromises() + expect(visible.map((rows) => [...rows])).toEqual([[`a`], [`a`]]) + + expect(() => first.releaseSnapshot(where)).toThrow(`release failed`) + expect([...visible[0]!]).toEqual([]) + expect(first.orderedBoundaryKey).toBeUndefined() + expect([...visible[1]!]).toEqual([`a`]) + expect(second.orderedBoundaryKey).toBe(`a`) + expect(collection.toArray.map(({ id }) => id)).toEqual([`a`]) + + first.releaseSnapshot(where) + expect([...visible[0]!]).toEqual([]) + expect(first.orderedBoundaryKey).toBeUndefined() + expect([...visible[1]!]).toEqual([`a`]) + expect(second.orderedBoundaryKey).toBe(`a`) + expect(collection.toArray.map(({ id }) => id)).toEqual([`a`]) + expect(unloadCount).toBe(2) + } finally { + failNextUnload = false + first.unsubscribe() + second.unsubscribe() + await collection.cleanup() + } + }) + + it(`keeps reentrant release idempotent and retains a new same-predicate demand`, async () => { + type Row = { id: string; value: number } + const where = new Func(`eq`, [new PropRef([`id`]), new Value(`a`)]) + const loads: Array = [] + let loadCount = 0 + let unloadCount = 0 + type TestSubscription = ReturnType< + ReturnType>[`subscribeChanges`] + > + const owner: { current?: TestSubscription } = {} + const collection = createCollection({ + id: `reentrant-release-same-predicate`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: (options) => { + loadCount++ + loads.push(options) + return true + }, + unloadSubset: () => { + unloadCount++ + if (unloadCount === 1) { + owner.current!.releaseSnapshot(where) + owner.current!.requestSnapshot({ + where, + optimizedOnly: false, + }) + } + }, + } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}) + owner.current = subscription + + try { + subscription.requestSnapshot({ where, optimizedOnly: false }) + subscription.releaseSnapshot(where) + + expect(loadCount).toBe(2) + expect(unloadCount).toBe(1) + expect(loads[0]?.signal?.aborted).toBe(true) + expect(loads[1]?.signal?.aborted).toBe(false) + + subscription.unsubscribe() + expect(unloadCount).toBe(2) + expect(loads[1]?.signal?.aborted).toBe(true) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it(`retires the last ordered publication while replay is still pending`, async () => { + type Row = { id: string; rank: number } + type Outcome = { hasMore: boolean; appliedRowKeys: ReadonlyArray } + let begin!: () => void + let write!: (message: ChangeMessageOrDeleteKeyMessage) => void + let commit!: () => void + let truncate!: () => void + let loadCount = 0 + const replay = createDeferred() + const loads: Array = [] + const collection = createCollection({ + id: `ordered-release-during-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) => { + loads.push(options) + loadCount++ + if (loadCount === 1) { + begin() + write({ type: `insert`, value: { id: `a`, rank: 1 } }) + commit() + return Promise.resolve({ + hasMore: false, + appliedRowKeys: [`a`], + }) + } + return replay.promise + }, + unloadSubset: () => {}, + } + }, + }, + }) + const where = new Func(`gte`, [new PropRef([`rank`]), new Value(0)]) + const orderBy: OrderBy = [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ] + const index = collection.createIndex((row) => row.rank, { + indexType: BTreeIndex, + }) + const visible = new Set() + const subscription = collection.subscribeChanges( + (changes) => { + for (const change of changes) { + const key = String(change.key) + if (change.type === `delete`) visible.delete(key) + else visible.add(key) + } + }, + { whereExpression: where }, + ) + subscription.setOrderByIndex(index) + + try { + subscription.requestLimitedSnapshot({ orderBy, limit: 1 }) + await flushPromises() + begin() + truncate() + commit() + await flushPromises() + expect(loads).toHaveLength(2) + + subscription.releaseSnapshot(where) + + expect([...visible]).toEqual([]) + expect(subscription.orderedBoundaryKey).toBeUndefined() + expect(subscription.orderedRowsNeeded).toBe(0) + expect(loads.every(({ signal }) => signal?.aborted)).toBe(true) + } finally { + replay.resolve({ hasMore: false, appliedRowKeys: [] }) + await flushPromises() + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it(`does not use ownerless source changes as a later ordered cursor`, async () => { + type Row = { id: string; rank: number } + type Outcome = { hasMore: boolean; appliedRowKeys: ReadonlyArray } + let begin!: () => void + let write!: (message: ChangeMessageOrDeleteKeyMessage) => void + let commit!: () => void + let loadCount = 0 + const secondLoad = createDeferred() + const loads: Array = [] + const collection = createCollection({ + id: `ownerless-ordered-cursor`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + params.markReady() + return { + loadSubset: (options) => { + loads.push(options) + loadCount++ + if (loadCount === 1) { + begin() + write({ type: `insert`, value: { id: `a`, rank: 1 } }) + commit() + return Promise.resolve({ + hasMore: false, + appliedRowKeys: [`a`], + }) + } + return secondLoad.promise + }, + unloadSubset: () => {}, + } + }, + }, + }) + const where = new Func(`gte`, [new PropRef([`rank`]), new Value(0)]) + const orderBy: OrderBy = [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ] + const index = collection.createIndex((row) => row.rank, { + indexType: BTreeIndex, + }) + const subscription = collection.subscribeChanges(() => {}, { + whereExpression: where, + }) + subscription.setOrderByIndex(index) + + try { + subscription.requestLimitedSnapshot({ orderBy, limit: 1 }) + await flushPromises() + subscription.releaseSnapshot(where) + + begin() + write({ type: `insert`, value: { id: `x`, rank: 0 } }) + commit() + + subscription.requestLimitedSnapshot({ orderBy, limit: 1 }) + expect(loads).toHaveLength(2) + expect(loads[1]).toMatchObject({ offset: 0 }) + expect(loads[1]?.cursor).toBeUndefined() + expect(subscription.orderedBoundaryKey).toBeUndefined() + } finally { + secondLoad.resolve({ hasMore: false, appliedRowKeys: [] }) + await flushPromises() + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it(`publishes a same-version insert after retiring a failed publication`, async () => { + type Row = { id: string; rank: number } + type Outcome = { hasMore: boolean; appliedRowKeys: ReadonlyArray } + let begin!: () => void + let write!: (message: ChangeMessageOrDeleteKeyMessage) => void + let commit!: () => void + let truncate!: () => void + let loadCount = 0 + const replay = createDeferred() + const collection = createCollection({ + id: `retired-failed-publication-reinsert`, + 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: `a`, rank: 1 } }) + commit() + return Promise.resolve({ + hasMore: false, + appliedRowKeys: [`a`], + }) + } + if (loadCount === 2) return replay.promise + return true + }, + unloadSubset: () => {}, + } + }, + }, + }) + const orderedWhere = new Func(`gte`, [new PropRef([`rank`]), new Value(0)]) + const additionalWhere = new Func(`eq`, [ + new PropRef([`id`]), + new Value(`a`), + ]) + const orderBy: OrderBy = [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ] + const index = collection.createIndex((row) => row.rank, { + indexType: BTreeIndex, + }) + const visible = new Set() + const subscription = collection.subscribeChanges( + (changes) => { + for (const change of changes) { + const key = String(change.key) + if (change.type === `delete`) visible.delete(key) + else visible.add(key) + } + }, + { whereExpression: orderedWhere }, + ) + subscription.setOrderByIndex(index) + + try { + subscription.requestLimitedSnapshot({ orderBy, limit: 1 }) + await flushPromises() + begin() + truncate() + commit() + await flushPromises() + replay.reject(new Error(`ordered replay failed`)) + await flushPromises() + + subscription.releaseSnapshot(orderedWhere) + subscription.requestSnapshot({ + where: additionalWhere, + optimizedOnly: false, + }) + await flushPromises() + begin() + write({ type: `insert`, value: { id: `a`, rank: 1 } }) + commit() + + expect(collection.toArray.map(({ id }) => id)).toEqual([`a`]) + expect([...visible]).toEqual([`a`]) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it(`retries cleanup by exact acquisition without releasing a replacement owner`, async () => { + type Row = { id: string; rank: number } + const loads: Array = [] + const unloadSignals: Array = [] + let loadCount = 0 + let failFirstUnload = true + let begin!: () => void + let write!: (message: ChangeMessageOrDeleteKeyMessage) => void + let commit!: () => void + const collection = createCollection({ + id: `exact-ordered-cleanup-retry`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + params.markReady() + return { + loadSubset: (options) => { + loads.push(options) + loadCount++ + if (loadCount === 1) { + begin() + write({ type: `insert`, value: { id: `a`, rank: 1 } }) + commit() + } + return Promise.resolve({ + hasMore: false, + appliedRowKeys: [`a`] as const, + }) + }, + unloadSubset: (options) => { + unloadSignals.push(options.signal) + if (failFirstUnload) { + failFirstUnload = false + throw new Error(`release failed`) + } + }, + } + }, + }, + }) + const where = new Func(`gte`, [new PropRef([`rank`]), new Value(0)]) + const orderBy: OrderBy = [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ] + const index = collection.createIndex((row) => row.rank, { + indexType: BTreeIndex, + }) + const visible = new Set() + const subscription = collection.subscribeChanges( + (changes) => { + for (const change of changes) { + const key = String(change.key) + if (change.type === `delete`) visible.delete(key) + else visible.add(key) + } + }, + { whereExpression: where }, + ) + subscription.setOrderByIndex(index) + + try { + subscription.requestLimitedSnapshot({ orderBy, limit: 1 }) + await flushPromises() + expect(() => subscription.releaseSnapshot(where)).toThrow( + `release failed`, + ) + subscription.requestLimitedSnapshot({ orderBy, limit: 1 }) + await flushPromises() + + const releaseExact = subscription.releaseSnapshot as ( + predicate: typeof where, + signal: AbortSignal | undefined, + ) => void + releaseExact.call(subscription, where, loads[0]?.signal) + + expect(unloadSignals).toEqual([loads[0]?.signal, loads[0]?.signal]) + expect(loads[1]?.signal?.aborted).toBe(false) + expect([...visible]).toEqual([`a`]) + expect(subscription.orderedBoundaryKey).toBe(`a`) + } finally { + failFirstUnload = false + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it(`keeps replay handoff cleanup idempotent under reentrant release`, async () => { + type Row = { id: string; rank: number } + type Outcome = { hasMore: boolean; appliedRowKeys: ReadonlyArray } + let begin!: () => void + let write!: (message: ChangeMessageOrDeleteKeyMessage) => void + let commit!: () => void + let truncate!: () => void + let loadCount = 0 + let releaseReentered = false + const replay = createDeferred() + const loads: Array = [] + const unloadLabels: Array<`old` | `replay`> = [] + type TestSubscription = ReturnType< + ReturnType>[`subscribeChanges`] + > + const owner: { current?: TestSubscription } = {} + const collection = createCollection({ + id: `reentrant-replay-handoff-cleanup`, + 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) => { + loads.push(options) + loadCount++ + if (loadCount === 1) { + begin() + write({ type: `insert`, value: { id: `a`, rank: 1 } }) + commit() + return Promise.resolve({ + hasMore: false, + appliedRowKeys: [`a`], + }) + } + return replay.promise + }, + unloadSubset: (options) => { + const label = + options.signal === loads[0]?.signal ? `old` : `replay` + unloadLabels.push(label) + if (label === `old` && !releaseReentered) { + releaseReentered = true + owner.current!.releaseSnapshot(where) + } + }, + } + }, + }, + }) + const where = new Func(`gte`, [new PropRef([`rank`]), new Value(0)]) + const orderBy: OrderBy = [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ] + const index = collection.createIndex((row) => row.rank, { + indexType: BTreeIndex, + }) + const subscription = collection.subscribeChanges(() => {}, { + whereExpression: where, + }) + owner.current = subscription + subscription.setOrderByIndex(index) + + try { + subscription.requestLimitedSnapshot({ orderBy, limit: 1 }) + await flushPromises() + begin() + truncate() + commit() + await flushPromises() + + replay.resolve({ hasMore: false, appliedRowKeys: [`a`] }) + await flushPromises() + + expect(unloadLabels).toEqual([`old`, `replay`]) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it(`reports every failed acquisition cleanup while abandoning a replay handoff`, async () => { + type Row = { id: string; version: number } + let begin!: () => void + let write!: (message: ChangeMessageOrDeleteKeyMessage) => void + let commit!: () => true | Promise + let truncate!: () => void + let loadCount = 0 + const replay = createDeferred() + const loads: Array = [] + const unloads: Array = [] + const failed = new Set() + const oldFailure = new Error(`old acquisition cleanup failed`) + const replacementFailure = new Error( + `replacement acquisition cleanup failed`, + ) + const collection = createCollection({ + id: `replay-handoff-multiple-cleanup-failures`, + 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) => { + loads.push(options) + loadCount++ + begin() + write({ + type: `insert`, + value: { id: `a`, version: loadCount }, + }) + commit() + return loadCount === 1 ? true : replay.promise + }, + unloadSubset: (options) => { + unloads.push(options) + if (failed.has(options)) return + failed.add(options) + if (options === loads[0]) throw oldFailure + if (options === loads[1]) throw replacementFailure + }, + } + }, + }, + }) + const where = new Func(`eq`, [new PropRef([`id`]), new Value(`a`)]) + const visible = new Map() + const reported: Array<{ error: unknown; options: LoadSubsetOptions }> = [] + let unsubscribed = false + const subscription = collection.subscribeChanges((changes) => { + for (const change of changes) { + if (change.type === `delete`) visible.delete(String(change.key)) + else visible.set(String(change.key), change.value) + } + }) + subscription.on(`loadSubset:error`, ({ error, options }) => + reported.push({ error, options }), + ) + + try { + subscription.requestSnapshot({ where }) + begin() + truncate() + commit() + await flushPromises() + + replay.resolve() + await flushPromises() + + expect(reported.map(({ error }) => error)).toEqual([ + oldFailure, + replacementFailure, + ]) + expect(reported[0]?.options).toBe(loads[0]) + expect(reported[1]?.options).toBe(loads[1]) + expect(visible.get(`a`)?.version).toBe(1) + expect(subscription.status).toBe(`ready`) + + subscription.unsubscribe() + unsubscribed = true + expect(unloads).toEqual([loads[0], loads[1], loads[1], loads[0]]) + } finally { + if (!unsubscribed) subscription.unsubscribe() + await collection.cleanup() + } + }) + + it(`attributes a reentrant replay handoff cleanup failure to its exact acquisition`, async () => { + type Row = { id: string; version: number } + let begin!: () => void + let write!: (message: ChangeMessageOrDeleteKeyMessage) => void + let commit!: () => true | Promise + let truncate!: () => void + let loadCount = 0 + let reentered = false + let replacementFailed = false + const replay = createDeferred() + const loads: Array = [] + const unloads: Array = [] + const replacementFailure = new Error(`replacement cleanup failed`) + type TestSubscription = ReturnType< + ReturnType>[`subscribeChanges`] + > + const owner: { current?: TestSubscription } = {} + const collection = createCollection({ + id: `reentrant-replay-handoff-cleanup-attribution`, + 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) => { + loads.push(options) + loadCount++ + begin() + write({ + type: `insert`, + value: { id: `a`, version: loadCount }, + }) + commit() + return loadCount === 1 ? true : replay.promise + }, + unloadSubset: (options) => { + unloads.push(options) + if (options === loads[0] && !reentered) { + reentered = true + owner.current!.releaseSnapshot(where) + return + } + if (options === loads[1] && !replacementFailed) { + replacementFailed = true + throw replacementFailure + } + }, + } + }, + }, + }) + const where = new Func(`eq`, [new PropRef([`id`]), new Value(`a`)]) + const reported: Array<{ error: unknown; options: LoadSubsetOptions }> = [] + const subscription = collection.subscribeChanges(() => {}, { + whereExpression: where, + }) + owner.current = subscription + subscription.on(`loadSubset:error`, ({ error, options }) => + reported.push({ error, options }), + ) + + try { + subscription.requestSnapshot({ where }) + begin() + truncate() + commit() + await flushPromises() + + replay.resolve() + await flushPromises() + + expect(reported.map(({ error }) => error)).toEqual([replacementFailure]) + expect(reported[0]?.options).toBe(loads[1]) + expect(unloads).toEqual([loads[0], loads[1], loads[1]]) + expect(subscription.status).toBe(`ready`) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it(`preserves nested cleanup occurrences across another demand's replay handoff`, async () => { + type Row = { id: string; version: number } + let begin!: () => void + let write!: (message: ChangeMessageOrDeleteKeyMessage) => void + let commit!: () => true | Promise + let truncate!: () => void + let nested = false + const replayA = createDeferred() + const replayB = createDeferred() + const loads: Array = [] + const unloads: Array = [] + const failed = new Set() + const pendingBFailure = new Error(`pending B cleanup failed`) + const currentBFailure = new Error(`current B cleanup failed`) + type TestSubscription = ReturnType< + ReturnType>[`subscribeChanges`] + > + const owner: { current?: TestSubscription } = {} + const collection = createCollection({ + id: `nested-demand-replay-handoff-cleanup`, + 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) => { + loads.push(options) + const id = loads.length % 2 === 1 ? `a` : `b` + if (loads.length <= 2) { + begin() + write({ type: `insert`, value: { id, version: 1 } }) + commit() + return true + } + return loads.length === 3 ? replayA.promise : replayB.promise + }, + unloadSubset: (options) => { + unloads.push(options) + if (options === loads[0] && !nested) { + nested = true + owner.current!.releaseSnapshot(whereB) + } + if (failed.has(options)) return + if (options === loads[3]) { + failed.add(options) + throw pendingBFailure + } + if (options === loads[1]) { + failed.add(options) + throw currentBFailure + } + }, + } + }, + }, + }) + const whereA = new Func(`eq`, [new PropRef([`id`]), new Value(`a`)]) + const whereB = new Func(`eq`, [new PropRef([`id`]), new Value(`b`)]) + const visible = new Set() + const reported: Array<{ error: unknown; options: LoadSubsetOptions }> = [] + const subscription = collection.subscribeChanges((changes) => { + for (const change of changes) { + if (change.type === `delete`) visible.delete(String(change.key)) + else visible.add(String(change.key)) + } + }) + owner.current = subscription + subscription.on(`loadSubset:error`, ({ error, options }) => + reported.push({ error, options }), + ) + + try { + subscription.requestSnapshot({ where: whereA }) + subscription.requestSnapshot({ where: whereB }) + begin() + truncate() + commit() + await flushPromises() + + replayA.resolve() + replayB.resolve() + await flushPromises() + + expect(reported.map(({ error }) => error)).toEqual([ + pendingBFailure, + currentBFailure, + ]) + expect(reported[0]?.options).toBe(loads[3]) + expect(reported[1]?.options).toBe(loads[1]) + expect(unloads.map((options) => loads.indexOf(options))).toEqual([ + 0, 3, 1, 2, 3, + ]) + expect([...visible].sort()).toEqual([`a`, `b`]) + expect(subscription.status).toBe(`ready`) + + subscription.unsubscribe() + expect(unloads.map((options) => loads.indexOf(options))).toEqual([ + 0, 3, 1, 2, 3, 0, 1, + ]) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it.each([ + { mode: `propagates the nested failure`, behavior: `propagate` }, + { mode: `swallows the nested failure`, behavior: `swallow` }, + { mode: `replaces it with another failure`, behavior: `replace` }, + ])( + `preserves cleanup provenance when an intermediate adapter $mode`, + async ({ behavior }) => { + type Row = { id: string } + let begin!: () => void + let write!: ( + message: ChangeMessageOrDeleteKeyMessage, + ) => void + let commit!: () => true | Promise + let truncate!: () => void + const ids = [`a`, `b`, `c`] as const + const wheres = ids.map( + (id) => new Func(`eq`, [new PropRef([`id`]), new Value(id)]), + ) + const replays = ids.map(() => createDeferred()) + const loads: Array = [] + const unloads: Array = [] + const failed = new Set() + const failureB = new Error(`B cleanup failed`) + const failureC = new Error(`C cleanup failed`) + let nestedA = false + let nestedB = false + type TestSubscription = ReturnType< + ReturnType>[`subscribeChanges`] + > + const owner: { current?: TestSubscription } = {} + const collection = createCollection({ + id: `deep-nested-replay-cleanup-${behavior}`, + 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) => { + loads.push(options) + const index = loads.length - 1 + if (index < ids.length) { + begin() + write({ type: `insert`, value: { id: ids[index]! } }) + commit() + return true + } + return replays[index - ids.length]!.promise + }, + unloadSubset: (options) => { + unloads.push(options) + if (options === loads[0] && !nestedA) { + nestedA = true + owner.current!.releaseSnapshot(wheres[1]!) + } + if (options === loads[1] && !nestedB) { + nestedB = true + if (behavior !== `propagate`) { + try { + owner.current!.releaseSnapshot(wheres[2]!) + } catch { + // The cleanup boundary must retain the nested occurrence + // even when this adapter handles the propagated error. + } + if (behavior === `replace` && !failed.has(options)) { + failed.add(options) + throw failureB + } + } else { + owner.current!.releaseSnapshot(wheres[2]!) + } + } + if (options === loads[2] && !failed.has(options)) { + failed.add(options) + throw failureC + } + }, + } + }, + }, + }) + const visible = new Set() + const reported: Array<{ + error: unknown + options: LoadSubsetOptions + }> = [] + const subscription = collection.subscribeChanges((changes) => { + for (const change of changes) { + if (change.type === `delete`) visible.delete(String(change.key)) + else visible.add(String(change.key)) + } + }) + owner.current = subscription + subscription.on(`loadSubset:error`, ({ error, options }) => + reported.push({ error, options }), + ) + + try { + for (const where of wheres) subscription.requestSnapshot({ where }) + begin() + truncate() + commit() + await flushPromises() + + for (const replay of replays) replay.resolve() + await flushPromises() + + expect(reported.map(({ error }) => error)).toEqual( + behavior === `replace` ? [failureC, failureB] : [failureC], + ) + expect(reported.map(({ options }) => loads.indexOf(options))).toEqual( + behavior === `replace` ? [2, 1] : [2], + ) + expect(unloads.map((options) => loads.indexOf(options))).toEqual([ + 0, 4, 1, 5, 2, 3, + ]) + expect([...visible].sort()).toEqual(ids) + expect(subscription.status).toBe(`ready`) + + subscription.unsubscribe() + expect(unloads.map((options) => loads.indexOf(options))).toEqual([ + 0, + 4, + 1, + 5, + 2, + 3, + 0, + ...(behavior === `swallow` ? [] : [1]), + 2, + ]) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }, + ) + + it.each( + [ + { name: `Error`, failure: new Error(`callback cleanup payload`) }, + { + name: `AggregateError`, + failure: new AggregateError( + [new Error(`callback cleanup inner payload`)], + `callback cleanup payload`, + ), + }, + { name: `undefined`, failure: undefined }, + { name: `NaN`, failure: Number.NaN }, + ].flatMap(({ name, failure }) => + ([`return`, `rethrow`, `distinct`, `same`] as const).map((mode) => ({ + name, + nestedFailure: failure, + outerFailure: + mode === `same` ? failure : new Error(`outer callback failed`), + mode, + })), + ), + )( + `preserves caught replay-callback cleanup failures: $name $mode`, + async ({ name, nestedFailure, outerFailure, mode }) => { + const result = await exerciseReplayCallbackCleanup({ + id: `caught-callback-cleanup-${name}-${mode}`, + nestedFailure, + outerFailure, + mode, + }) + + const expectedErrors = + mode === `distinct` + ? [nestedFailure, outerFailure] + : mode === `same` + ? [nestedFailure, nestedFailure] + : [nestedFailure] + expect(result.reported.map(({ error }) => error)).toEqual(expectedErrors) + expect(result.reported.map(({ optionsIndex }) => optionsIndex)).toEqual( + expectedErrors.length === 1 ? [2] : [2, 3], + ) + expect(result.visibleVersions).toEqual([ + [`a`, 2], + [`b`, 1], + ]) + expect(result.beforeRetry).toEqual([0, 1, 2]) + expect(result.afterRetry).toEqual([0, 1, 2, 2, 3]) + expect(result.status).toBe(`ready`) + }, + ) + + it(`carries nested public teardown failures without exposing propagation tokens`, async () => { + type Row = { id: string } + const ids = [`a`, `b`, `c`] as const + const wheres = ids.map( + (id) => new Func(`eq`, [new PropRef([`id`]), new Value(id)]), + ) + const failures = [ + new Error(`B cleanup failed`), + new Error(`C cleanup failed`), + ] as const + const loads: Array = [] + const unloads: Array = [] + const failed = new Set() + let nested = false + type TestSubscription = ReturnType< + ReturnType>[`subscribeChanges`] + > + const owner: { current?: TestSubscription } = {} + const collection = createCollection({ + id: `nested-public-teardown-cleanup`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + params.markReady() + return { + loadSubset: (options) => { + loads.push(options) + return true + }, + unloadSubset: (options) => { + unloads.push(options) + const index = loads.indexOf(options) + if (index === 0 && !nested) { + nested = true + owner.current!.unsubscribe() + } + if ((index === 1 || index === 2) && !failed.has(options)) { + failed.add(options) + throw failures[index - 1] + } + }, + } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}) + owner.current = subscription + + try { + for (const where of wheres) subscription.requestSnapshot({ where }) + let thrown: unknown + try { + subscription.releaseSnapshot(wheres[0]!) + } catch (error) { + thrown = error + } + + expect(thrown).toBeInstanceOf(AggregateError) + expect((thrown as AggregateError).errors).toEqual(failures) + expect(unloads.map((options) => loads.indexOf(options))).toEqual([ + 0, 1, 2, + ]) + + subscription.unsubscribe() + expect(unloads.map((options) => loads.indexOf(options))).toEqual([ + 0, 1, 2, 0, 1, 2, + ]) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it(`carries a true cleanup failure across nested replay callback frames once`, async () => { + type Row = { id: string } + const whereA = new Func(`eq`, [new PropRef([`id`]), new Value(`a`)]) + const whereB = new Func(`eq`, [new PropRef([`id`]), new Value(`b`)]) + const whereC = new Func(`eq`, [new PropRef([`id`]), new Value(`c`)]) + const cleanupFailure = new Error(`nested callback cleanup failed`) + const loads: Array = [] + const unloads: Array = [] + const reported: Array<{ + error: unknown + options: LoadSubsetOptions + }> = [] + let begin!: () => void + let commit!: () => true | Promise + let truncate!: () => void + let cleanupArmed = false + let cleanupFailed = false + let outerCallbackCount = 0 + const collection = createCollection({ + id: `nested-replay-callback-frame-cleanup`, + 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 true + }, + unloadSubset: (options) => { + unloads.push(options) + if (cleanupArmed && options.where === whereC && !cleanupFailed) { + cleanupFailed = true + throw cleanupFailure + } + }, + } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}) + subscription.on(`loadSubset:error`, ({ error, options }) => + reported.push({ error, options }), + ) + let unsubscribed = false + + try { + subscription.requestSnapshot({ where: whereC }) + subscription.requestSnapshot({ + where: whereA, + onLoadSubsetResult: () => { + outerCallbackCount++ + if (outerCallbackCount !== 2) return + + let propagatedCleanup: unknown + subscription.requestSnapshot({ + where: whereB, + onLoadSubsetResult: () => { + cleanupArmed = true + try { + subscription.releaseSnapshot(whereC) + } catch (error) { + propagatedCleanup = error + } + }, + }) + throw propagatedCleanup + }, + }) + + begin() + truncate() + commit() + await flushPromises() + + expect(reported).toHaveLength(1) + expect(reported[0]!.error).toBe(cleanupFailure) + expect(loads.indexOf(reported[0]!.options)).toBe(2) + expect(unloads.map((options) => loads.indexOf(options))).toEqual([ + 0, 1, 2, + ]) + + subscription.unsubscribe() + unsubscribed = true + expect(unloads.map((options) => loads.indexOf(options))).toEqual([ + 0, 1, 2, 2, 3, 4, + ]) + } finally { + if (!unsubscribed) subscription.unsubscribe() + await collection.cleanup() + } + }) + + it(`aggregates unsubscribe listener failures after adapter cleanup`, async () => { + type Row = { id: string } + const where = new Func(`eq`, [new PropRef([`id`]), new Value(`a`)]) + const cleanupFailure = new Error(`adapter cleanup failed`) + const listenerFailure = new Error(`unsubscribe listener failed`) + const loads: Array = [] + const unloads: Array = [] + const deferredMicrotasks: Array = [] + let cleanupFailed = false + const collection = createCollection({ + id: `unsubscribe-listener-cleanup-order`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + params.markReady() + return { + loadSubset: (options) => { + loads.push(options) + return true + }, + unloadSubset: (options) => { + unloads.push(options) + if (!cleanupFailed) { + cleanupFailed = true + throw cleanupFailure + } + }, + } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}) + subscription.on(`unsubscribed`, () => { + throw listenerFailure + }) + const queueMicrotaskSpy = vi + .spyOn(globalThis, `queueMicrotask`) + .mockImplementation((callback) => deferredMicrotasks.push(callback)) + + try { + subscription.requestSnapshot({ where }) + let thrown: unknown + try { + subscription.unsubscribe() + } catch (error) { + thrown = error + } + + expect(thrown).toBeInstanceOf(AggregateError) + expect((thrown as AggregateError).errors).toEqual([ + cleanupFailure, + listenerFailure, + ]) + expect(deferredMicrotasks).toEqual([]) + expect(unloads).toEqual([loads[0]]) + + subscription.unsubscribe() + expect(unloads).toEqual([loads[0], loads[0]]) + } finally { + queueMicrotaskSpy.mockRestore() + try { + subscription.unsubscribe() + } catch { + // The assertions above own the first teardown failure. + } + await collection.cleanup() + } + }) + + it(`reports a queued sibling replay failure before callback teardown`, async () => { + type Row = { id: `a` | `b` | `c` } + const ids = [`a`, `b`, `c`] as const + const wheres = ids.map( + (id) => new Func(`eq`, [new PropRef([`id`]), new Value(id)]), + ) + const replays = ids.map(() => createDeferred()) + const failure = new Error(`queued sibling replay failed`) + const loads: Array = [] + const reported: Array<{ + error: unknown + options: LoadSubsetOptions + }> = [] + let begin!: () => void + let commit!: () => true | Promise + let truncate!: () => void + const collection = createCollection({ + id: `queued-sibling-replay-before-unsubscribe`, + 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) + const loadIndex = loads.length - 1 + return loadIndex < ids.length + ? true + : replays[loadIndex - ids.length]!.promise + }, + unloadSubset: () => {}, + } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}) + subscription.on(`loadSubset:error`, ({ error, options }) => + reported.push({ error, options }), + ) + + try { + for (const [index, where] of wheres.entries()) { + subscription.requestSnapshot({ + where, + ...(index === 1 && { + onLoadSubsetResult: (result) => { + if (result instanceof Promise) { + void result.then(() => subscription.unsubscribe()) + } + }, + }), + }) + } + + begin() + truncate() + commit() + await flushPromises() + + replays[0]!.reject(failure) + await flushPromises() + expect(reported).toEqual([]) + + replays[1]!.resolve() + await flushPromises() + + expect(reported).toEqual([{ error: failure, options: loads[3] }]) + expect(subscription.lastError).toBe(failure) + expect(subscription.lastErrorVersion).toBe(1) + + replays[2]!.reject(new Error(`late obsolete replay failure`)) + await flushPromises() + expect(reported).toEqual([{ error: failure, options: loads[3] }]) + expect(subscription.lastErrorVersion).toBe(1) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it(`reports queued and active replay failures in occurrence order`, async () => { + type Row = { id: string } + const whereA = new Func(`eq`, [new PropRef([`id`]), new Value(`a`)]) + const whereB = new Func(`eq`, [new PropRef([`id`]), new Value(`b`)]) + const whereC = new Func(`eq`, [new PropRef([`id`]), new Value(`c`)]) + const whereNested = new Func(`eq`, [ + new PropRef([`id`]), + new Value(`nested`), + ]) + const replayFailure = new Error(`prior replay failed`) + const cleanupFailure = new Error(`callback cleanup failed`) + const startFailure = new Error(`callback start failed`) + const loads: Array = [] + const reported: Array<{ + error: unknown + options: LoadSubsetOptions + }> = [] + let begin!: () => void + let commit!: () => true | Promise + let truncate!: () => void + let replaying = false + let callbackCount = 0 + let cleanupFailed = false + let nestedOptions: LoadSubsetOptions | undefined + const collection = createCollection({ + id: `queued-and-active-replay-failure-order`, + 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) + if (options.where === whereNested) { + nestedOptions = options + throw startFailure + } + if (replaying && options.where === whereA) throw replayFailure + return true + }, + unloadSubset: (options) => { + if (options.where === whereC && !cleanupFailed) { + cleanupFailed = true + throw cleanupFailure + } + }, + } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}) + subscription.on(`loadSubset:error`, ({ error, options }) => + reported.push({ error, options }), + ) + + try { + subscription.requestSnapshot({ where: whereA }) + subscription.requestSnapshot({ + where: whereB, + onLoadSubsetResult: () => { + callbackCount++ + if (callbackCount !== 2) return + try { + subscription.releaseSnapshot(whereC) + } catch { + // Teardown must retain this active callback-frame occurrence. + } + try { + subscription.requestSnapshot({ where: whereNested }) + } catch { + // This occurrence is both queued and reachable through the frame. + } + subscription.unsubscribe() + }, + }) + subscription.requestSnapshot({ where: whereC }) + + replaying = true + begin() + truncate() + commit() + await flushPromises() + + expect(reported).toEqual([ + { error: replayFailure, options: loads[3] }, + { error: cleanupFailure, options: loads[2] }, + { error: startFailure, options: nestedOptions }, + ]) + expect(subscription.lastError).toBe(startFailure) + expect(subscription.lastErrorVersion).toBe(3) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it(`finishes a queued replay error batch before reentrant listener teardown`, async () => { + type Row = { id: `a` | `b` } + const whereA = new Func(`eq`, [new PropRef([`id`]), new Value(`a`)]) + const whereB = new Func(`eq`, [new PropRef([`id`]), new Value(`b`)]) + const replayA = createDeferred() + const replayB = createDeferred() + const failureA = new Error(`first queued replay failed`) + const failureB = new Error(`second queued replay failed`) + const cleanupFailure = new Error(`reentrant cleanup failed`) + const listenerFailure = new Error(`reentrant error listener failed`) + const loads: Array = [] + const reported: Array<{ + error: unknown + options: LoadSubsetOptions + }> = [] + const surfacedErrors: Array = [] + const cleanupErrors: Array = [] + const onceErrors: Array = [] + const nativeQueueMicrotask = globalThis.queueMicrotask + const queueMicrotaskSpy = vi + .spyOn(globalThis, `queueMicrotask`) + .mockImplementation((callback) => + nativeQueueMicrotask(() => { + try { + callback() + } catch (error) { + surfacedErrors.push(error) + } + }), + ) + let begin!: () => void + let commit!: () => true | Promise + let truncate!: () => void + let replaying = false + let terminalCalls = 0 + let unloadAttempts = 0 + const collection = createCollection({ + id: `queued-replay-errors-before-listener-teardown`, + 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) + if (!replaying) return true + return options.where === whereA + ? replayA.promise + : replayB.promise + }, + unloadSubset: (options) => { + if (options.where !== whereA) return + unloadAttempts++ + if (unloadAttempts <= 2) { + throw cleanupFailure + } + }, + } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}) + subscription.on(`unsubscribed`, () => { + terminalCalls++ + }) + subscription.on(`loadSubset:error`, ({ error, options }) => { + reported.push({ error, options }) + if (reported.length === 1) { + subscription.off(`loadSubset:error`, onceListener) + } + try { + subscription.unsubscribe() + } catch (cleanupError) { + cleanupErrors.push(cleanupError) + } + if (reported.length === 1) { + throw listenerFailure + } + }) + const onceListener = ({ error }: { error: unknown }) => { + onceErrors.push(error) + } + subscription.once(`loadSubset:error`, onceListener) + + try { + subscription.requestSnapshot({ where: whereA }) + subscription.requestSnapshot({ where: whereB }) + replaying = true + begin() + truncate() + commit() + await flushPromises() + + replayA.reject(failureA) + replayB.reject(failureB) + await flushPromises() + + expect(reported.map(({ error }) => error)).toEqual([ + failureA, + cleanupFailure, + failureB, + ]) + expect(reported[0]?.options).toBe(loads[2]) + expect(reported[1]?.options).toBe(loads[2]) + expect(reported[2]?.options).toBe(loads[3]) + expect(subscription.lastError).toBe(failureB) + expect(subscription.lastErrorVersion).toBe(3) + expect(terminalCalls).toBe(1) + expect(surfacedErrors).toEqual([listenerFailure]) + expect(cleanupErrors).toEqual([cleanupFailure]) + expect(onceErrors).toEqual([]) + + const attemptsBeforeRetry = unloadAttempts + subscription.unsubscribe() + expect(unloadAttempts).toBe(attemptsBeforeRetry + 1) + expect(terminalCalls).toBe(1) + } finally { + queueMicrotaskSpy.mockRestore() + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it(`reports a caught replay start failure before callback teardown`, async () => { + type Row = { id: string } + const whereA = new Func(`eq`, [new PropRef([`id`]), new Value(`a`)]) + const whereNested = new Func(`eq`, [ + new PropRef([`id`]), + new Value(`nested`), + ]) + const failure = new Error(`nested replay start failed`) + const reported: Array<{ + error: unknown + options: LoadSubsetOptions + }> = [] + let begin!: () => void + let commit!: () => true | Promise + let truncate!: () => void + let callbackCount = 0 + let caught = false + let failedOptions: LoadSubsetOptions | undefined + const collection = createCollection({ + id: `caught-replay-start-before-unsubscribe`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + begin = params.begin + commit = params.commit + truncate = params.truncate + params.markReady() + return { + loadSubset: (options) => { + if (options.where === whereNested) { + failedOptions = options + throw failure + } + return true + }, + unloadSubset: () => {}, + } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}) + subscription.on(`loadSubset:error`, ({ error, options }) => + reported.push({ error, options }), + ) + + try { + subscription.requestSnapshot({ + where: whereA, + onLoadSubsetResult: () => { + callbackCount++ + if (callbackCount !== 2) return + try { + subscription.requestSnapshot({ where: whereNested }) + } catch { + caught = true + } + subscription.unsubscribe() + }, + }) + + begin() + truncate() + commit() + await flushPromises() + + expect(caught).toBe(true) + expect(reported).toEqual([{ error: failure, options: failedOptions }]) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it(`reports and retries caught replay cleanup before callback teardown`, async () => { + type Row = { id: string } + const whereA = new Func(`eq`, [new PropRef([`id`]), new Value(`a`)]) + const whereB = new Func(`eq`, [new PropRef([`id`]), new Value(`b`)]) + const failure = new Error(`nested replay cleanup failed`) + const reported: Array<{ + error: unknown + options: LoadSubsetOptions + }> = [] + const unloads: Array = [] + let begin!: () => void + let commit!: () => true | Promise + let truncate!: () => void + let callbackCount = 0 + let armed = false + let failed = false + let caught = false + let failedOptions: LoadSubsetOptions | undefined + const collection = createCollection({ + id: `caught-replay-cleanup-before-unsubscribe`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + begin = params.begin + commit = params.commit + truncate = params.truncate + params.markReady() + return { + loadSubset: () => true, + unloadSubset: (options) => { + unloads.push(options) + if (armed && options.where === whereB && !failed) { + failed = true + failedOptions = options + throw failure + } + }, + } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}) + subscription.on(`loadSubset:error`, ({ error, options }) => + reported.push({ error, options }), + ) + + try { + subscription.requestSnapshot({ where: whereB }) + subscription.requestSnapshot({ + where: whereA, + onLoadSubsetResult: () => { + callbackCount++ + if (callbackCount !== 2) return + armed = true + try { + subscription.releaseSnapshot(whereB) + } catch { + caught = true + } + subscription.unsubscribe() + }, + }) + + begin() + truncate() + commit() + await flushPromises() + + expect(caught).toBe(true) + expect(reported).toEqual([{ error: failure, options: failedOptions }]) + expect( + unloads.filter((options) => options === failedOptions), + ).toHaveLength(2) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it.each( + ([`adapter-entry`, `cleanup`, `result-callback`] as const).flatMap( + (activeFrame) => + ([`before-failure`, `after-failure`] as const).map( + (teardownOrder) => [activeFrame, teardownOrder] as const, + ), + ), + )( + `retains exact replay failures when teardown starts in %s %s`, + async (activeFrame, teardownOrder) => { + type Row = { id: string } + const whereOuter = new Func(`eq`, [ + new PropRef([`id`]), + new Value(`outer`), + ]) + const whereCleanup = new Func(`eq`, [ + new PropRef([`id`]), + new Value(`cleanup`), + ]) + const whereInner = new Func(`eq`, [ + new PropRef([`id`]), + new Value(`inner`), + ]) + const whereAfterTeardown = new Func(`eq`, [ + new PropRef([`id`]), + new Value(`after-teardown`), + ]) + const failure = new Error(`failure while teardown is requested`) + const reported: Array<{ + error: unknown + options: LoadSubsetOptions + }> = [] + const lifecycle: Array<`error` | `terminal`> = [] + const cleanupUnloads: Array = [] + let begin!: () => void + let commit!: () => true | Promise + let truncate!: () => void + let replaying = false + let callbackCount = 0 + let failedOptions: LoadSubsetOptions | undefined + let failureStarted = false + let postTeardownRequestResult: boolean | undefined + let postTeardownLoads = 0 + + const requestInner = () => + subscription.requestSnapshot({ where: whereInner }) + const failWithinBoundary = (options: LoadSubsetOptions) => { + if (failureStarted) return + failureStarted = true + if (teardownOrder === `before-failure`) { + failedOptions = options + subscription.unsubscribe() + postTeardownRequestResult = subscription.requestSnapshot({ + where: whereAfterTeardown, + }) + throw failure + } + try { + requestInner() + } catch { + // The containing frame retains the exact inner occurrence. + } + subscription.unsubscribe() + postTeardownRequestResult = subscription.requestSnapshot({ + where: whereAfterTeardown, + }) + } + + const collection = createCollection({ + id: `teardown-during-${activeFrame}-${teardownOrder}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + begin = params.begin + commit = params.commit + truncate = params.truncate + params.markReady() + return { + loadSubset: (options) => { + if (options.where === whereAfterTeardown) { + postTeardownLoads++ + } + if (options.where === whereInner) { + failedOptions = options + throw failure + } + if ( + replaying && + activeFrame === `adapter-entry` && + options.where === whereOuter + ) { + failWithinBoundary(options) + } + if ( + replaying && + activeFrame === `cleanup` && + options.where === whereOuter + ) { + subscription.releaseSnapshot(whereCleanup) + } + return true + }, + unloadSubset: (options) => { + if (options.where !== whereCleanup) return + cleanupUnloads.push(options) + if (replaying && activeFrame === `cleanup`) { + failWithinBoundary(options) + } + }, + } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}) + subscription.on(`loadSubset:error`, ({ error, options }) => { + lifecycle.push(`error`) + reported.push({ error, options }) + }) + subscription.on(`unsubscribed`, () => { + lifecycle.push(`terminal`) + subscription.unsubscribe() + }) + + try { + subscription.requestSnapshot({ + where: whereOuter, + onLoadSubsetResult: (_result, options) => { + callbackCount++ + if ( + replaying && + activeFrame === `result-callback` && + callbackCount === 2 + ) { + failWithinBoundary(options) + } + }, + }) + subscription.requestSnapshot({ where: whereCleanup }) + + replaying = true + begin() + truncate() + commit() + await flushPromises() + await flushPromises() + + expect(reported).toEqual([{ error: failure, options: failedOptions }]) + expect(subscription.lastError).toBe(failure) + expect(subscription.lastErrorVersion).toBe(1) + expect(lifecycle).toEqual([`error`, `terminal`]) + expect(postTeardownRequestResult).toBe(false) + expect(postTeardownLoads).toBe(0) + + const unloadsAfterDeferredTeardown = cleanupUnloads.length + subscription.unsubscribe() + expect(cleanupUnloads).toHaveLength( + unloadsAfterDeferredTeardown + + (activeFrame === `cleanup` && teardownOrder === `before-failure` + ? 1 + : 0), + ) + expect(lifecycle).toEqual([`error`, `terminal`]) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }, + ) + + it.each([`adapter-entry`, `cleanup`, `result-callback`] as const)( + `retains teardown cleanup failures caught inside replay %s`, + async (activeFrame) => { + type Row = { id: string } + const whereOuter = new Func(`eq`, [ + new PropRef([`id`]), + new Value(`outer`), + ]) + const whereActiveCleanup = new Func(`eq`, [ + new PropRef([`id`]), + new Value(`active-cleanup`), + ]) + const whereTeardownCleanup = new Func(`eq`, [ + new PropRef([`id`]), + new Value(`teardown-cleanup`), + ]) + const failure = new Error(`teardown cleanup failed`) + const reported: Array<{ + error: unknown + options: LoadSubsetOptions + }> = [] + const lifecycle: Array<`error` | `terminal`> = [] + let begin!: () => void + let commit!: () => true | Promise + let truncate!: () => void + let replaying = false + let callbackCount = 0 + let teardownStarted = false + let teardownCleanupFailed = false + let teardownCleanupOptions: LoadSubsetOptions | undefined + let teardownCleanupUnloads = 0 + let caughtTeardownFailure: unknown + + const startTeardown = () => { + if (teardownStarted) return + teardownStarted = true + try { + subscription.unsubscribe() + } catch (error) { + // Adapter and callback code may catch the teardown failure, + // but that cannot erase the exact cleanup occurrence it represents. + caughtTeardownFailure = error + } + } + + const collection = createCollection({ + id: `caught-teardown-cleanup-during-${activeFrame}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + begin = params.begin + commit = params.commit + truncate = params.truncate + params.markReady() + return { + loadSubset: (options) => { + if ( + replaying && + activeFrame === `adapter-entry` && + options.where === whereOuter + ) { + startTeardown() + } + if ( + replaying && + activeFrame === `cleanup` && + options.where === whereOuter + ) { + subscription.releaseSnapshot(whereActiveCleanup) + } + return true + }, + unloadSubset: (options) => { + if ( + replaying && + activeFrame === `cleanup` && + options.where === whereActiveCleanup + ) { + startTeardown() + } + if (options.where !== whereTeardownCleanup) return + teardownCleanupOptions = options + teardownCleanupUnloads++ + if (!teardownCleanupFailed) { + teardownCleanupFailed = true + throw failure + } + }, + } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}) + subscription.on(`loadSubset:error`, ({ error, options }) => { + lifecycle.push(`error`) + reported.push({ error, options }) + }) + subscription.on(`unsubscribed`, () => lifecycle.push(`terminal`)) + + try { + subscription.requestSnapshot({ + where: whereOuter, + onLoadSubsetResult: () => { + callbackCount++ + if ( + replaying && + activeFrame === `result-callback` && + callbackCount === 2 + ) { + startTeardown() + } + }, + }) + subscription.requestSnapshot({ where: whereActiveCleanup }) + subscription.requestSnapshot({ where: whereTeardownCleanup }) + + replaying = true + begin() + truncate() + commit() + await flushPromises() + await flushPromises() + + expect(caughtTeardownFailure).toBeDefined() + expect(reported).toEqual([ + { error: failure, options: teardownCleanupOptions }, + ]) + expect(subscription.lastError).toBe(failure) + expect(subscription.lastErrorVersion).toBe(1) + expect(lifecycle).toEqual([`error`, `terminal`]) + + subscription.unsubscribe() + expect(teardownCleanupUnloads).toBe(2) + expect(lifecycle).toEqual([`error`, `terminal`]) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }, + ) + + it.each([ + `propagate`, + `return`, + `throw-distinct`, + `throw-same-payload`, + ] as const)( + `retains nested replay cleanup across adapter terminal form %s`, + async (terminalForm) => { + type Row = { id: string } + const whereOuter = new Func(`eq`, [ + new PropRef([`id`]), + new Value(`outer`), + ]) + const whereCleanup = new Func(`eq`, [ + new PropRef([`id`]), + new Value(`cleanup`), + ]) + const whereInner = new Func(`eq`, [ + new PropRef([`id`]), + new Value(`inner`), + ]) + const nestedFailure = new Error(`nested cleanup failed`) + const outerFailure = new Error(`outer replay failed`) + const reported: Array<{ + error: unknown + options: LoadSubsetOptions + }> = [] + let begin!: () => void + let commit!: () => true | Promise + let truncate!: () => void + let replaying = false + let outerLoads = 0 + let cleanupFailed = false + let cleanupOptions: LoadSubsetOptions | undefined + let replayOuterOptions: LoadSubsetOptions | undefined + let cleanupUnloads = 0 + let caughtNestedFailure: unknown + + const collection = createCollection({ + id: `nested-cleanup-${terminalForm}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + begin = params.begin + commit = params.commit + truncate = params.truncate + params.markReady() + return { + loadSubset: (options) => { + if (options.where === whereInner) { + subscription.releaseSnapshot(whereCleanup) + return true + } + if (options.where !== whereOuter) return true + outerLoads++ + if (!replaying || outerLoads !== 2) return true + + replayOuterOptions = options + try { + subscription.requestSnapshot({ where: whereInner }) + } catch (error) { + caughtNestedFailure = error + } + + if (terminalForm === `return`) return true + if (terminalForm === `propagate`) throw caughtNestedFailure + if (terminalForm === `throw-same-payload`) { + throw nestedFailure + } + throw outerFailure + }, + unloadSubset: (options) => { + if (options.where !== whereCleanup) return + cleanupUnloads++ + cleanupOptions ??= options + if (!cleanupFailed) { + cleanupFailed = true + throw nestedFailure + } + }, + } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}) + subscription.on(`loadSubset:error`, ({ error, options }) => + reported.push({ error, options }), + ) + + try { + subscription.requestSnapshot({ where: whereOuter }) + subscription.requestSnapshot({ where: whereCleanup }) + + replaying = true + begin() + truncate() + commit() + await flushPromises() + await flushPromises() + + expect(caughtNestedFailure).not.toBe(nestedFailure) + const outerError = + terminalForm === `throw-distinct` + ? outerFailure + : terminalForm === `throw-same-payload` + ? nestedFailure + : undefined + expect(reported).toEqual([ + { error: nestedFailure, options: cleanupOptions }, + ...(outerError === undefined + ? [] + : [{ error: outerError, options: replayOuterOptions }]), + ]) + expect(subscription.lastErrorVersion).toBe( + outerError === undefined ? 1 : 2, + ) + + subscription.releaseSnapshot(whereCleanup) + expect(cleanupUnloads).toBe(2) + expect(subscription.lastErrorVersion).toBe( + outerError === undefined ? 1 : 2, + ) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }, + ) + + it(`dispatches the terminal event once under reentrant unsubscribe`, async () => { + type Row = { id: string } + const collection = createCollection({ + id: `reentrant-unsubscribe-listener`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + params.markReady() + return { loadSubset: () => true } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}) + let calls = 0 + subscription.on(`unsubscribed`, () => { + calls++ + subscription.unsubscribe() + }) + + expect(() => subscription.unsubscribe()).not.toThrow() + expect(calls).toBe(1) + await expect(collection.cleanup()).resolves.toBeUndefined() + }) + + it(`does not redispatch the terminal event while retrying cleanup debt`, async () => { + type Row = { id: string } + const where = new Func(`eq`, [new PropRef([`id`]), new Value(`a`)]) + const cleanupFailure = new Error(`first cleanup attempt failed`) + const events: Array<`first` | `retry`> = [] + let unloads = 0 + const collection = createCollection({ + id: `terminal-event-cleanup-retry`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + params.markReady() + return { + loadSubset: () => true, + unloadSubset: () => { + unloads++ + if (unloads === 1) throw cleanupFailure + }, + } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}) + subscription.requestSnapshot({ where }) + subscription.on(`unsubscribed`, () => events.push(`first`)) + + expect(() => subscription.unsubscribe()).toThrow(cleanupFailure) + subscription.on(`unsubscribed`, () => events.push(`retry`)) + expect(() => subscription.unsubscribe()).not.toThrow() + + expect(unloads).toBe(2) + expect(events).toEqual([`first`]) + await expect(collection.cleanup()).resolves.toBeUndefined() + }) + + it(`preserves a synchronous acquisition failure nested inside cleanup`, async () => { + type Row = { id: string } + const whereA = new Func(`eq`, [new PropRef([`id`]), new Value(`a`)]) + const whereB = new Func(`eq`, [new PropRef([`id`]), new Value(`b`)]) + const failure = new Error(`nested acquisition failed`) + const loads: Array = [] + const unloads: Array = [] + let nestedOptions: LoadSubsetOptions | undefined + type TestSubscription = ReturnType< + ReturnType>[`subscribeChanges`] + > + const owner: { current?: TestSubscription } = {} + const collection = createCollection({ + id: `synchronous-acquisition-failure-inside-cleanup`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + params.markReady() + return { + loadSubset: (options) => { + loads.push(options) + if (options.where === whereB) { + nestedOptions = options + throw failure + } + return true + }, + unloadSubset: (options) => { + unloads.push(options) + if (options.where === whereA) { + try { + owner.current!.requestSnapshot({ where: whereB }) + } catch { + // The surrounding cleanup boundary retains the attributed + // failure even after adapter code handles its propagation. + } + } + }, + } + }, + }, + }) + const reported: Array<{ error: unknown; options: LoadSubsetOptions }> = [] + const subscription = collection.subscribeChanges(() => {}) + owner.current = subscription + subscription.on(`loadSubset:error`, (event) => reported.push(event)) + + try { + subscription.requestSnapshot({ where: whereA }) + let thrown: unknown + try { + subscription.releaseSnapshot(whereA) + } catch (error) { + thrown = error + } + + expect(Object.is(thrown, failure)).toBe(true) + expect(reported).toHaveLength(1) + expect(Object.is(reported[0]?.error, failure)).toBe(true) + expect(reported[0]?.options).toBe(nestedOptions) + expect(unloads).toEqual([loads[0]]) + subscription.unsubscribe() + expect(unloads).toEqual([loads[0]]) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it(`reports a promise-adopted acquisition failure nested inside cleanup once`, async () => { + type Row = { id: string } + const whereOuter = new Func(`eq`, [new PropRef([`id`]), new Value(`outer`)]) + const whereMiddle = new Func(`eq`, [ + new PropRef([`id`]), + new Value(`middle`), + ]) + const whereInner = new Func(`eq`, [new PropRef([`id`]), new Value(`inner`)]) + const failure = new Error(`nested asynchronous acquisition failed`) + let innerOptions: LoadSubsetOptions | undefined + type TestSubscription = ReturnType< + ReturnType>[`subscribeChanges`] + > + const owner: { current?: TestSubscription } = {} + const collection = createCollection({ + id: `promise-adopted-acquisition-failure-inside-cleanup`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + params.markReady() + return { + loadSubset: (options) => { + if (options.where === whereInner) { + innerOptions = options + throw failure + } + if (options.where === whereMiddle) { + return (async () => { + owner.current!.requestSnapshot({ where: whereInner }) + await Promise.resolve() + })() + } + return true + }, + unloadSubset: (options) => { + if (options.where === whereOuter) { + owner.current!.requestSnapshot({ where: whereMiddle }) + } + }, + } + }, + }, + }) + const reported: Array<{ error: unknown; options: LoadSubsetOptions }> = [] + const subscription = collection.subscribeChanges(() => {}) + owner.current = subscription + subscription.on(`loadSubset:error`, ({ error, options }) => + reported.push({ error, options }), + ) + + try { + subscription.requestSnapshot({ where: whereOuter }) + expect(() => subscription.releaseSnapshot(whereOuter)).toThrow(failure) + await flushPromises() + + expect(reported).toHaveLength(1) + expect(Object.is(reported[0]?.error, failure)).toBe(true) + expect(reported[0]?.options).toBe(innerOptions) + expect(subscription.lastError).toBe(failure) + expect(subscription.lastErrorVersion).toBe(1) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it.each( + ([`unordered`, `ordered`] as const).flatMap((demandKind) => + ([`throw`, `reject`] as const).map( + (laterFailure) => [demandKind, laterFailure] as const, + ), + ), + )( + `does not let a retained propagation carrier erase a later %s %s`, + async (demandKind, laterFailure) => { + type Row = { id: string; rank: number } + const whereOuter = new Func(`eq`, [ + new PropRef([`id`]), + new Value(`outer`), + ]) + const whereMiddle = new Func(`eq`, [ + new PropRef([`id`]), + new Value(`middle`), + ]) + const whereInner = new Func(`eq`, [ + new PropRef([`id`]), + new Value(`inner`), + ]) + const whereLater = new Func(`eq`, [ + new PropRef([`id`]), + new Value(`later`), + ]) + const orderBy: OrderBy = [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ] + const failure = new Error(`retained carrier payload`) + let retainedCarrier: unknown + let innerOptions: LoadSubsetOptions | undefined + let laterOptions: LoadSubsetOptions | undefined + type TestSubscription = ReturnType< + ReturnType>[`subscribeChanges`] + > + const owner: { current?: TestSubscription } = {} + const collection = createCollection({ + id: `retained-propagation-carrier-${demandKind}-${laterFailure}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + params.markReady() + return { + loadSubset: (options) => { + if (options.where === whereInner) { + innerOptions = options + throw failure + } + if (options.where === whereMiddle) { + try { + owner.current!.requestSnapshot({ where: whereInner }) + } catch (error) { + retainedCarrier = error + } + return true + } + if ( + options.where === whereLater || + (demandKind === `ordered` && options.orderBy === orderBy) + ) { + laterOptions = options + if (laterFailure === `throw`) throw retainedCarrier + return Promise.reject(retainedCarrier) + } + return true + }, + unloadSubset: (options) => { + if (options.where === whereOuter) { + owner.current!.requestSnapshot({ where: whereMiddle }) + } + }, + } + }, + }, + }) + const index = collection.createIndex((row) => row.rank, { + indexType: BTreeIndex, + }) + const reported: Array<{ error: unknown; options: LoadSubsetOptions }> = [] + const subscription = collection.subscribeChanges(() => {}) + owner.current = subscription + subscription.setOrderByIndex(index) + subscription.on(`loadSubset:error`, ({ error, options }) => + reported.push({ error, options }), + ) + + try { + subscription.requestSnapshot({ where: whereOuter }) + expect(() => subscription.releaseSnapshot(whereOuter)).toThrow(failure) + expect(Object.is(retainedCarrier, failure)).toBe(false) + + const requestLater = () => + demandKind === `ordered` + ? subscription.requestLimitedSnapshot({ orderBy, limit: 1 }) + : subscription.requestSnapshot({ where: whereLater }) + if (laterFailure === `throw`) { + expect(requestLater).toThrow(failure) + } else { + requestLater() + await flushPromises() + } + + expect(reported).toHaveLength(2) + expect(Object.is(reported[0]?.error, failure)).toBe(true) + expect(reported[0]?.options).toBe(innerOptions) + expect(Object.is(reported[1]?.error, failure)).toBe(true) + expect(reported[1]?.options).toBe(laterOptions) + expect(subscription.lastError).toBe(failure) + expect(subscription.lastErrorVersion).toBe(2) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }, + ) + + it(`keeps failures after asynchronous suspension as distinct adapter occurrences`, async () => { + type Row = { id: string } + const whereOuter = new Func(`eq`, [new PropRef([`id`]), new Value(`outer`)]) + const whereMiddle = new Func(`eq`, [ + new PropRef([`id`]), + new Value(`middle`), + ]) + const whereInner = new Func(`eq`, [new PropRef([`id`]), new Value(`inner`)]) + const failure = new Error(`shared asynchronous failure payload`) + let middleOptions: LoadSubsetOptions | undefined + let innerOptions: LoadSubsetOptions | undefined + type TestSubscription = ReturnType< + ReturnType>[`subscribeChanges`] + > + const owner: { current?: TestSubscription } = {} + const collection = createCollection({ + id: `suspended-acquisition-failure-inside-cleanup`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + params.markReady() + return { + loadSubset: (options) => { + if (options.where === whereInner) { + innerOptions = options + throw failure + } + if (options.where === whereMiddle) { + middleOptions = options + return (async () => { + await Promise.resolve() + owner.current!.requestSnapshot({ where: whereInner }) + })() + } + return true + }, + unloadSubset: (options) => { + if (options.where === whereOuter) { + owner.current!.requestSnapshot({ where: whereMiddle }) + } + }, + } + }, + }, + }) + const reported: Array<{ error: unknown; options: LoadSubsetOptions }> = [] + const subscription = collection.subscribeChanges(() => {}) + owner.current = subscription + subscription.on(`loadSubset:error`, ({ error, options }) => + reported.push({ error, options }), + ) + + try { + subscription.requestSnapshot({ where: whereOuter }) + subscription.releaseSnapshot(whereOuter) + await flushPromises() + + expect(reported).toHaveLength(2) + expect(Object.is(reported[0]?.error, failure)).toBe(true) + expect(reported[0]?.options).toBe(innerOptions) + expect(Object.is(reported[1]?.error, failure)).toBe(true) + expect(reported[1]?.options).toBe(middleOptions) + expect(subscription.lastError).toBe(failure) + expect(subscription.lastErrorVersion).toBe(2) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it.each([ + { name: `Error`, failure: new Error(`shared cleanup payload`) }, + { + name: `AggregateError`, + failure: new AggregateError( + [new Error(`inner cleanup payload`)], + `shared cleanup payload`, + ), + }, + { name: `undefined`, failure: undefined }, + { name: `NaN`, failure: Number.NaN }, + ])( + `distinguishes nested and outer cleanup occurrences with the same $name payload`, + async ({ failure }) => { + const result = await exerciseNestedCleanupGraph({ + id: `same-payload-nested-cleanup-${String(failure)}`, + ids: [`a`, `b`], + edges: new Map([[0, { targets: [1], catchFailures: true }]]), + failures: new Map([ + [0, failure], + [1, failure], + ]), + }) + + expect(result.reported.map(({ error }) => error)).toEqual([ + failure, + failure, + ]) + expect(result.reported.map(({ optionsIndex }) => optionsIndex)).toEqual([ + 1, 0, + ]) + expect(result.beforeRetry).toEqual([0, 3, 1, 2]) + expect(result.afterRetry).toEqual([0, 3, 1, 2, 0, 1]) + expect(result.publishedIds).toEqual([`a`, `b`]) + expect(result.status).toBe(`ready`) + }, + ) + + it(`installs a completed handoff while retaining its nested cleanup failure`, async () => { + const nestedFailure = new Error(`nested cleanup failed`) + const result = await exerciseNestedCleanupGraph({ + id: `completed-handoff-with-nested-failure`, + ids: [`a`, `b`], + edges: new Map([[0, { targets: [1], catchFailures: true }]]), + failures: new Map([[1, nestedFailure]]), + }) + + expect(result.reported).toEqual([{ error: nestedFailure, optionsIndex: 1 }]) + expect(result.beforeRetry).toEqual([0, 3, 1]) + expect(result.afterRetry).toEqual([0, 3, 1, 2, 1]) + expect(result.publishedIds).toEqual([`a`, `b`]) + expect(result.status).toBe(`ready`) + }) + + it(`preserves failure order and ownership through four cleanup levels`, async () => { + const failureC = new Error(`C cleanup failed`) + const failureD = new Error(`D cleanup failed`) + const result = await exerciseNestedCleanupGraph({ + id: `four-level-nested-cleanup`, + ids: [`a`, `b`, `c`, `d`], + edges: new Map([ + [0, { targets: [1], catchFailures: false }], + [1, { targets: [2], catchFailures: true }], + [2, { targets: [3], catchFailures: true }], + ]), + failures: new Map([ + [2, failureC], + [3, failureD], + ]), + }) + + expect(result.reported).toEqual([ + { error: failureD, optionsIndex: 3 }, + { error: failureC, optionsIndex: 2 }, + ]) + expect(result.beforeRetry).toEqual([0, 5, 1, 6, 2, 7, 3, 4]) + expect(result.afterRetry).toEqual([0, 5, 1, 6, 2, 7, 3, 4, 0, 2, 3]) + expect(result.publishedIds).toEqual([`a`, `b`, `c`, `d`]) + expect(result.status).toBe(`ready`) + }) + + it(`preserves sibling cleanup failures in callback order`, async () => { + const failureB = new Error(`B cleanup failed`) + const failureC = new Error(`C cleanup failed`) + const result = await exerciseNestedCleanupGraph({ + id: `sibling-nested-cleanup`, + ids: [`a`, `b`, `c`], + edges: new Map([[0, { targets: [1, 2], catchFailures: true }]]), + failures: new Map([ + [1, failureB], + [2, failureC], + ]), + }) + + expect(result.reported).toEqual([ + { error: failureB, optionsIndex: 1 }, + { error: failureC, optionsIndex: 2 }, + ]) + expect(result.beforeRetry).toEqual([0, 4, 1, 5, 2]) + expect(result.afterRetry).toEqual([0, 4, 1, 5, 2, 3, 1, 2]) + expect(result.publishedIds).toEqual([`a`, `b`, `c`]) + expect(result.status).toBe(`ready`) + }) + + it(`collects inactive demand state after late replay cleanup succeeds`, async () => { + type Row = { id: string; rank: number } + type Outcome = { hasMore: boolean; appliedRowKeys: ReadonlyArray } + let begin!: () => void + let write!: (message: ChangeMessageOrDeleteKeyMessage) => void + let commit!: () => void + let truncate!: () => void + let loadCount = 0 + let failReplayUnload = true + const replay = createDeferred() + const loads: Array = [] + const unloadSignals: Array = [] + const collection = createCollection({ + id: `late-replay-cleanup-collection`, + 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) => { + loads.push(options) + loadCount++ + if (loadCount === 1) { + begin() + write({ type: `insert`, value: { id: `a`, rank: 1 } }) + commit() + return Promise.resolve({ + hasMore: false, + appliedRowKeys: [`a`], + }) + } + return replay.promise + }, + unloadSubset: (options) => { + unloadSignals.push(options.signal) + if (options.signal === loads[1]?.signal && failReplayUnload) { + failReplayUnload = false + throw new Error(`replay unload failed`) + } + }, + } + }, + }, + }) + const where = new Func(`gte`, [new PropRef([`rank`]), new Value(0)]) + const orderBy: OrderBy = [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ] + const index = collection.createIndex((row) => row.rank, { + indexType: BTreeIndex, + }) + const subscription = collection.subscribeChanges(() => {}, { + whereExpression: where, + }) + subscription.setOrderByIndex(index) + + try { + subscription.requestLimitedSnapshot({ orderBy, limit: 1 }) + await flushPromises() + begin() + truncate() + commit() + await flushPromises() + + expect(() => subscription.releaseSnapshot(where)).toThrow( + `replay unload failed`, + ) + replay.resolve({ hasMore: false, appliedRowKeys: [] }) + await flushPromises() + + expect(unloadSignals).toEqual([ + loads[1]?.signal, + loads[0]?.signal, + loads[1]?.signal, + ]) + subscription.unsubscribe() + expect(unloadSignals).toHaveLength(3) + } finally { + failReplayUnload = false + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it(`restores top-K admission when ordered demand restarts over a stale additional row`, async () => { + type Row = { id: string; rank: number } + let begin!: () => void + let write!: (message: ChangeMessageOrDeleteKeyMessage) => void + let commit!: (signal?: AbortSignal) => true | Promise + let truncate!: () => void + let loadCount = 0 + const collection = createCollection({ + id: `ordered-restart-over-stale-additional-row`, + 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: `a`, rank: 1 } }) + commit(options.signal) + return Promise.resolve({ + hasMore: false, + appliedRowKeys: [`a`] as const, + }) + } + if (loadCount === 2) return true + if (loadCount === 3) { + return Promise.reject(new Error(`ordered replay failed`)) + } + if (loadCount === 4) { + begin() + write({ type: `insert`, value: { id: `a`, rank: 1 } }) + commit(options.signal) + return Promise.resolve({ + hasMore: false, + appliedRowKeys: [`a`] as const, + }) + } + if (loadCount === 5) { + begin() + write({ type: `insert`, value: { id: `x`, rank: 0 } }) + write({ type: `insert`, value: { id: `y`, rank: 2 } }) + commit(options.signal) + return Promise.resolve({ + hasMore: false, + appliedRowKeys: [`x`, `y`] as const, + }) + } + return true + }, + unloadSubset: () => {}, + } + }, + }, + }) + const orderedWhere = new Func(`gte`, [new PropRef([`rank`]), new Value(0)]) + const additionalWhere = new Func(`eq`, [ + new PropRef([`id`]), + new Value(`a`), + ]) + const orderBy: OrderBy = [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ] + const index = collection.createIndex((row) => row.rank, { + indexType: BTreeIndex, + }) + const visible = new Map() + const subscription = collection.subscribeChanges( + (changes) => { + for (const change of changes) { + const key = String(change.key) + if (change.type === `delete`) visible.delete(key) + else visible.set(key, change.value) + } + }, + { whereExpression: orderedWhere }, + ) + subscription.setOrderByIndex(index) + + try { + subscription.requestLimitedSnapshot({ orderBy, limit: 1 }) + await flushPromises() + subscription.requestSnapshot({ where: additionalWhere }) + await flushPromises() + + begin() + truncate() + commit() + await flushPromises() + subscription.releaseSnapshot(orderedWhere) + + expect([...visible.keys()]).toEqual([`a`]) + expect(subscription.orderedBoundaryKey).toBeUndefined() + + subscription.requestLimitedSnapshot({ orderBy, limit: 1 }) + await flushPromises() + + expect([...visible.keys()].sort()).toEqual([`a`, `x`]) + expect(subscription.orderedBoundaryKey).toBe(`x`) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it(`rejects a reentrant subset acquisition after unsubscribe starts`, async () => { + type Row = { id: string } + const where = new Func(`eq`, [new PropRef([`id`]), new Value(`a`)]) + const loads: Array = [] + const unloads: Array = [] + let acquireDuringUnload = () => {} + let reentered = false + const collection = createCollection({ + id: `unsubscribe-reentrant-acquisition`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: (options) => { + loads.push(options) + return true + }, + unloadSubset: (options) => { + unloads.push(options) + if (!reentered) { + reentered = true + acquireDuringUnload() + } + }, + } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}) + acquireDuringUnload = () => { + subscription.requestSnapshot({ where }) + } + + try { + subscription.requestSnapshot({ where }) + subscription.unsubscribe() + + expect(loads).toHaveLength(1) + expect(unloads).toEqual(loads) + expect(loads[0]?.signal?.aborted).toBe(true) + } 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< + ReturnType< + typeof createDeferred<{ + hasMore: boolean + appliedRowKeys: Array + }> + > + > = [] + const replayRows: ReadonlyArray = + identity === `same` + ? [ + { id: `one`, value: 1 }, + { id: `two`, value: 2 }, ] : [ { id: `three`, value: 1 }, @@ -1850,16 +8241,38 @@ describe(`CollectionSubscription replay oracle`, () => { 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 === 1) { + const row = + direction === `asc` + ? ({ id: `one`, value: 1 } as const) + : ({ id: `two`, value: 2 } as const) + begin() + write({ type: `insert`, value: row }) + commit() + return Promise.resolve({ + hasMore: true, + appliedRowKeys: [row.id], + }) + } + if (loadCount === 2) { + const row = + direction === `asc` + ? ({ id: `two`, value: 2 } as const) + : ({ id: `one`, value: 1 } as const) + begin() + write({ type: `insert`, value: row }) + commit() + return Promise.resolve({ + hasMore: false, + appliedRowKeys: [row.id], + }) + } + if (loadCount > 4) return true if (delivery === `return`) { @@ -1873,7 +8286,10 @@ describe(`CollectionSubscription replay oracle`, () => { return true } - const deferred = createDeferred() + const deferred = createDeferred<{ + hasMore: boolean + appliedRowKeys: Array + }>() replayLoads.push(deferred) return deferred.promise }, @@ -1893,8 +8309,16 @@ describe(`CollectionSubscription replay oracle`, () => { }, ] const batches: Array> = [] + const visibleIds = new Set() + const publicationSnapshots: Array> = [] const subscription = collection.subscribeChanges((changes) => { batches.push(changes.map(({ value }) => value.id)) + for (const change of changes) { + if (change.type === `delete`) + visibleIds.delete(change.key as OrderedReplayRow[`id`]) + else visibleIds.add(change.key as OrderedReplayRow[`id`]) + } + publicationSnapshots.push([...visibleIds].sort()) }) subscription.setOrderByIndex(orderedIndex) @@ -1911,33 +8335,63 @@ describe(`CollectionSubscription replay oracle`, () => { try { subscription.requestLimitedSnapshot({ orderBy, limit: 1 }) - expect(batches).toEqual([[initialIds[0]]]) + await flushPromises() + // A finite ordered result stays unpublished until the continuation + // proves the complete boundary class used for the public-key tie-break. + expect(batches).toEqual([]) subscription.requestLimitedSnapshot({ orderBy, limit: 1, minValues: [direction === `asc` ? 1 : 2], }) + await flushPromises() + expect(batches).toEqual([[initialIds[0]]]) expect(loadOptions[1]).toMatchObject({ - offset: 1, - cursor: { lastKey: initialIds[1] }, + offset: 0, + cursor: { lastKey: initialIds[0] }, }) begin() truncate() commit() await flushPromises() - expectSameSubsetRequest(loadOptions[2]!, loadOptions[0]!) - expectSameSubsetRequest(loadOptions[3]!, loadOptions[1]!) + const replayOptions = loadOptions + .slice(2) + .filter((options) => options.limit !== undefined) + expectReplayRequestToRestart(replayOptions[0]!, loadOptions[0]!) + expectReplayRequestToRestart( + replayOptions[1]!, + loadOptions[1]!, + // A synchronous first replay acquisition can establish private + // current-generation progress before the second one is rebuilt. + delivery === `return` ? 1 : 0, + ) + + if (delivery === `resolve` || delivery === `reject`) { + const batchesBeforeResize = batches.length + subscription.ensureOrderedWindowSize(2) + subscription.ensureOrderedWindowSize(1) + expect(batches).toHaveLength(batchesBeforeResize) + } if (delivery === `resolve`) { expect(replayLoads).toHaveLength(2) installReplayRows() - replayLoads[0]?.resolve() - replayLoads[1]?.resolve() + replayLoads[0]?.resolve({ + hasMore: true, + appliedRowKeys: [expectedIds[0]], + }) + replayLoads[1]?.resolve({ + hasMore: false, + appliedRowKeys: [expectedIds[1]], + }) } else if (delivery === `reject`) { expect(replayLoads).toHaveLength(2) replayLoads[0]?.reject(new Error(`ordered replay failed`)) - replayLoads[1]?.resolve() + replayLoads[1]?.resolve({ + hasMore: false, + appliedRowKeys: [initialIds[1]], + }) } else { expect(replayLoads).toEqual([]) } @@ -1945,19 +8399,280 @@ describe(`CollectionSubscription replay oracle`, () => { expect(collection.toArray.map(({ id }) => id).sort()).toEqual( succeeds ? [...expectedIds].sort() : [], ) + expect(publicationSnapshots).toEqual( + delivery === `resolve` + ? [[initialIds[0]], [...expectedIds].sort()] + : delivery === `return` && identity === `changed` + ? [[initialIds[0]], [expectedIds[0]]] + : [[initialIds[0]]], + ) + const loadCountBeforeWiden = loadOptions.length subscription.requestLimitedSnapshot({ orderBy, limit: 1, minValues: [direction === `asc` ? 2 : 1], }) - expect(loadOptions[4]).toMatchObject({ - offset: 2, - cursor: { - lastKey: succeeds ? expectedIds[1] : initialIds[1], + if (succeeds) { + expect(loadOptions).toHaveLength(loadCountBeforeWiden) + } else { + expect(loadOptions[loadCountBeforeWiden]).toMatchObject({ + offset: 1, + cursor: { lastKey: initialIds[0] }, + }) + } + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }, + ) + + it.each([`resolve`, `reject`] as const)( + `keeps an empty ordered publication private until every replay demand settles: %s`, + async (otherOutcome) => { + type Row = { + id: `new-ordered` + rank: number + route: `ordered` | `other` + } + type Outcome = { + hasMore: boolean + appliedRowKeys: ReadonlyArray + } + let begin!: () => void + let write!: ( + message: ChangeMessageOrDeleteKeyMessage, + ) => void + let commit!: () => void + let truncate!: () => void + let initialLoads = 2 + const history: Array = [ + { + type: `stagePublicationRows`, + publicationId: `initial`, + demandId: `ordered`, + rows: [], + }, + { type: `commitPublication`, publicationId: `initial` }, + { + type: `requestDemand`, + ownerId: `other-owner`, + sessionId: `session`, + demandId: `other`, + alreadyAborted: false, + }, + { + type: `stagePublicationRows`, + publicationId: `initial`, + demandId: `other`, + rows: [], + }, + { type: `commitPublication`, publicationId: `initial` }, + ] + const expectedBoundary = () => + projectAtomicOrderedPublicationState(history, { + demandId: `ordered`, + direction: `asc`, + initialWindowSize: 1, + }).currentPublication?.orderedBoundary?.key + const replayLoads: Array<{ + options: LoadSubsetOptions + deferred: ReturnType> + }> = [] + const collection = createCollection({ + id: `empty-ordered-replay-${otherOutcome}`, + 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 (initialLoads > 0) { + initialLoads-- + return Promise.resolve({ + hasMore: false, + appliedRowKeys: [], + }) + } + const deferred = createDeferred() + replayLoads.push({ options, deferred }) + return deferred.promise + }, + unloadSubset: () => {}, + } }, + }, + }) + const index = collection.createIndex((row) => row.rank, { + indexType: BTreeIndex, + }) + const orderBy: OrderBy = [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ] + const otherWhere = new Func(`eq`, [ + new PropRef([`route`]), + new Value(`other`), + ]) + const visible = new Set() + const subscription = collection.subscribeChanges((changes) => { + for (const change of changes) { + const key = change.key as Row[`id`] + if (change.type === `delete`) visible.delete(key) + else visible.add(key) + } + }) + subscription.setOrderByIndex(index) + + try { + subscription.requestLimitedSnapshot({ orderBy, limit: 1 }) + subscription.requestSnapshot({ where: otherWhere }) + await flushPromises() + expect([...visible]).toEqual([]) + expect(subscription.orderedBoundaryKey).toBeUndefined() + + begin() + truncate() + commit() + await flushPromises() + expect(replayLoads).toHaveLength(2) + history.push({ + type: `beginReplacement`, + publicationId: `replacement`, + demandIds: [`ordered`, `other`], + }) + + const orderedReplay = replayLoads.find(({ options }) => options.orderBy) + const otherReplay = replayLoads.find(({ options }) => !options.orderBy) + if (!orderedReplay || !otherReplay) { + throw new Error(`Expected ordered and additional replay demands`) + } + + begin() + write({ + type: `insert`, + value: { id: `new-ordered`, rank: 1, route: `ordered` }, + }) + commit() + history.push({ + type: `stagePublicationRows`, + publicationId: `replacement`, + demandId: `ordered`, + rows: [{ key: `new-ordered`, orderValue: 1 }], + }) + orderedReplay.deferred.resolve({ + hasMore: false, + appliedRowKeys: [`new-ordered`], }) - expect(batches.at(-1)).toEqual([]) + history.push({ + type: `settleReplacement`, + publicationId: `replacement`, + demandId: `ordered`, + outcome: `success`, + extent: `exhausted`, + }) + await flushPromises() + + expect([...visible]).toEqual([]) + expect(subscription.orderedBoundaryKey).toBe(expectedBoundary()) + + if (otherOutcome === `resolve`) { + otherReplay.deferred.resolve({ + hasMore: false, + appliedRowKeys: [], + }) + history.push({ + type: `settleReplacement`, + publicationId: `replacement`, + demandId: `other`, + outcome: `success`, + extent: `exhausted`, + }) + } else { + otherReplay.deferred.reject(new Error(`other replay failed`)) + history.push({ + type: `settleReplacement`, + publicationId: `replacement`, + demandId: `other`, + outcome: `failure`, + }) + } + await flushPromises() + + expect([...visible]).toEqual( + otherOutcome === `resolve` ? [`new-ordered`] : [], + ) + expect(subscription.orderedBoundaryKey).toBe(expectedBoundary()) + + if (otherOutcome === `resolve`) { + // Fail the next generation so the changed-key publication becomes + // the retained restoration baseline, then widen it. The request must + // continue from the new public key and prefix. + begin() + truncate() + commit() + await flushPromises() + const nextReplayLoads = replayLoads.slice(2) + expect(nextReplayLoads).toHaveLength(2) + history.push({ + type: `beginReplacement`, + publicationId: `failed-replacement`, + demandIds: [`ordered`, `other`], + }) + const nextOrderedReplay = nextReplayLoads.find( + ({ options }) => options.orderBy, + ) + const nextOtherReplay = nextReplayLoads.find( + ({ options }) => !options.orderBy, + ) + if (!nextOrderedReplay || !nextOtherReplay) { + throw new Error(`Expected the next ordered and additional replays`) + } + nextOrderedReplay.deferred.reject(new Error(`next replay failed`)) + history.push({ + type: `settleReplacement`, + publicationId: `failed-replacement`, + demandId: `ordered`, + outcome: `failure`, + }) + nextOtherReplay.deferred.resolve({ + hasMore: false, + appliedRowKeys: [], + }) + history.push({ + type: `settleReplacement`, + publicationId: `failed-replacement`, + demandId: `other`, + outcome: `success`, + extent: `exhausted`, + }) + await flushPromises() + expect(subscription.orderedBoundaryKey).toBe(expectedBoundary()) + + const loadCountBeforeWiden = replayLoads.length + subscription.requestLimitedSnapshot({ + orderBy, + limit: 1, + minValues: [1], + }) + expect(replayLoads[loadCountBeforeWiden]?.options).toMatchObject({ + offset: 1, + cursor: { lastKey: `new-ordered` }, + }) + replayLoads[loadCountBeforeWiden]?.deferred.resolve({ + hasMore: false, + appliedRowKeys: [], + }) + await flushPromises() + } } finally { subscription.unsubscribe() await collection.cleanup() @@ -2169,7 +8884,11 @@ describe(`CollectionSubscription replay oracle`, () => { fcTest.prop([replayScenarioArbitrary], { numRuns: generatedRuns, seed: 1756, - })(`matches replay and ownership laws for a fixed seed`, runReplayScenario) + })( + `matches replay and ownership laws for a fixed seed`, + runReplayScenario, + generatedTimeout, + ) fcTest.prop( [replayScenarioArbitrary], @@ -2177,6 +8896,7 @@ describe(`CollectionSubscription replay oracle`, () => { )( `matches replay and ownership laws for a random or replayed seed`, runReplayScenario, + generatedTimeout, ) fcTest.prop( @@ -2185,6 +8905,7 @@ describe(`CollectionSubscription replay oracle`, () => { )( `matches synchronous, asynchronous, and partial-failure replay laws`, runSequentialReplayScenario, + generatedTimeout, ) it(`releases every exact acquisition once across bounded replay completion histories`, async () => { @@ -2215,6 +8936,7 @@ describe(`CollectionSubscription replay oracle`, () => { })( `isolates cleanup and restart sessions for a fixed seed`, runCleanupRestartScenario, + generatedTimeout, ) fcTest.prop( @@ -2223,6 +8945,7 @@ describe(`CollectionSubscription replay oracle`, () => { )( `isolates cleanup and restart sessions for a random or replayed seed`, runCleanupRestartScenario, + generatedTimeout, ) fcTest.prop([sharedSubscriptionScenarioArbitrary], { @@ -2231,6 +8954,7 @@ describe(`CollectionSubscription replay oracle`, () => { })( `keeps shared transport and logical ownership distinct for a fixed seed`, runSharedSubscriptionScenario, + generatedTimeout, ) fcTest.prop( @@ -2239,6 +8963,7 @@ describe(`CollectionSubscription replay oracle`, () => { )( `keeps shared transport and logical ownership distinct for a random or replayed seed`, runSharedSubscriptionScenario, + generatedTimeout, ) fcTest.prop([optimisticReplayScenarioArbitrary], { @@ -2247,6 +8972,7 @@ describe(`CollectionSubscription replay oracle`, () => { })( `preserves optimistic overlays across replay outcomes for a fixed seed`, runOptimisticReplayScenario, + generatedTimeout, ) fcTest.prop( @@ -2255,5 +8981,6 @@ describe(`CollectionSubscription replay oracle`, () => { )( `preserves optimistic overlays across replay outcomes for a random or replayed seed`, runOptimisticReplayScenario, + generatedTimeout, ) }) diff --git a/packages/db/tests/cursor.property.test.ts b/packages/db/tests/cursor.property.test.ts index 04c2fa536..c2a1c3ae4 100644 --- a/packages/db/tests/cursor.property.test.ts +++ b/packages/db/tests/cursor.property.test.ts @@ -1,370 +1,95 @@ -import { describe, expect, it } from 'vitest' import { fc, test as fcTest } from '@fast-check/vitest' -import { buildCursor } from '../src/utils/cursor' -import { Func, PropRef, Value } from '../src/query/ir' -import type { OrderBy, OrderByClause } from '../src/query/ir' -import type { CompareOptions } from '../src/query/builder/types' - -/** - * Property-based tests for cursor building - * - * Key properties: - * 1. Empty inputs return undefined - * 2. Single column produces simple gt/lt based on direction - * 3. Direction affects operator choice (asc = gt, desc = lt) - * 4. Determinism - same inputs always produce same output - * 5. Result structure is always valid - */ - -// Arbitraries for generating test data -const arbitraryDirection = fc.constantFrom(`asc`, `desc`) - -const arbitraryNulls = fc.constantFrom(`first`, `last`) - -const arbitraryStringSort = fc.constantFrom(`locale`, `lexical`) - -const arbitraryCompareOptions = fc.record({ - direction: arbitraryDirection, - nulls: arbitraryNulls, - stringSort: arbitraryStringSort, -}) as fc.Arbitrary - -const arbitraryPropRef = fc - .array(fc.string({ minLength: 1, maxLength: 10 }), { - minLength: 1, - maxLength: 3, - }) - .map((path) => new PropRef(path)) - -const arbitraryOrderByClause = fc - .tuple(arbitraryPropRef, arbitraryCompareOptions) - .map( - ([expr, compareOptions]): OrderByClause => ({ - expression: expr, - compareOptions, - }), - ) - -const arbitraryOrderBy = ( - minLength: number, - maxLength: number, -): fc.Arbitrary => - fc.array(arbitraryOrderByClause, { minLength, maxLength }) +import { describe, expect, it } from 'vitest' +import { PropRef } from '../src/query/ir.js' +import { buildCursor } from '../src/utils/cursor.js' +import { evaluateReferenceExpression } from './reference-expression.js' +import type { OrderBy } from '../src/query/ir.js' + +type Term = { + direction: `asc` | `desc` + nulls: `first` | `last` +} -const arbitraryValue = fc.oneof( - fc.string(), - fc.integer(), - fc.double({ noNaN: true }), - fc.boolean(), +const termArbitrary = fc.record({ + direction: fc.constantFrom(`asc`, `desc`), + nulls: fc.constantFrom(`first`, `last`), +}) +const valueArbitrary = fc.oneof( + fc.integer({ min: -2, max: 2 }), fc.constant(null), + fc.constant(undefined), ) +const cursorCaseArbitrary = fc + .integer({ min: 1, max: 4 }) + .chain((length) => + fc.tuple( + fc.array(termArbitrary, { minLength: length, maxLength: length }), + fc.array(valueArbitrary, { minLength: length, maxLength: length }), + fc.array(valueArbitrary, { minLength: length, maxLength: length }), + ), + ) -const arbitraryValues = ( - minLength: number, - maxLength: number, -): fc.Arbitrary> => - fc.array(arbitraryValue, { minLength, maxLength }) - -// Helper to check if result is a Func -function isFunc(expr: unknown): expr is Func { - return expr instanceof Func +function compareValue(left: unknown, right: unknown, term: Term): number { + if (left == null && right == null) return 0 + if (left == null) return term.nulls === `first` ? -1 : 1 + if (right == null) return term.nulls === `first` ? 1 : -1 + const compared = left === right ? 0 : left < right ? -1 : 1 + return term.direction === `asc` ? compared : -compared } -// Helper to get operator name from Func -function getFuncName(expr: Func): string { - return expr.name +function compareTuple( + left: ReadonlyArray, + right: ReadonlyArray, + terms: ReadonlyArray, +): number { + for (let index = 0; index < terms.length; index++) { + const compared = compareValue(left[index], right[index], terms[index]!) + if (compared !== 0) return compared + } + return 0 } -// Helper to recursively count operators in an expression -function countOperators(expr: unknown, name: string): number { - if (!isFunc(expr)) return 0 - const selfCount = expr.name === name ? 1 : 0 - return ( - selfCount + - expr.args.reduce((sum, arg) => sum + countOperators(arg, name), 0) +function row(values: ReadonlyArray): Record { + return Object.fromEntries( + values.map((value, index) => [`column${index}`, value]), ) } -describe(`buildCursor property-based tests`, () => { - describe(`empty input handling`, () => { - fcTest.prop([arbitraryOrderBy(0, 5)])( - `returns undefined for empty values array`, - (orderBy) => { - const result = buildCursor(orderBy, []) - expect(result).toBeUndefined() - }, - ) - - fcTest.prop([arbitraryValues(0, 5)])( - `returns undefined for empty orderBy array`, - (values) => { - const result = buildCursor([], values) - expect(result).toBeUndefined() - }, - ) - - it(`returns undefined for both empty`, () => { - expect(buildCursor([], [])).toBeUndefined() - }) - }) - - describe(`single column cursor`, () => { - fcTest.prop([arbitraryOrderByClause, arbitraryValue])( - `produces a simple comparison for single column`, - (clause, value) => { - const result = buildCursor([clause], [value]) - - expect(result).toBeDefined() - expect(isFunc(result!)).toBe(true) - - // Should be either 'gt' or 'lt' based on direction - const func = result as Func - expect([`gt`, `lt`]).toContain(getFuncName(func)) - }, - ) - - fcTest.prop([ - arbitraryPropRef, - arbitraryNulls, - arbitraryStringSort, - arbitraryValue, - ])( - `ascending direction produces gt operator`, - (expr, nulls, stringSort, value) => { - const clause: OrderByClause = { - expression: expr, - compareOptions: { - direction: `asc`, - nulls: nulls as `first` | `last`, - stringSort: stringSort as `locale` | `lexical`, - }, - } - const result = buildCursor([clause], [value]) - - expect(result).toBeDefined() - expect(getFuncName(result as Func)).toBe(`gt`) - }, - ) - - fcTest.prop([ - arbitraryPropRef, - arbitraryNulls, - arbitraryStringSort, - arbitraryValue, - ])( - `descending direction produces lt operator`, - (expr, nulls, stringSort, value) => { - const clause: OrderByClause = { - expression: expr, - compareOptions: { - direction: `desc`, - nulls: nulls as `first` | `last`, - stringSort: stringSort as `locale` | `lexical`, - }, - } - const result = buildCursor([clause], [value]) - - expect(result).toBeDefined() - expect(getFuncName(result as Func)).toBe(`lt`) - }, - ) - }) - - describe(`multi-column cursor structure`, () => { - fcTest.prop([arbitraryOrderBy(2, 4), arbitraryValues(2, 4)])( - `multi-column produces or at top level when matching lengths`, - (orderBy, values) => { - // Ensure we have matching lengths for a valid multi-column cursor - const minLen = Math.min(orderBy.length, values.length) - if (minLen < 2) return // Skip if not enough for multi-column - - const trimmedOrderBy = orderBy.slice(0, minLen) - const trimmedValues = values.slice(0, minLen) - - const result = buildCursor(trimmedOrderBy, trimmedValues) - - expect(result).toBeDefined() - expect(isFunc(result!)).toBe(true) - - // For 2+ columns, top level should be 'or' - const func = result as Func - expect(getFuncName(func)).toBe(`or`) - }, - ) - - fcTest.prop([ - fc.tuple(arbitraryOrderByClause, arbitraryOrderByClause), - fc.tuple(arbitraryValue, arbitraryValue), - ])( - `two columns produces correct structure`, - ([clause1, clause2], [val1, val2]) => { - const result = buildCursor([clause1, clause2], [val1, val2]) - - expect(result).toBeDefined() - const func = result as Func - - // Top level should be 'or' - expect(getFuncName(func)).toBe(`or`) - - // Should have structure: or(comparison1, and(eq, comparison2)) - expect(func.args).toHaveLength(2) - - // First arg should be direct gt/lt - expect(isFunc(func.args[0])).toBe(true) - expect([`gt`, `lt`]).toContain(getFuncName(func.args[0] as Func)) - - // Second arg should be 'and' combining eq and comparison - expect(isFunc(func.args[1])).toBe(true) - expect(getFuncName(func.args[1] as Func)).toBe(`and`) - }, - ) - }) - - describe(`determinism`, () => { - fcTest.prop([arbitraryOrderBy(1, 3), arbitraryValues(1, 3)])( - `buildCursor is deterministic`, - (orderBy, values) => { - const result1 = buildCursor(orderBy, values) - const result2 = buildCursor(orderBy, values) - - // Both should be defined or both undefined - expect(result1 === undefined).toBe(result2 === undefined) - - if (result1 !== undefined && result2 !== undefined) { - // Compare structure by JSON representation - expect(JSON.stringify(result1)).toBe(JSON.stringify(result2)) - } - }, - ) - }) - - describe(`value preservation`, () => { - fcTest.prop([arbitraryOrderByClause, arbitraryValue])( - `cursor contains the provided value`, - (clause, value) => { - const result = buildCursor([clause], [value]) - - expect(result).toBeDefined() - const func = result as Func - - // Second argument should be a Value containing our value - expect(func.args[1]).toBeInstanceOf(Value) - expect((func.args[1] as Value).value).toBe(value) - }, - ) - - fcTest.prop([arbitraryOrderByClause, arbitraryValue])( - `cursor references the correct property`, - (clause, value) => { - const result = buildCursor([clause], [value]) - - expect(result).toBeDefined() - const func = result as Func - - // First argument should be the same PropRef - expect(func.args[0]).toBeInstanceOf(PropRef) - expect((func.args[0] as PropRef).path).toEqual( - (clause.expression as PropRef).path, - ) - }, - ) - }) - - describe(`length mismatch handling`, () => { - fcTest.prop([arbitraryOrderBy(3, 5), arbitraryValues(1, 2)])( - `handles more orderBy columns than values gracefully`, - (orderBy, values) => { - // Should use the minimum of the two lengths - const result = buildCursor(orderBy, values) - - if (values.length === 0) { - expect(result).toBeUndefined() - } else { - expect(result).toBeDefined() - expect(isFunc(result!)).toBe(true) - } - }, - ) - - fcTest.prop([arbitraryOrderBy(1, 2), arbitraryValues(3, 5)])( - `handles more values than orderBy columns gracefully`, - (orderBy, values) => { - // Should use the minimum of the two lengths - const result = buildCursor(orderBy, values) +function orderBy(terms: ReadonlyArray): OrderBy { + return terms.map((compareOptions, index) => ({ + expression: new PropRef([`column${index}`]), + compareOptions, + })) +} - if (orderBy.length === 0) { - expect(result).toBeUndefined() - } else { - expect(result).toBeDefined() - expect(isFunc(result!)).toBe(true) - } - }, - ) +describe(`buildCursor properties`, () => { + it(`returns no cursor without terms or boundary values`, () => { + expect(buildCursor([], [1])).toBeUndefined() + expect( + buildCursor(orderBy([{ direction: `asc`, nulls: `first` }]), []), + ).toBeUndefined() }) - describe(`operator consistency`, () => { - fcTest.prop([ - fc.array( - fc.tuple(arbitraryPropRef, arbitraryNulls, arbitraryStringSort), - { minLength: 2, maxLength: 4 }, - ), - arbitraryValues(2, 4), - ])(`all ascending columns use gt operators`, (clauseParts, values) => { - const orderBy: OrderBy = clauseParts.map(([expr, nulls, stringSort]) => ({ - expression: expr, - compareOptions: { - direction: `asc` as const, - nulls: nulls as `first` | `last`, - stringSort: stringSort as `locale` | `lexical`, - }, - })) + fcTest.prop([cursorCaseArbitrary], { numRuns: 300 })( + `selects exactly the tuples after a nullable mixed-direction boundary`, + ([terms, boundary, candidate]) => { + const cursor = buildCursor(orderBy(terms), [...boundary]) + expect(cursor).toBeDefined() - const minLen = Math.min(orderBy.length, values.length) - const result = buildCursor( - orderBy.slice(0, minLen), - values.slice(0, minLen), + const actual = Boolean( + evaluateReferenceExpression(cursor!, row(candidate)), ) + const expected = compareTuple(candidate, boundary, terms) > 0 + expect(actual).toBe(expected) + }, + ) - if (result) { - // Count gt operators - should equal number of columns - const gtCount = countOperators(result, `gt`) - expect(gtCount).toBe(minLen) - // Should have no lt operators - const ltCount = countOperators(result, `lt`) - expect(ltCount).toBe(0) - } - }) - - fcTest.prop([ - fc.array( - fc.tuple(arbitraryPropRef, arbitraryNulls, arbitraryStringSort), - { minLength: 2, maxLength: 4 }, - ), - arbitraryValues(2, 4), - ])(`all descending columns use lt operators`, (clauseParts, values) => { - const orderBy: OrderBy = clauseParts.map(([expr, nulls, stringSort]) => ({ - expression: expr, - compareOptions: { - direction: `desc` as const, - nulls: nulls as `first` | `last`, - stringSort: stringSort as `locale` | `lexical`, - }, - })) - - const minLen = Math.min(orderBy.length, values.length) - const result = buildCursor( - orderBy.slice(0, minLen), - values.slice(0, minLen), + fcTest.prop([cursorCaseArbitrary], { numRuns: 100 })( + `is deterministic`, + ([terms, boundary]) => { + expect(buildCursor(orderBy(terms), [...boundary])).toEqual( + buildCursor(orderBy(terms), [...boundary]), ) - - if (result) { - // Count lt operators - should equal number of columns - const ltCount = countOperators(result, `lt`) - expect(ltCount).toBe(minLen) - // Should have no gt operators - const gtCount = countOperators(result, `gt`) - expect(gtCount).toBe(0) - } - }) - }) + }, + ) }) diff --git a/packages/db/tests/cursor.test.ts b/packages/db/tests/cursor.test.ts index 5d8f4a2f4..8f035423d 100644 --- a/packages/db/tests/cursor.test.ts +++ b/packages/db/tests/cursor.test.ts @@ -1,226 +1,97 @@ import { describe, expect, it } from 'vitest' -import { buildCursor } from '../src/utils/cursor.js' -import { Func, PropRef, Value } from '../src/query/ir.js' -import type { OrderBy, OrderByClause } from '../src/query/ir.js' -import type { CompareOptions } from '../src/query/builder/types.js' - -// Helper to create an OrderByClause for testing -function createOrderByClause( - path: string, - direction: `asc` | `desc`, -): OrderByClause { - const compareOptions: CompareOptions = { - direction, - nulls: direction === `asc` ? `first` : `last`, - } - return { - expression: new PropRef([`t`, path]), - compareOptions, - } +import { PropRef } from '../src/query/ir.js' +import { buildCursor, canExpressCursorOrder } from '../src/utils/cursor.js' +import { evaluateReferenceExpression } from './reference-expression.js' +import type { OrderBy } from '../src/query/ir.js' + +function orderBy( + ...terms: ReadonlyArray +): OrderBy { + return terms.map(([path, direction, nulls]) => ({ + expression: new PropRef([path]), + compareOptions: { direction, nulls }, + })) } -// Helper to check if a Func has the expected structure -function isFuncWithName(expr: unknown, name: string): expr is Func { - return expr instanceof Func && expr.name === name +function matches( + order: OrderBy, + boundary: Array, + row: object, +): boolean { + const cursor = buildCursor(order, boundary) + if (!cursor) throw new Error(`expected a cursor`) + return Boolean(evaluateReferenceExpression(cursor, row)) } describe(`buildCursor`, () => { - describe(`edge cases`, () => { - it(`returns undefined for empty values array`, () => { - const orderBy: OrderBy = [createOrderByClause(`col1`, `asc`)] - expect(buildCursor(orderBy, [])).toBeUndefined() - }) - - it(`returns undefined for empty orderBy array`, () => { - expect(buildCursor([], [1, 2, 3])).toBeUndefined() - }) - - it(`returns undefined for both empty`, () => { - expect(buildCursor([], [])).toBeUndefined() - }) + it(`uses direction for one non-null term`, () => { + expect(matches(orderBy([`rank`, `asc`, `first`]), [10], { rank: 11 })).toBe( + true, + ) + expect(matches(orderBy([`rank`, `asc`, `first`]), [10], { rank: 9 })).toBe( + false, + ) + expect(matches(orderBy([`rank`, `desc`, `first`]), [10], { rank: 9 })).toBe( + true, + ) + expect( + matches(orderBy([`rank`, `desc`, `first`]), [10], { rank: 11 }), + ).toBe(false) }) - describe(`single column`, () => { - it(`produces gt() for ASC direction`, () => { - const orderBy: OrderBy = [createOrderByClause(`col1`, `asc`)] - const result = buildCursor(orderBy, [10]) - - expect(result).toBeInstanceOf(Func) - expect(isFuncWithName(result, `gt`)).toBe(true) - - const func = result as Func - expect(func.args).toHaveLength(2) - expect(func.args[0]).toBeInstanceOf(PropRef) - expect((func.args[0] as PropRef).path).toEqual([`t`, `col1`]) - expect(func.args[1]).toBeInstanceOf(Value) - expect((func.args[1] as Value).value).toBe(10) - }) - - it(`produces lt() for DESC direction`, () => { - const orderBy: OrderBy = [createOrderByClause(`col1`, `desc`)] - const result = buildCursor(orderBy, [10]) - - expect(result).toBeInstanceOf(Func) - expect(isFuncWithName(result, `lt`)).toBe(true) - - const func = result as Func - expect(func.args).toHaveLength(2) - expect(func.args[0]).toBeInstanceOf(PropRef) - expect((func.args[0] as PropRef).path).toEqual([`t`, `col1`]) - expect(func.args[1]).toBeInstanceOf(Value) - expect((func.args[1] as Value).value).toBe(10) - }) - - it(`handles string cursor values`, () => { - const orderBy: OrderBy = [createOrderByClause(`name`, `asc`)] - const result = buildCursor(orderBy, [`alice`]) + it(`places nullish values according to the term`, () => { + const nullsFirst = orderBy([`rank`, `asc`, `first`]) + expect(matches(nullsFirst, [null], { rank: 0 })).toBe(true) + expect(matches(nullsFirst, [null], { rank: undefined })).toBe(false) - expect(result).toBeInstanceOf(Func) - const func = result as Func - expect(func.args[1]).toBeInstanceOf(Value) - expect((func.args[1] as Value).value).toBe(`alice`) - }) - - it(`handles null cursor values`, () => { - const orderBy: OrderBy = [createOrderByClause(`col1`, `asc`)] - const result = buildCursor(orderBy, [null]) - - expect(result).toBeInstanceOf(Func) - const func = result as Func - expect((func.args[1] as Value).value).toBeNull() - }) + const nullsLast = orderBy([`rank`, `asc`, `last`]) + expect(matches(nullsLast, [0], { rank: null })).toBe(true) + expect(matches(nullsLast, [null], { rank: 0 })).toBe(false) }) - describe(`multi-column composite cursor`, () => { - it(`produces or(gt(col1), and(eq(col1), gt(col2))) for two ASC columns`, () => { - const orderBy: OrderBy = [ - createOrderByClause(`col1`, `asc`), - createOrderByClause(`col2`, `asc`), - ] - const result = buildCursor(orderBy, [10, 20]) - - // Should be: or(gt(col1, 10), and(eq(col1, 10), gt(col2, 20))) - expect(result).toBeInstanceOf(Func) - expect(isFuncWithName(result, `or`)).toBe(true) - - const orFunc = result as Func - expect(orFunc.args).toHaveLength(2) - - // First arg: gt(col1, 10) - const gtCol1 = orFunc.args[0] - expect(isFuncWithName(gtCol1, `gt`)).toBe(true) - expect((gtCol1 as Func).args[0]).toBeInstanceOf(PropRef) - expect(((gtCol1 as Func).args[0] as PropRef).path).toEqual([`t`, `col1`]) - expect(((gtCol1 as Func).args[1] as Value).value).toBe(10) - - // Second arg: and(eq(col1, 10), gt(col2, 20)) - const andClause = orFunc.args[1] - expect(isFuncWithName(andClause, `and`)).toBe(true) - const andFunc = andClause as Func - expect(andFunc.args).toHaveLength(2) - - // eq(col1, 10) - expect(isFuncWithName(andFunc.args[0], `eq`)).toBe(true) - const eqCol1 = andFunc.args[0] as Func - expect((eqCol1.args[0] as PropRef).path).toEqual([`t`, `col1`]) - expect((eqCol1.args[1] as Value).value).toBe(10) - - // gt(col2, 20) - expect(isFuncWithName(andFunc.args[1], `gt`)).toBe(true) - const gtCol2 = andFunc.args[1] as Func - expect((gtCol2.args[0] as PropRef).path).toEqual([`t`, `col2`]) - expect((gtCol2.args[1] as Value).value).toBe(20) - }) + it(`uses lexicographic equality before later mixed-direction terms`, () => { + const order = orderBy([`group`, `asc`, `first`], [`rank`, `desc`, `last`]) - it(`handles mixed ASC/DESC directions`, () => { - const orderBy: OrderBy = [ - createOrderByClause(`col1`, `asc`), - createOrderByClause(`col2`, `desc`), - ] - const result = buildCursor(orderBy, [10, 20]) - - // Should be: or(gt(col1, 10), and(eq(col1, 10), lt(col2, 20))) - expect(isFuncWithName(result, `or`)).toBe(true) - - const orFunc = result as Func - const andClause = orFunc.args[1] as Func - - // Second column should use lt() for DESC - expect(isFuncWithName(andClause.args[1], `lt`)).toBe(true) - }) - - it(`handles three columns correctly`, () => { - const orderBy: OrderBy = [ - createOrderByClause(`col1`, `asc`), - createOrderByClause(`col2`, `asc`), - createOrderByClause(`col3`, `desc`), - ] - const result = buildCursor(orderBy, [1, 2, 3]) - - // Should be: or( - // gt(col1, 1), - // and(eq(col1, 1), gt(col2, 2)), - // and(eq(col1, 1), eq(col2, 2), lt(col3, 3)) - // ) - expect(isFuncWithName(result, `or`)).toBe(true) - - const outerOr = result as Func - // The structure is: or(or(gt, and), and) due to reduce - expect(outerOr.args).toHaveLength(2) - - // First arg is or(gt(col1, 1), and(eq(col1, 1), gt(col2, 2))) - const innerOr = outerOr.args[0] - expect(isFuncWithName(innerOr, `or`)).toBe(true) - - // Second arg is and(and(eq(col1, 1), eq(col2, 2)), lt(col3, 3)) - const thirdClause = outerOr.args[1] - expect(isFuncWithName(thirdClause, `and`)).toBe(true) - - // The innermost and should have eq conditions and lt for col3 - const innerAnd = thirdClause as Func - // Due to reduce, the structure is nested: and(and(eq, eq), lt) - expect(isFuncWithName(innerAnd.args[1], `lt`)).toBe(true) - const ltCol3 = innerAnd.args[1] as Func - expect((ltCol3.args[0] as PropRef).path).toEqual([`t`, `col3`]) - expect((ltCol3.args[1] as Value).value).toBe(3) - }) + expect(matches(order, [1, 10], { group: 2, rank: 99 })).toBe(true) + expect(matches(order, [1, 10], { group: 1, rank: 9 })).toBe(true) + expect(matches(order, [1, 10], { group: 1, rank: 11 })).toBe(false) + expect(matches(order, [1, 10], { group: 0, rank: 0 })).toBe(false) }) - describe(`partial values`, () => { - it(`handles fewer values than orderBy columns`, () => { - const orderBy: OrderBy = [ - createOrderByClause(`col1`, `asc`), - createOrderByClause(`col2`, `asc`), - createOrderByClause(`col3`, `asc`), - ] - const result = buildCursor(orderBy, [10, 20]) - - // Should only use first two columns - expect(isFuncWithName(result, `or`)).toBe(true) - - const orFunc = result as Func - expect(orFunc.args).toHaveLength(2) - - // First clause: gt(col1, 10) - expect(isFuncWithName(orFunc.args[0], `gt`)).toBe(true) - - // Second clause: and(eq(col1, 10), gt(col2, 20)) - expect(isFuncWithName(orFunc.args[1], `and`)).toBe(true) - }) - - it(`handles single value for multi-column orderBy`, () => { - const orderBy: OrderBy = [ - createOrderByClause(`col1`, `asc`), - createOrderByClause(`col2`, `asc`), - ] - const result = buildCursor(orderBy, [10]) - - // Should just be gt(col1, 10) since only one value provided - expect(isFuncWithName(result, `gt`)).toBe(true) + it(`uses only the terms with supplied boundary values`, () => { + const order = orderBy([`first`, `asc`, `first`], [`second`, `asc`, `first`]) + expect(matches(order, [1], { first: 2, second: -100 })).toBe(true) + expect(matches(order, [1], { first: 1, second: 100 })).toBe(false) + }) - const gtFunc = result as Func - expect((gtFunc.args[0] as PropRef).path).toEqual([`t`, `col1`]) - expect((gtFunc.args[1] as Value).value).toBe(10) - }) + it(`rejects cursor pushdown when predicate comparison cannot express the total order`, () => { + const localeOrder: OrderBy = [ + { + expression: new PropRef([`label`]), + compareOptions: { + direction: `asc`, + nulls: `first`, + stringSort: `locale`, + localeOptions: { numeric: true }, + }, + }, + ] + + expect(canExpressCursorOrder(localeOrder, [`item2`])).toBe(false) + expect( + canExpressCursorOrder( + [ + { + ...localeOrder[0]!, + compareOptions: { + ...localeOrder[0]!.compareOptions, + stringSort: `lexical`, + }, + }, + ], + [`item2`], + ), + ).toBe(true) + expect(canExpressCursorOrder(localeOrder, [{ rank: 1 }])).toBe(false) }) }) diff --git a/packages/db/tests/effect.test.ts b/packages/db/tests/effect.test.ts index 5c95625a4..7e5c12f67 100644 --- a/packages/db/tests/effect.test.ts +++ b/packages/db/tests/effect.test.ts @@ -1962,6 +1962,7 @@ describe(`createEffect`, () => { const loadFailure = new Error(`ordered subset failed`) const cleanupFailure = new Error(`ordered subset cleanup failed`) let loadCount = 0 + let unloadCount = 0 let removeVisibleRow: () => void = () => { throw new Error(`source has not started`) } @@ -1989,6 +1990,7 @@ describe(`createEffect`, () => { return Promise.resolve() }, unloadSubset: () => { + unloadCount++ throw cleanupFailure }, } @@ -2016,12 +2018,17 @@ describe(`createEffect`, () => { expect(sourceErrors).toEqual([loadFailure]) expect(effect.disposed).toBe(true) - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringContaining(`failed to dispose after a source error`), + expect(unloadCount).toBe(2) + const cleanupError = consoleErrorSpy.mock.calls.find(([message]) => + String(message).includes(`failed to dispose after a source error`), + )?.[1] + expect(cleanupError).toBeInstanceOf(AggregateError) + expect((cleanupError as AggregateError).errors).toEqual([ cleanupFailure, - ) + cleanupFailure, + ]) + await expect(effect.dispose()).rejects.toBe(cleanupError) } finally { - await expect(effect.dispose()).rejects.toBe(cleanupFailure) consoleErrorSpy.mockRestore() await users.cleanup() } diff --git a/packages/db/tests/load-subset-full-flow-model.ts b/packages/db/tests/load-subset-full-flow-model.ts index a688240cd..33f43c6d1 100644 --- a/packages/db/tests/load-subset-full-flow-model.ts +++ b/packages/db/tests/load-subset-full-flow-model.ts @@ -1,6 +1,105 @@ export type FullFlowOwnerId = string export type FullFlowSessionId = string export type FullFlowDemandId = string +export type FullFlowPublicationId = string + +export type FullFlowPublishedOrderRow = { + key: string + orderValue: number +} + +export type OrderedContinuationEvidencePage = { + requestedPrefix: number + appliedKeys: ReadonlyArray + extent: `continues` | `exhausted` +} + +export type OrderedContinuationEvidence = { + visibleKeys: ReadonlyArray + boundaryKey: string | undefined + coveredPrefixSize: number + coversTarget: boolean + rowsNeeded: number +} + +/** + * Projects ordered evidence from request receipts alone. Requested size and + * source progress are independent inputs; only eligible applied rows count + * toward the visible prefix, while every applied row may advance its cursor. + */ +export function projectOrderedContinuationEvidence(options: { + sourceOrder: ReadonlyArray + eligibleKeys: ReadonlySet + targetSize: number + pages: ReadonlyArray +}): OrderedContinuationEvidence { + const { sourceOrder, eligibleKeys, targetSize, pages } = options + const sourcePosition = new Map( + sourceOrder.map((key, position) => [key, position]), + ) + const known = (keys: ReadonlySet) => + sourceOrder.filter((key) => keys.has(key)) + const candidates = new Set() + const provenance = new Set() + const admitted = new Set() + let coveredPrefixSize = 0 + let exhausted = false + + const initial = pages[0] + if (initial) { + for (const key of initial.appliedKeys) { + if (sourcePosition.has(key)) candidates.add(key) + } + exhausted = initial.extent === `exhausted` + } + + for (const page of pages.slice(1)) { + if (exhausted) break + if (page.extent === `exhausted`) { + exhausted = true + break + } + for (const key of candidates) { + provenance.add(key) + admitted.add(key) + } + candidates.clear() + for (const key of page.appliedKeys) { + if (!sourcePosition.has(key)) continue + provenance.add(key) + admitted.add(key) + } + const eligibleAdmitted = known(admitted).filter((key) => + eligibleKeys.has(key), + ) + coveredPrefixSize = Math.max( + coveredPrefixSize, + Math.min( + page.requestedPrefix, + eligibleAdmitted.slice(0, targetSize).length, + ), + ) + } + + const visibleKeys = exhausted + ? sourceOrder.filter((key) => eligibleKeys.has(key)).slice(0, targetSize) + : known(admitted) + .filter((key) => eligibleKeys.has(key)) + .slice(0, targetSize) + const boundaryKeys = exhausted + ? sourceOrder.slice(0, targetSize) + : provenance.size > 0 + ? known(provenance) + : known(candidates).slice(0, targetSize) + + return { + visibleKeys, + boundaryKey: boundaryKeys.at(-1), + coveredPrefixSize: exhausted ? Number.POSITIVE_INFINITY : coveredPrefixSize, + coversTarget: exhausted || coveredPrefixSize >= targetSize, + rowsNeeded: Math.max(0, targetSize - visibleKeys.length), + } +} export type LoadSubsetFullFlowEvent = | { @@ -44,6 +143,61 @@ export type LoadSubsetFullFlowEvent = previousSessionId: FullFlowSessionId nextSessionId: FullFlowSessionId } + | { + type: `cleanupSession` + sessionId: FullFlowSessionId + } + | { + type: `advanceWindowRevision` + sessionId: FullFlowSessionId + revision: number + } + | { + type: `scheduleContinuation` + taskId: string + sessionId: FullFlowSessionId + windowRevision: number + } + | { + type: `runContinuation` + taskId: string + } + | { + type: `stagePublicationRows` + publicationId: FullFlowPublicationId + demandId: FullFlowDemandId + rows: ReadonlyArray + } + | { + type: `commitPublication` + publicationId: FullFlowPublicationId + } + | { + type: `beginReplacement` + publicationId: FullFlowPublicationId + demandIds: ReadonlyArray + } + | { + type: `settleReplacement` + publicationId: FullFlowPublicationId + demandId: FullFlowDemandId + outcome: `failure` | `abort` + } + | { + type: `settleReplacement` + publicationId: FullFlowPublicationId + demandId: FullFlowDemandId + outcome: `success` + extent: `exhausted` | `continues` + } + | { + type: `establishReplacementCoverage` + publicationId: FullFlowPublicationId + } + | { + type: `resizeOrderedWindow` + size: number + } export type ExpectedAdapterLifecycleEvent = { type: `invoke` | `release` @@ -119,6 +273,16 @@ export function projectTransportLoads( } break case `restartSession`: + case `cleanupSession`: + case `advanceWindowRevision`: + case `scheduleContinuation`: + case `runContinuation`: + case `stagePublicationRows`: + case `commitPublication`: + case `beginReplacement`: + case `settleReplacement`: + case `establishReplacementCoverage`: + case `resizeOrderedWindow`: break } } @@ -126,6 +290,78 @@ export function projectTransportLoads( return loads } +/** + * Counts follow-up loads that a settled ordered continuation may authorize. + * Authority is scoped to both the current live-query session and the window + * revision captured when the continuation was scheduled. + */ +export function projectAuthorizedContinuationStarts( + history: ReadonlyArray, +): number { + const activeSessions = new Set() + const revisions = new Map() + const tasks = new Map< + string, + { sessionId: FullFlowSessionId; windowRevision: number } + >() + let currentSession: FullFlowSessionId | undefined + let starts = 0 + + for (const event of history) { + switch (event.type) { + case `requestDemand`: + currentSession ??= event.sessionId + activeSessions.add(event.sessionId) + revisions.set(event.sessionId, revisions.get(event.sessionId) ?? 0) + break + case `cleanupSession`: + activeSessions.delete(event.sessionId) + break + case `restartSession`: + currentSession = event.nextSessionId + activeSessions.add(event.nextSessionId) + revisions.set(event.nextSessionId, 0) + break + case `advanceWindowRevision`: + revisions.set(event.sessionId, event.revision) + break + case `scheduleContinuation`: + tasks.set(event.taskId, { + sessionId: event.sessionId, + windowRevision: event.windowRevision, + }) + break + case `runContinuation`: { + const task = tasks.get(event.taskId) + if ( + task && + currentSession === task.sessionId && + activeSessions.has(task.sessionId) && + revisions.get(task.sessionId) === task.windowRevision + ) { + starts++ + } + tasks.delete(event.taskId) + break + } + case `applyAuthoritativeRows`: + case `applyUnprovenRows`: + case `rejectDemand`: + case `truncateSource`: + case `releaseDemand`: + case `stagePublicationRows`: + case `commitPublication`: + case `beginReplacement`: + case `settleReplacement`: + case `establishReplacementCoverage`: + case `resizeOrderedWindow`: + break + } + } + + return starts +} + /** Projects reusable demand evidence without using registry state. */ export function projectReusableDemands( history: ReadonlyArray, @@ -158,6 +394,16 @@ export function projectReusableDemands( case `applyUnprovenRows`: case `rejectDemand`: case `restartSession`: + case `cleanupSession`: + case `advanceWindowRevision`: + case `scheduleContinuation`: + case `runContinuation`: + case `stagePublicationRows`: + case `commitPublication`: + case `beginReplacement`: + case `settleReplacement`: + case `establishReplacementCoverage`: + case `resizeOrderedWindow`: break } } @@ -165,6 +411,297 @@ export function projectReusableDemands( return [...reusableDemands].sort() } +/** + * Projects the last complete ordered boundary from public publication + * provenance. Rows published for another demand cannot move this boundary, + * and an uncommitted replacement cannot supersede the last complete snapshot. + */ +export function projectOrderedPublicationBoundary( + history: ReadonlyArray, + options: { + demandId: FullFlowDemandId + direction: `asc` | `desc` + prefixSize: number + }, +): FullFlowPublishedOrderRow | undefined { + const staged = new Map< + FullFlowPublicationId, + Map> + >() + let committedRows: ReadonlyArray = [] + + for (const event of history) { + if (event.type === `stagePublicationRows`) { + let publication = staged.get(event.publicationId) + if (!publication) { + publication = new Map() + staged.set(event.publicationId, publication) + } + publication.set(event.demandId, event.rows) + continue + } + if (event.type === `commitPublication`) { + const publication = staged.get(event.publicationId) + if (publication?.has(options.demandId)) { + committedRows = publication.get(options.demandId) ?? [] + } + } + } + + const sorted = [...committedRows].sort((left, right) => { + const valueOrder = + options.direction === `asc` + ? left.orderValue - right.orderValue + : right.orderValue - left.orderValue + if (valueOrder !== 0) return valueOrder + if (left.key === right.key) return 0 + return left.key < right.key ? -1 : 1 + }) + return sorted.slice(0, options.prefixSize).at(-1) +} + +/** + * Projects semantic ordered publications across replacement epochs. Empty + * transport callbacks do not appear here because they cannot change public + * state. Demand activity comes only from request and release events, and the + * retained window size is grow-only. Staged rows stay private until every + * acquisition has settled, then the current replacement publishes the retained + * ordered prefix plus rows required by still-active demands. Abort or failure + * from a released demand or obsolete attempt satisfies its barrier without + * vetoing the current attempt. Failure of a current active demand keeps the + * previous publication, and cleanup is a terminal fence against late writes + * and settlements. + */ +export function projectAtomicOrderedPublications( + history: ReadonlyArray, + options: { + demandId: FullFlowDemandId + direction: `asc` | `desc` + initialWindowSize: number + }, +): ReadonlyArray> { + return projectAtomicOrderedPublicationState(history, options).publications +} + +export type AtomicOrderedPublicationState = { + rows: ReadonlyArray + orderedPrefixSize: number + orderedBoundary: FullFlowPublishedOrderRow | undefined +} + +export type AtomicOrderedPublicationProjection = { + publications: ReadonlyArray> + currentPublication: AtomicOrderedPublicationState | undefined + retainsPreviousPublication: boolean +} + +/** + * Projects both reader-visible rows and the ordered continuation state owned by + * that publication. The explicit optional boundary matters: an empty retained + * publication has a valid `undefined` boundary and must not fall through to a + * private replacement's progress boundary. + */ +export function projectAtomicOrderedPublicationState( + history: ReadonlyArray, + options: { + demandId: FullFlowDemandId + direction: `asc` | `desc` + initialWindowSize: number + }, +): AtomicOrderedPublicationProjection { + const staged = new Map< + FullFlowPublicationId, + Map> + >() + const attempts = new Map< + FullFlowPublicationId, + Map< + FullFlowDemandId, + | { outcome: `success`; publishable: boolean } + | { outcome: `failure` | `abort`; publishable: false } + | undefined + > + >() + const activeAdditionalDemands = new Set() + const publications: Array> = [] + let currentPublication: AtomicOrderedPublicationState | undefined + let retainsPreviousPublication = false + let currentReplacement: FullFlowPublicationId | undefined + let retainedSize = options.initialWindowSize + let closed = false + + const sortRows = (rows: ReadonlyArray) => + [...rows].sort((left, right) => { + const valueOrder = + options.direction === `asc` + ? left.orderValue - right.orderValue + : right.orderValue - left.orderValue + if (valueOrder !== 0) return valueOrder + if (left.key === right.key) return 0 + return left.key < right.key ? -1 : 1 + }) + + const publicationState = ( + publicationId: FullFlowPublicationId, + ): AtomicOrderedPublicationState | undefined => { + const publication = staged.get(publicationId) + const orderedRows = publication?.get(options.demandId) + if (!publication || !orderedRows) return undefined + + const orderedPrefix = sortRows(orderedRows).slice(0, retainedSize) + const desired = new Map(orderedPrefix.map((row) => [row.key, row] as const)) + for (const demandId of activeAdditionalDemands) { + for (const row of publication.get(demandId) ?? []) { + desired.set(row.key, row) + } + } + return { + rows: sortRows([...desired.values()]), + orderedPrefixSize: orderedPrefix.length, + orderedBoundary: orderedPrefix.at(-1), + } + } + + const publish = (publicationId: FullFlowPublicationId) => { + const next = publicationState(publicationId) + if (!next) return + const previous = publications.at(-1) + if (previous === undefined && next.rows.length === 0) { + currentPublication = next + return + } + if ( + previous?.length === next.rows.length && + previous.every( + (row, index) => + row.key === next.rows[index]!.key && + row.orderValue === next.rows[index]!.orderValue, + ) + ) { + currentPublication = next + return + } + publications.push(next.rows) + currentPublication = next + } + + const finishCurrentReplacement = () => { + if (currentReplacement === undefined) return + if ( + [...attempts.values()].some((demands) => + [...demands.values()].some((outcome) => outcome === undefined), + ) + ) { + return + } + + const current = attempts.get(currentReplacement) + const ordered = current?.get(options.demandId) + const activeDemandFailed = [...activeAdditionalDemands].some( + (demandId) => current?.get(demandId)?.outcome !== `success`, + ) + if (ordered?.outcome !== `success` || activeDemandFailed) { + attempts.clear() + currentReplacement = undefined + retainsPreviousPublication = true + return + } + if (!ordered.publishable) return + + publish(currentReplacement) + attempts.clear() + currentReplacement = undefined + retainsPreviousPublication = false + } + + for (const event of history) { + if (closed) continue + switch (event.type) { + case `stagePublicationRows`: { + let publication = staged.get(event.publicationId) + if (!publication) { + publication = new Map() + staged.set(event.publicationId, publication) + } + publication.set(event.demandId, event.rows) + break + } + case `commitPublication`: { + if (attempts.size > 0) break + publish(event.publicationId) + retainsPreviousPublication = false + break + } + case `beginReplacement`: + attempts.set( + event.publicationId, + new Map(event.demandIds.map((demandId) => [demandId, undefined])), + ) + currentReplacement = event.publicationId + retainsPreviousPublication = true + break + case `resizeOrderedWindow`: + retainedSize = Math.max(retainedSize, event.size) + break + case `settleReplacement`: { + const attempt = attempts.get(event.publicationId) + if (!attempt?.has(event.demandId)) break + attempt.set( + event.demandId, + event.outcome === `success` + ? { + outcome: `success`, + publishable: event.extent === `exhausted`, + } + : { outcome: event.outcome, publishable: false }, + ) + finishCurrentReplacement() + break + } + case `establishReplacementCoverage`: { + if (event.publicationId !== currentReplacement) break + const ordered = attempts.get(event.publicationId)?.get(options.demandId) + if (ordered?.outcome === `success`) { + ordered.publishable = true + finishCurrentReplacement() + } + break + } + case `requestDemand`: + if (!event.alreadyAborted && event.demandId !== options.demandId) { + activeAdditionalDemands.add(event.demandId) + } + break + case `applyAuthoritativeRows`: + case `applyUnprovenRows`: + case `rejectDemand`: + break + case `releaseDemand`: + activeAdditionalDemands.delete(event.demandId) + break + case `truncateSource`: + case `restartSession`: + case `advanceWindowRevision`: + case `scheduleContinuation`: + case `runContinuation`: + break + case `cleanupSession`: + attempts.clear() + currentReplacement = undefined + activeAdditionalDemands.clear() + retainsPreviousPublication = false + closed = true + break + } + } + + return { + publications, + currentPublication, + retainsPreviousPublication, + } +} + /** Derives visible row identity without consulting Collection implementation. */ export function projectRetainedRowKeys( history: ReadonlyArray, diff --git a/packages/db/tests/query/live-query-collection.test.ts b/packages/db/tests/query/live-query-collection.test.ts index c6fc4be39..fc4d7f06b 100644 --- a/packages/db/tests/query/live-query-collection.test.ts +++ b/packages/db/tests/query/live-query-collection.test.ts @@ -2507,7 +2507,7 @@ describe(`createLiveQueryCollection`, () => { } }) - it(`advances offset when async loadSubset fills an initially empty window`, async () => { + it(`refreshes a wider prefix when an async load has no row provenance`, async () => { type Item = { id: number; value: number } const remoteData: Array = [ { id: 1, value: 1 }, @@ -2516,6 +2516,7 @@ describe(`createLiveQueryCollection`, () => { { id: 4, value: 4 }, ] const loadOffsets: Array = [] + const loadLimits: Array = [] const sourceCollection = createCollection({ id: `offset-advances-async`, @@ -2530,6 +2531,7 @@ describe(`createLiveQueryCollection`, () => { return { loadSubset: (options: LoadSubsetOptions) => { loadOffsets.push(options.offset) + loadLimits.push(options.limit) return new Promise((resolve) => { setTimeout(() => { begin() @@ -2567,11 +2569,12 @@ describe(`createLiveQueryCollection`, () => { await moveResult } - expect(loadOffsets).toEqual([0, 2]) + expect(loadOffsets).toEqual([0, 0]) + expect(loadLimits).toEqual([2, 4]) expect(liveQuery.toArray.map((item) => item.value)).toEqual([3, 4]) }) - it(`requests new offsets when window moves across identical orderBy values`, async () => { + it(`refreshes wider prefixes when synchronous loads have no row provenance`, async () => { type Item = { id: number; rank: number } const remoteData: Array = [ { id: 1, rank: 1 }, @@ -2582,6 +2585,7 @@ describe(`createLiveQueryCollection`, () => { { id: 6, rank: 1 }, ] const loadOffsets: Array = [] + const loadLimits: Array = [] const sourceCollection = createCollection({ id: `offset-moves-constant-orderby`, @@ -2596,6 +2600,7 @@ describe(`createLiveQueryCollection`, () => { return { loadSubset: (options: LoadSubsetOptions) => { loadOffsets.push(options.offset) + loadLimits.push(options.limit) const start = options.offset ?? 0 const end = options.limit ? start + options.limit @@ -2630,7 +2635,8 @@ describe(`createLiveQueryCollection`, () => { await moveFirst } await flushPromises() - expect(loadOffsets).toEqual([0, 2]) + expect(loadOffsets).toEqual([0, 0]) + expect(loadLimits).toEqual([2, 4]) expect(liveQuery.toArray.map((item) => item.id)).toEqual([3, 4]) const moveSecond = liveQuery.utils.setWindow({ offset: 4, limit: 2 }) @@ -2638,7 +2644,8 @@ describe(`createLiveQueryCollection`, () => { await moveSecond } await flushPromises() - expect(loadOffsets).toEqual([0, 2, 4]) + expect(loadOffsets).toEqual([0, 0, 0]) + expect(loadLimits).toEqual([2, 4, 6]) expect(liveQuery.toArray.map((item) => item.id)).toEqual([5, 6]) }) }) diff --git a/packages/db/tests/query/load-subset-full-flow-oracle.property.test.ts b/packages/db/tests/query/load-subset-full-flow-oracle.property.test.ts index be4e53c63..c1116eca7 100644 --- a/packages/db/tests/query/load-subset-full-flow-oracle.property.test.ts +++ b/packages/db/tests/query/load-subset-full-flow-oracle.property.test.ts @@ -1,20 +1,34 @@ import { fc, test as fcTest } from '@fast-check/vitest' import { expect, it, vi } from 'vitest' import { createCollection } from '../../src/collection/index.js' -import { createLiveQueryCollection } from '../../src/query/index.js' -import { DeduplicatedLoadSubset } from '../../src/query/subset-dedupe.js' import { createDeferred } from '../../src/deferred.js' +import { BTreeIndex, ReverseIndex } from '../../src/index.js' +import { Func, PropRef, Value } from '../../src/query/ir.js' +import { createEffect } from '../../src/query/effect.js' +import { createLiveQueryCollection, eq } from '../../src/query/index.js' +import { DeduplicatedLoadSubset } from '../../src/query/subset-dedupe.js' +import { LIVE_QUERY_INTERNAL } from '../../src/query/live/internal.js' +import { computeOrderedLoadCursor } from '../../src/query/live/utils.js' +import { WindowState } from '../../src/query/live/window-state.js' +import { evaluateReferenceExpression } from '../reference-expression.js' import { projectAdapterLifecycle, + projectAtomicOrderedPublicationState, + projectAtomicOrderedPublications, + projectAuthorizedContinuationStarts, + projectOrderedContinuationEvidence, + projectOrderedPublicationBoundary, projectRetainedRowKeys, projectReusableDemands, projectTransportLoads, } from '../load-subset-full-flow-model.js' +import { flushPromises, mockSyncCollectionOptions } from '../utils.js' import { oracleRandomParameters, readOracleRunConfig, } from '../oracle-config.js' -import type { LoadSubsetOptions } from '../../src/types.js' +import type { InitialQueryBuilder } from '../../src/query/builder/index.js' +import type { LoadSubsetOptions, WritableDeep } from '../../src/types.js' import type { LoadSubsetFullFlowEvent } from '../load-subset-full-flow-model.js' type AdapterLifecycleEvent = @@ -66,7 +80,7 @@ const exhaustiveTruncateCoverageScenarios: Array = [ ), ) -const { multiplier: truncateMultiplier, replaySeed: truncateReplaySeed } = +const { multiplier: fullFlowMultiplier, replaySeed: fullFlowReplaySeed } = readOracleRunConfig() let truncateCoverageHarnessId = 0 @@ -441,6 +455,2743 @@ it(`reloads authoritative rows after final-owner cleanup invalidates retained ad ]) } }) +it(`does not let an ordered continuation from a cleaned session start new work after restart`, async () => { + type Row = { id: number; rank: number } + const history: ReadonlyArray = [ + { + type: `requestDemand`, + ownerId: `owner-1`, + sessionId: `session-1`, + demandId: `top-1`, + alreadyAborted: false, + }, + { + type: `scheduleContinuation`, + taskId: `load-1-settlement`, + sessionId: `session-1`, + windowRevision: 0, + }, + { type: `cleanupSession`, sessionId: `session-1` }, + { + type: `restartSession`, + previousSessionId: `session-1`, + nextSessionId: `session-2`, + }, + { + type: `requestDemand`, + ownerId: `owner-2`, + sessionId: `session-2`, + demandId: `top-1`, + alreadyAborted: false, + }, + { type: `runContinuation`, taskId: `load-1-settlement` }, + ] + const pending: Array>> = [] + const source = createCollection({ + id: `full-flow-stale-ordered-continuation-source`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: () => { + const deferred = createDeferred() + pending.push(deferred) + return deferred.promise.then(() => ({ + hasMore: false, + appliedRowKeys: [], + })) + }, + unloadSubset: () => {}, + } + }, + }, + }) + const live = createLiveQueryCollection({ + id: `full-flow-stale-ordered-continuation-live`, + query: (q) => + q + .from({ row: source }) + .orderBy(({ row }) => row.rank) + .limit(1), + startSync: true, + }) + const firstPreload = live.preload().catch(() => undefined) + let secondPreload: Promise | undefined + + try { + expect(pending).toHaveLength(1) + await live.cleanup() + + secondPreload = live.preload() + expect(pending).toHaveLength(2) + + const requestsBeforeStaleSettlement = pending.length + pending[0]!.resolve() + await flushPromises() + + expect(pending).toHaveLength( + requestsBeforeStaleSettlement + + projectAuthorizedContinuationStarts(history), + ) + } finally { + for (const request of pending) request.resolve() + await flushPromises() + await Promise.all([ + firstPreload, + secondPreload?.catch(() => undefined) ?? Promise.resolve(), + live.cleanup(), + source.cleanup(), + ]) + } +}) + +it.each([`sync`, `async`] as const)( + `keeps an outcome-free %s completion local to its exact ordered window`, + async (settlement) => { + type Row = { id: number; rank: number } + const remoteRows: ReadonlyArray = [ + { id: 1, rank: 1 }, + { id: 2, rank: 2 }, + { id: 3, rank: 3 }, + ] + const loadedKeys = new Set() + const demands: Array = [] + const source = createCollection({ + id: `full-flow-outcome-free-${settlement}-source`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + const applyRequestedPrefix = (options: LoadSubsetOptions) => { + demands.push(options) + const requestedPrefix = options.limit ?? remoteRows.length + begin() + for (const row of remoteRows.slice(0, requestedPrefix)) { + if (loadedKeys.has(row.id)) continue + write({ type: `insert`, value: row }) + loadedKeys.add(row.id) + } + commit() + } + return { + loadSubset: (options) => { + if (settlement === `sync`) { + applyRequestedPrefix(options) + return true + } + return Promise.resolve().then(() => { + applyRequestedPrefix(options) + }) + }, + unloadSubset: () => {}, + } + }, + }, + }) + const live = createLiveQueryCollection({ + id: `full-flow-outcome-free-${settlement}-live`, + query: (q) => + q + .from({ row: source }) + .orderBy(({ row }) => row.rank) + .limit(1), + startSync: true, + }) + + try { + await live.preload() + expect(live.toArray.map(({ id }) => id)).toEqual([1]) + expect(demands).toHaveLength(1) + expect(demands[0]?.cursor).toBeUndefined() + + await live.utils.setWindow({ offset: 0, limit: 2 }) + + expect(live.toArray.map(({ id }) => id)).toEqual([1, 2]) + expect(demands).toHaveLength(2) + expect(demands[1]).toMatchObject({ limit: 2, offset: 0 }) + expect(demands[1]?.cursor).toBeUndefined() + } finally { + await live.cleanup() + await source.cleanup() + } + }, +) + +it.each([ + { + name: `continues past an excluded source row`, + middleEligible: false, + expectedCalls: 3, + expectedCursorKeys: [undefined, 1, 3], + expectedIds: [1, 2], + }, + { + name: `keeps the same source progress when that row is eligible`, + middleEligible: true, + expectedCalls: 3, + expectedCursorKeys: [undefined, 1, undefined], + expectedIds: [1, 3], + }, +] as const)(`$name after a short non-exhausted page`, async (scenario) => { + type Row = { id: number; rank: number; eligible: boolean } + const remoteRows: ReadonlyArray = [ + { id: 1, rank: 1, eligible: true }, + { id: 3, rank: 1, eligible: scenario.middleEligible }, + { id: 2, rank: 2, eligible: true }, + ] + const calls: Array = [] + let begin!: () => void + let write!: (message: { type: `insert`; value: Row }) => void + let commit!: () => true | Promise + const source = createCollection({ + id: `full-flow-short-continuation-source`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + params.markReady() + return { + loadSubset: async (options) => { + calls.push(options) + await Promise.resolve() + const rows = + calls.length === 1 + ? [remoteRows[0]!] + : calls.length === 2 + ? [remoteRows[1]!] + : [remoteRows[2]!] + begin() + for (const row of rows) write({ type: `insert`, value: row }) + const applied = commit() + if (applied !== true) await applied + return { + hasMore: calls.length < 3, + appliedRowKeys: rows.map(({ id }) => id), + } + }, + unloadSubset: () => {}, + } + }, + }, + }) + const live = createLiveQueryCollection({ + id: `full-flow-short-continuation-live`, + query: (q) => + q + .from({ row: source }) + .where(({ row }) => eq(row.eligible, true)) + .orderBy(({ row }) => row.rank) + .limit(2), + startSync: true, + }) + + try { + await live.preload() + await flushPromises() + + expect(calls).toHaveLength(scenario.expectedCalls) + expect(calls.map(({ cursor }) => cursor?.lastKey)).toEqual( + scenario.expectedCursorKeys, + ) + expect(live.toArray.map(({ id }) => id)).toEqual(scenario.expectedIds) + } finally { + await live.cleanup() + await source.cleanup() + } +}) + +type OrderedConsumer = `live-collection` | `effect` + +type OrderedConsumerParityScenario = { + middleCount: 0 | 1 | 2 | 3 + middleEligible: boolean + tied: boolean +} + +type OrderedConsumerParityObservation = { + cursorKeys: Array + limits: Array + visibleIds: Array + ready: boolean +} + +const orderedConsumerParityScenarioArbitrary: fc.Arbitrary = + fc.record({ + middleCount: fc.constantFrom( + 0 as const, + 1 as const, + 2 as const, + 3 as const, + ), + middleEligible: fc.boolean(), + tied: fc.boolean(), + }) + +const exhaustiveOrderedConsumerParityScenarios: ReadonlyArray = + ([0, 1, 2, 3] as const).flatMap((middleCount) => + [false, true].flatMap((middleEligible) => + [false, true].map((tied) => ({ + middleCount, + middleEligible, + tied, + })), + ), + ) + +let orderedConsumerParityHarnessId = 0 + +async function runTiedContinuationConsumer( + consumer: OrderedConsumer, + scenario: OrderedConsumerParityScenario, +): Promise { + type Row = { id: number; rank: number; eligible: boolean } + const firstRow: Row = { id: 1, rank: 1, eligible: true } + const middleRows: ReadonlyArray = Array.from( + { length: scenario.middleCount }, + (_, index) => ({ + id: index + 3, + rank: scenario.tied ? 1 : index + 2, + eligible: scenario.middleEligible, + }), + ) + const finalRow: Row = { + id: 2, + rank: scenario.tied ? 2 : scenario.middleCount + 2, + eligible: true, + } + const pageRows = [firstRow, ...middleRows, finalRow] + const calls: Array = [] + const pending: Array<{ + request: ReturnType< + typeof createDeferred<{ + hasMore: boolean + appliedRowKeys: ReadonlyArray + }> + > + result: { + hasMore: boolean + appliedRowKeys: ReadonlyArray + } + rowToApply?: Row + }> = [] + const visible = new Map() + let begin!: () => void + let write!: (message: { type: `insert`; value: Row }) => void + let commit!: () => true | Promise + const source = createCollection({ + id: `full-flow-effect-parity-${consumer}-${orderedConsumerParityHarnessId++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + begin() + for (const row of middleRows) { + write({ type: `insert`, value: row }) + } + commit() + params.markReady() + return { + loadSubset: (options) => { + const pageIndex = calls.length + calls.push(options) + const row = pageRows[pageIndex] + if (pageIndex === 0) { + if (!row) throw new Error(`Ordered consumer exceeded its pages`) + begin() + write({ type: `insert`, value: row }) + commit() + } + const request = createDeferred<{ + hasMore: boolean + appliedRowKeys: ReadonlyArray + }>() + pending.push({ + request, + result: { + hasMore: pageIndex < pageRows.length - 1, + appliedRowKeys: row ? [row.id] : [], + }, + rowToApply: pageIndex === pageRows.length - 1 ? row : undefined, + }) + return request.promise + }, + unloadSubset: () => {}, + } + }, + }, + }) + const query = (q: InitialQueryBuilder) => + q + .from({ row: source }) + .where(({ row }) => eq(row.eligible, true)) + .orderBy(({ row }) => row.rank) + .limit(2) + + let live: ReturnType | undefined + let preloadPromise: Promise | undefined + let preloadSettled = consumer === `effect` + let effect: ReturnType | undefined + if (consumer === `live-collection`) { + live = createLiveQueryCollection({ + id: `full-flow-effect-parity-live`, + query, + startSync: true, + }) + preloadPromise = live.preload() + void preloadPromise.then( + () => { + preloadSettled = true + }, + () => {}, + ) + } else { + effect = createEffect({ + query, + onBatch: (events) => { + for (const event of events) { + if (event.type === `exit`) visible.delete(event.key) + else visible.set(event.key, event.value) + } + }, + }) + } + + try { + await flushPromises() + let settled = 0 + while (settled < pending.length) { + if (settled > pageRows.length) { + throw new Error(`Ordered consumer did not reach a fixed point`) + } + const page = pending[settled]! + settled++ + if (page.rowToApply) { + begin() + write({ type: `insert`, value: page.rowToApply }) + const applied = commit() + if (applied !== true) await applied + } + page.request.resolve(page.result) + await flushPromises() + } + if (preloadPromise && preloadSettled) await preloadPromise + return { + cursorKeys: calls.map(({ cursor }) => cursor?.lastKey), + limits: calls.map(({ limit }) => limit), + visibleIds: live + ? live.toArray.map(({ id }) => id) + : [...visible.keys()].sort((a, b) => a - b), + ready: preloadSettled, + } + } finally { + if (live) await live.cleanup() + if (effect) await effect.dispose() + await source.cleanup() + } +} + +function projectOrderedConsumerParity( + scenario: OrderedConsumerParityScenario, +): Pick< + OrderedConsumerParityObservation, + `cursorKeys` | `visibleIds` | `ready` +> { + if (scenario.middleEligible && scenario.middleCount > 0) { + return { + cursorKeys: [undefined, 1], + visibleIds: [1, 3], + ready: true, + } + } + + return { + cursorKeys: [ + undefined, + 1, + ...Array.from({ length: scenario.middleCount }, (_, index) => index + 3), + ], + visibleIds: [1, 2], + ready: true, + } +} + +async function assertOrderedConsumerParity( + scenario: OrderedConsumerParityScenario, +): Promise { + const [live, effect] = await Promise.all([ + runTiedContinuationConsumer(`live-collection`, scenario), + runTiedContinuationConsumer(`effect`, scenario), + ]) + const expected = projectOrderedConsumerParity(scenario) + + expect({ + cursorKeys: live.cursorKeys, + visibleIds: live.visibleIds, + ready: live.ready, + }).toEqual(expected) + expect(effect).toEqual(live) +} + +it(`keeps ordered continuation progress equal across collection consumers`, async () => { + const scenario: OrderedConsumerParityScenario = { + middleCount: 2, + middleEligible: false, + tied: true, + } + const [live, effect] = await Promise.all([ + runTiedContinuationConsumer(`live-collection`, scenario), + runTiedContinuationConsumer(`effect`, scenario), + ]) + + expect(live.cursorKeys).toEqual([undefined, 1, 3, 4]) + expect(live.visibleIds).toEqual([1, 2]) + expect(effect).toEqual(live) +}) + +it(`keeps consumer parity when only the middle rows become eligible`, async () => { + const scenario: OrderedConsumerParityScenario = { + middleCount: 2, + middleEligible: true, + tied: true, + } + const [live, effect] = await Promise.all([ + runTiedContinuationConsumer(`live-collection`, scenario), + runTiedContinuationConsumer(`effect`, scenario), + ]) + + expect(live.cursorKeys).toEqual([undefined, 1]) + expect(live.visibleIds).toEqual([1, 3]) + expect(effect).toEqual(live) +}) + +it(`exhausts bounded ordered continuation histories across collection consumers`, async () => { + for (const scenario of exhaustiveOrderedConsumerParityScenarios) { + await assertOrderedConsumerParity(scenario) + } +}) + +fcTest.prop([orderedConsumerParityScenarioArbitrary], { + numRuns: 12 * fullFlowMultiplier, + seed: 17785, +})( + `keeps ordered collection consumers equal for a fixed seed`, + assertOrderedConsumerParity, +) + +fcTest.prop( + [orderedConsumerParityScenarioArbitrary], + oracleRandomParameters(12 * fullFlowMultiplier, fullFlowReplaySeed), +)( + `keeps ordered collection consumers equal for a random or replayed seed`, + assertOrderedConsumerParity, +) + +it(`retries an evidence-free Effect continuation after prefix refinement`, async () => { + type Row = { id: number; rank: number; label: string } + type Result = { + hasMore: boolean + appliedRowKeys: ReadonlyArray + } + const firstRow: Row = { + id: 1, + rank: 1, + label: `before`, + } + const updatedFirstRow: Row = { ...firstRow, label: `after` } + const secondRow: Row = { + id: 2, + rank: 2, + label: `second`, + } + const calls: Array = [] + const pending: Array>> = [] + const visible = new Map() + let begin!: () => void + let write!: ( + message: + | { type: `insert`; value: Row } + | { type: `update`; value: Row; previousValue: Row }, + ) => void + let commit!: () => true | Promise + const source = createCollection({ + id: `full-flow-effect-prefix-refinement`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + params.markReady() + return { + loadSubset: (options) => { + calls.push(options) + if (calls.length === 1) { + begin() + write({ type: `insert`, value: firstRow }) + commit() + } + const request = createDeferred() + pending.push(request) + return request.promise + }, + unloadSubset: () => {}, + } + }, + }, + }) + const effect = createEffect({ + query: (q) => + q + .from({ row: source }) + .orderBy(({ row }) => row.rank) + .limit(2), + onBatch: (events) => { + for (const event of events) { + if (event.type === `exit`) visible.delete(event.key) + else visible.set(event.key, event.value) + } + }, + }) + + try { + await flushPromises() + expect(pending).toHaveLength(1) + pending[0]!.resolve({ hasMore: true, appliedRowKeys: [firstRow.id] }) + await flushPromises() + expect(pending).toHaveLength(2) + pending[1]!.resolve({ hasMore: true, appliedRowKeys: [firstRow.id] }) + await flushPromises() + expect(pending).toHaveLength(3) + pending[2]!.resolve({ hasMore: true, appliedRowKeys: [] }) + await flushPromises() + expect(calls).toHaveLength(3) + + begin() + write({ + type: `update`, + value: updatedFirstRow, + previousValue: firstRow, + }) + const updated = commit() + if (updated !== true) await updated + await flushPromises() + + expect(calls).toHaveLength(4) + begin() + write({ type: `insert`, value: secondRow }) + const applied = commit() + if (applied !== true) await applied + pending[3]!.resolve({ hasMore: false, appliedRowKeys: [secondRow.id] }) + await flushPromises() + + expect([...visible.values()].map(({ id }) => id)).toEqual([1, 2]) + } finally { + await effect.dispose() + await source.cleanup() + } +}) + +it(`retries an evidence-free ordered Effect after truncate`, async () => { + type Row = { id: number; rank: number } + type Result = { + hasMore: boolean + appliedRowKeys: ReadonlyArray + } + const finalRow: Row = { id: 2, rank: 2 } + const calls: Array = [] + const pending: Array>> = [] + const visible = new Map() + let begin!: () => void + let write!: (message: { type: `insert`; value: Row }) => void + let commit!: () => true | Promise + let truncate!: () => void + const source = createCollection({ + id: `full-flow-effect-truncate-reset`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + truncate = params.truncate + params.markReady() + return { + loadSubset: (options) => { + calls.push(options) + const request = createDeferred() + pending.push(request) + return request.promise + }, + unloadSubset: () => {}, + } + }, + }, + }) + const effect = createEffect({ + query: (q) => + q + .from({ row: source }) + .orderBy(({ row }) => row.rank) + .limit(1), + onBatch: (events) => { + for (const event of events) { + if (event.type === `exit`) visible.delete(event.key) + else visible.set(event.key, event.value) + } + }, + }) + + try { + await flushPromises() + expect(pending).toHaveLength(1) + pending[0]!.resolve({ hasMore: true, appliedRowKeys: [] }) + await flushPromises() + expect(pending).toHaveLength(2) + pending[1]!.resolve({ hasMore: true, appliedRowKeys: [] }) + await flushPromises() + expect(calls).toHaveLength(2) + + begin() + truncate() + const replacement = commit() + await flushPromises() + // Both retained logical demands replay, but Effect must not add a third + // transport until those replacement acquisitions have settled. + expect(pending).toHaveLength(4) + + pending[2]!.resolve({ hasMore: true, appliedRowKeys: [] }) + await flushPromises() + expect(pending).toHaveLength(4) + pending[3]!.resolve({ hasMore: true, appliedRowKeys: [] }) + await flushPromises() + expect(pending).toHaveLength(5) + + begin() + write({ type: `insert`, value: finalRow }) + const applied = commit() + if (applied !== true) await applied + pending[4]!.resolve({ hasMore: false, appliedRowKeys: [finalRow.id] }) + if (replacement !== true) await replacement + await flushPromises() + + expect([...visible.keys()]).toEqual([finalRow.id]) + expect(calls).toHaveLength(5) + } finally { + await effect.dispose() + await source.cleanup() + } +}) + +it(`rechecks an ordered Effect after synchronous truncate replay`, async () => { + type Row = { id: number; rank: number } + type Result = { + hasMore: boolean + appliedRowKeys: ReadonlyArray + } + const finalRow: Row = { id: 2, rank: 2 } + const pending: Array>> = [] + const visible = new Map() + let calls = 0 + let replaying = false + let replayCalls = 0 + let begin!: () => void + let write!: (message: { type: `insert`; value: Row }) => void + let commit!: () => true | Promise + let truncate!: () => void + const source = createCollection({ + id: `full-flow-effect-sync-truncate-reset`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + truncate = params.truncate + params.markReady() + return { + loadSubset: () => { + calls++ + if (!replaying) { + const request = createDeferred() + pending.push(request) + return request.promise + } + + replayCalls++ + if (replayCalls === 3) { + begin() + write({ type: `insert`, value: finalRow }) + commit() + } + return true + }, + unloadSubset: () => {}, + } + }, + }, + }) + const effect = createEffect({ + query: (q) => + q + .from({ row: source }) + .orderBy(({ row }) => row.rank) + .limit(1), + onBatch: (events) => { + for (const event of events) { + if (event.type === `exit`) visible.delete(event.key) + else visible.set(event.key, event.value) + } + }, + }) + + try { + await flushPromises() + pending[0]!.resolve({ hasMore: true, appliedRowKeys: [] }) + await flushPromises() + pending[1]!.resolve({ hasMore: true, appliedRowKeys: [] }) + await flushPromises() + expect(calls).toBe(2) + + replaying = true + begin() + truncate() + const replacement = commit() + await flushPromises() + if (replacement !== true) await replacement + + expect(replayCalls).toBe(3) + expect(calls).toBe(5) + expect([...visible.keys()]).toEqual([finalRow.id]) + } finally { + await effect.dispose() + await source.cleanup() + } +}) + +it(`replaces an ordered Effect only after a rejected continuation disposes it`, async () => { + type Row = { id: number; rank: number } + const firstRow: Row = { id: 1, rank: 1 } + const replacementRow: Row = { id: 2, rank: 2 } + const failure = new Error(`ordered continuation failed`) + let calls = 0 + let begin!: () => void + let write!: ( + message: { type: `insert`; value: Row } | { type: `delete`; value: Row }, + ) => void + let commit!: () => true | Promise + const source = createCollection({ + id: `full-flow-effect-rejection-reset`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + params.markReady() + return { + loadSubset: () => { + calls++ + if (calls === 2) return Promise.reject(failure) + const row = calls === 1 ? firstRow : replacementRow + begin() + write({ type: `insert`, value: row }) + commit() + return Promise.resolve() + }, + unloadSubset: () => {}, + } + }, + }, + }) + const errors: Array = [] + const first = createEffect({ + query: (q) => + q + .from({ row: source }) + .orderBy(({ row }) => row.rank) + .limit(1), + onBatch: () => {}, + onSourceError: (error) => errors.push(error), + }) + let second: ReturnType> | undefined + + try { + await flushPromises() + expect(calls).toBe(1) + + begin() + write({ type: `delete`, value: firstRow }) + const removed = commit() + if (removed !== true) await removed + await flushPromises() + + expect(calls).toBe(2) + expect(errors).toEqual([failure]) + expect(first.disposed).toBe(true) + + const visible = new Map() + second = createEffect({ + query: (q) => + q + .from({ row: source }) + .orderBy(({ row }) => row.rank) + .limit(1), + onBatch: (events) => { + for (const event of events) { + if (event.type === `exit`) visible.delete(event.key) + else visible.set(event.key, event.value) + } + }, + }) + await flushPromises() + + expect(calls).toBe(3) + expect(second.disposed).toBe(false) + expect([...visible.keys()]).toEqual([replacementRow.id]) + } finally { + await first.dispose() + if (second) await second.dispose() + await source.cleanup() + } +}) + +it(`does not continue an ordered Effect after teardown`, async () => { + type Row = { id: number; rank: number } + const row: Row = { id: 1, rank: 1 } + const pending = createDeferred<{ + hasMore: boolean + appliedRowKeys: ReadonlyArray + }>() + let calls = 0 + let begin!: () => void + let write!: (message: { type: `insert`; value: Row }) => void + let commit!: () => true | Promise + const source = createCollection({ + id: `full-flow-effect-teardown-fence`, + getKey: (value) => value.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + params.markReady() + return { + loadSubset: () => { + calls++ + begin() + write({ type: `insert`, value: row }) + commit() + return pending.promise + }, + unloadSubset: () => {}, + } + }, + }, + }) + const effect = createEffect({ + query: (q) => + q + .from({ row: source }) + .orderBy(({ row: value }) => value.rank) + .limit(2), + onBatch: () => {}, + }) + + try { + await flushPromises() + expect(calls).toBe(1) + await effect.dispose() + + pending.resolve({ hasMore: true, appliedRowKeys: [row.id] }) + await flushPromises() + + expect(calls).toBe(1) + } finally { + await effect.dispose() + await source.cleanup() + } +}) + +it(`continues across every excluded source row beyond the visible target`, async () => { + type Row = { id: number; rank: number; eligible: boolean } + const remoteRows: ReadonlyArray = [ + { id: 1, rank: 1, eligible: true }, + { id: 2, rank: 2, eligible: false }, + { id: 3, rank: 3, eligible: false }, + { id: 4, rank: 4, eligible: true }, + ] + const calls: Array = [] + let begin!: () => void + let write!: (message: { type: `insert`; value: Row }) => void + let commit!: () => true | Promise + const source = createCollection({ + id: `full-flow-excluded-progress-source`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + params.markReady() + return { + loadSubset: async (options) => { + calls.push(options) + await Promise.resolve() + const lastKey = options.cursor?.lastKey + const rowIndex = + lastKey === undefined + ? 0 + : remoteRows.findIndex(({ id }) => id === lastKey) + 1 + const row = remoteRows[rowIndex] + if (!row) throw new Error(`Expected another remote row`) + begin() + write({ type: `insert`, value: row }) + const applied = commit() + if (applied !== true) await applied + return { + hasMore: rowIndex < remoteRows.length - 1, + appliedRowKeys: [row.id], + } + }, + unloadSubset: () => {}, + } + }, + }, + }) + const live = createLiveQueryCollection({ + id: `full-flow-excluded-progress-live`, + query: (q) => + q + .from({ row: source }) + .where(({ row }) => eq(row.eligible, true)) + .orderBy(({ row }) => row.rank) + .limit(2), + startSync: true, + }) + + try { + await live.preload() + await flushPromises() + + expect(calls).toHaveLength(4) + expect(calls.map(({ cursor }) => cursor?.lastKey)).toEqual([ + undefined, + 1, + 2, + 3, + ]) + expect(live.toArray.map(({ id }) => id)).toEqual([1, 4]) + } finally { + await live.cleanup() + await source.cleanup() + } +}) + +it(`does not repeat an evidence-free ordered continuation`, async () => { + type Row = { id: number; rank: number } + const row: Row = { id: 1, rank: 1 } + const calls: Array = [] + let begin!: () => void + let write!: (message: { type: `insert`; value: Row }) => void + let commit!: () => true | Promise + const source = createCollection({ + id: `full-flow-no-progress-source`, + getKey: (value) => value.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + params.markReady() + return { + loadSubset: async (options) => { + calls.push(options) + await Promise.resolve() + const rows = calls.length === 1 ? [row] : [] + begin() + for (const value of rows) write({ type: `insert`, value }) + const applied = commit() + if (applied !== true) await applied + return { + hasMore: true, + appliedRowKeys: rows.map(({ id }) => id), + } + }, + unloadSubset: () => {}, + } + }, + }, + }) + const live = createLiveQueryCollection({ + id: `full-flow-no-progress-live`, + query: (q) => + q + .from({ row: source }) + .orderBy(({ row: value }) => value.rank) + .limit(2), + startSync: true, + }) + + try { + await live.preload() + await flushPromises() + + expect(calls).toHaveLength(2) + expect(live.toArray.map(({ id }) => id)).toEqual([1]) + expect(live.utils.lastSubsetError).toMatchObject({ + message: expect.stringContaining(`made no ordered progress`), + }) + const [subscription] = Object.values( + live.utils[LIVE_QUERY_INTERNAL].getBuilder().subscriptions, + ) + expect(subscription?.hasOrderedCoverageForActiveWindow).toBe(false) + expect(subscription?.orderedRowsNeeded).toBe(1) + + await live.utils.setWindow({ offset: 0, limit: 3 }) + await flushPromises() + + expect(calls).toHaveLength(3) + expect(calls[2]?.cursor?.lastKey).toBe(1) + expect(subscription?.hasOrderedCoverageForActiveWindow).toBe(false) + expect(subscription?.orderedRowsNeeded).toBe(2) + } finally { + await live.cleanup() + await source.cleanup() + } +}) + +type OrderedContinuationEvidenceScenario = { + targetSize: number + eligibleKeys: ReadonlyArray + pages: ReadonlyArray<{ + requestedPrefix: number + appliedKeys: ReadonlyArray + extent: `continues` | `exhausted` + }> +} + +const orderedEvidenceKeyArbitrary = fc.constantFrom(`a`, `b`, `c`, `d`) +const orderedContinuationEvidenceScenarioArbitrary: fc.Arbitrary = + fc.record({ + targetSize: fc.integer({ min: 1, max: 4 }), + eligibleKeys: fc.uniqueArray(orderedEvidenceKeyArbitrary, { + minLength: 0, + maxLength: 4, + }), + pages: fc.array( + fc.record({ + requestedPrefix: fc.integer({ min: 1, max: 4 }), + appliedKeys: fc.uniqueArray(orderedEvidenceKeyArbitrary, { + minLength: 0, + maxLength: 4, + }), + extent: fc.constantFrom(`continues` as const, `exhausted` as const), + }), + { minLength: 1, maxLength: 4 }, + ), + }) + +if (process.env.TANSTACK_DB_ORACLE_STATISTICS === `1`) { + fc.statistics( + orderedContinuationEvidenceScenarioArbitrary, + ({ eligibleKeys, pages }) => [ + `empty-continuation=${pages.some( + (page) => page.extent === `continues` && page.appliedKeys.length === 0, + )}`, + `short-continuation=${pages.some( + (page) => + page.extent === `continues` && + page.appliedKeys.length < page.requestedPrefix, + )}`, + `excluded-applied-row=${pages.some((page) => + page.appliedKeys.some((key) => !eligibleKeys.includes(key)), + )}`, + `exhaustion=${pages.some((page) => page.extent === `exhausted`)}`, + ], + oracleRandomParameters(1_000, fullFlowReplaySeed), + ) +} + +let orderedEvidenceHarnessId = 0 + +type OrderedEvidenceRow = { + id: string + rank: number + eligible: boolean +} + +function assertOrderedContinuationEvidence( + window: WindowState, string | number>, + scenario: OrderedContinuationEvidenceScenario, + sourceOrder: ReadonlyArray = [`a`, `b`, `c`, `d`], +): void { + const eligibleKeys = new Set(scenario.eligibleKeys) + const [initial, ...continuations] = scenario.pages + if (!initial) throw new Error(`Expected an initial evidence page`) + window.recordInitialCoverage( + initial.appliedKeys, + initial.extent === `exhausted`, + ) + if (initial.extent !== `exhausted`) { + for (const page of continuations) { + window.recordContinuationCoverage( + page.appliedKeys, + page.extent === `exhausted`, + page.requestedPrefix, + window.coverageRevision, + ) + if (page.extent === `exhausted`) break + } + } + + const expected = projectOrderedContinuationEvidence({ + sourceOrder, + eligibleKeys, + targetSize: scenario.targetSize, + pages: scenario.pages, + }) + const actualKeys = window + .reconcile(new Map()) + .filter((change) => change.type === `insert`) + .map(({ key }) => key) + + expect(actualKeys).toEqual(expected.visibleKeys) + expect(window.requestBoundary()?.key).toBe(expected.boundaryKey) + expect(window.coveredPrefixSize).toBe(expected.coveredPrefixSize) + expect(window.coversActiveWindow).toBe(expected.coversTarget) + expect(window.rowsNeeded()).toBe(expected.rowsNeeded) +} + +async function runOrderedContinuationEvidenceScenario( + scenario: OrderedContinuationEvidenceScenario, +): Promise { + const sourceOrder = [`a`, `b`, `c`, `d`] + const eligibleKeys = new Set(scenario.eligibleKeys) + const rows: Array = sourceOrder.map((id, index) => ({ + id, + rank: index + 1, + eligible: eligibleKeys.has(id), + })) + const source = createCollection( + mockSyncCollectionOptions({ + id: `ordered-evidence-oracle-${orderedEvidenceHarnessId++}`, + initialData: rows, + getKey: (row) => row.id, + }), + ) + await source.preload() + const orderBy = [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc` as const, nulls: `first` as const }, + }, + ] + const where = new Func(`eq`, [new PropRef([`eligible`]), new Value(true)]) + const window = new WindowState(source, orderBy, where, scenario.targetSize) + + try { + assertOrderedContinuationEvidence(window, scenario) + } finally { + await source.cleanup() + } +} + +it(`exhausts the bounded ordered-evidence model`, async () => { + const boundedKeys = [`a`, `b`] as const + const keySets: Array> = [[]] + for (const key of boundedKeys) { + keySets.push(...keySets.map((keys) => [...keys, key])) + } + const pages = [1, 2].flatMap((requestedPrefix) => + keySets.flatMap((appliedKeys) => + ([`continues`, `exhausted`] as const).map((extent) => ({ + requestedPrefix, + appliedKeys, + extent, + })), + ), + ) + const histories = [ + ...pages.map((page) => [page]), + ...pages.flatMap((first) => pages.map((second) => [first, second])), + ] + const sourceOrder = [...boundedKeys] + let checked = 0 + + for (const eligible of keySets) { + const eligibleKeys = new Set(eligible) + const rows: Array = sourceOrder.map((id, index) => ({ + id, + rank: index + 1, + eligible: eligibleKeys.has(id), + })) + const source = createCollection( + mockSyncCollectionOptions({ + id: `ordered-evidence-exhaustive-${orderedEvidenceHarnessId++}`, + initialData: rows, + getKey: (row) => row.id, + }), + ) + await source.preload() + const orderBy = [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc` as const, nulls: `first` as const }, + }, + ] + const where = new Func(`eq`, [new PropRef([`eligible`]), new Value(true)]) + + try { + for (const targetSize of [1, 2]) { + for (const evidencePages of histories) { + const scenario: OrderedContinuationEvidenceScenario = { + targetSize, + eligibleKeys: eligible, + pages: evidencePages, + } + assertOrderedContinuationEvidence( + new WindowState(source, orderBy, where, targetSize), + scenario, + sourceOrder, + ) + checked++ + } + } + } finally { + await source.cleanup() + } + } + + expect(checked).toBe(2_176) +}) + +type AutomaticOrderedProgressState = { + demandedPrefix: number + refillLimit: number + boundary?: { rank: number; key: string } +} + +function assertAutomaticOrderedProgress( + states: ReadonlyArray, +): void { + const orderByInfo = { + orderBy: [ + { + expression: new PropRef([`rank`]), + compareOptions: { + direction: `asc` as const, + nulls: `first` as const, + }, + }, + ], + offset: 0, + valueExtractorForRawRow: (row: Record) => row.rank, + } + let lastLoadRequestKey: string | undefined + let lastAcceptedIdentity: string | undefined + + for (const state of states) { + const identity = JSON.stringify({ + demandedPrefix: state.demandedPrefix, + rank: state.boundary?.rank ?? null, + key: state.boundary?.key ?? null, + }) + const request = computeOrderedLoadCursor( + orderByInfo, + state.boundary, + lastLoadRequestKey, + `row`, + state.refillLimit, + state.demandedPrefix, + state.boundary?.key, + ) + const shouldStart = identity !== lastAcceptedIdentity + + expect(request !== undefined).toBe(shouldStart) + if (request) { + lastLoadRequestKey = request.loadRequestKey + lastAcceptedIdentity = identity + } + } +} + +const automaticOrderedProgressStateArbitrary: fc.Arbitrary = + fc.record({ + demandedPrefix: fc.integer({ min: 1, max: 4 }), + refillLimit: fc.integer({ min: 1, max: 4 }), + boundary: fc.option( + fc.record({ + rank: fc.integer({ min: -1, max: 2 }), + key: fc.constantFrom(`a`, `b`, `c`), + }), + { nil: undefined }, + ), + }) + +it(`exhausts the bounded automatic-progress transition law`, () => { + const boundaries: ReadonlyArray = [ + undefined, + { rank: 0, key: `a` }, + { rank: 0, key: `b` }, + { rank: 1, key: `a` }, + ] + const states = [1, 2].flatMap((demandedPrefix) => + [1, 2].flatMap((refillLimit) => + boundaries.map((boundary) => ({ + demandedPrefix, + refillLimit, + boundary, + })), + ), + ) + let checked = 0 + + for (const first of states) { + for (const second of states) { + assertAutomaticOrderedProgress([first, second]) + checked++ + } + } + + expect(checked).toBe(256) +}) + +fcTest.prop( + [ + fc.array(automaticOrderedProgressStateArbitrary, { + minLength: 1, + maxLength: 8, + }), + ], + { + numRuns: 128 * fullFlowMultiplier, + seed: 17784, + }, +)( + `starts automatic continuation only for new semantic progress with a fixed seed`, + assertAutomaticOrderedProgress, +) + +fcTest.prop( + [ + fc.array(automaticOrderedProgressStateArbitrary, { + minLength: 1, + maxLength: 8, + }), + ], + oracleRandomParameters(128 * fullFlowMultiplier, fullFlowReplaySeed), +)( + `starts automatic continuation only for new semantic progress with a random or replayed seed`, + assertAutomaticOrderedProgress, +) + +fcTest.prop([orderedContinuationEvidenceScenarioArbitrary], { + numRuns: 64 * fullFlowMultiplier, + seed: 17783, +})( + `derives ordered progress from applied eligible evidence for a fixed seed`, + runOrderedContinuationEvidenceScenario, +) + +fcTest.prop( + [orderedContinuationEvidenceScenarioArbitrary], + oracleRandomParameters(64 * fullFlowMultiplier, fullFlowReplaySeed), +)( + `derives ordered progress from applied eligible evidence for a random or replayed seed`, + runOrderedContinuationEvidenceScenario, +) + +type OrderedBoundaryProvenanceScenario = { + direction: `asc` | `desc` + offset: 0 | 1 + tied: boolean + addedRowPlacement: `before` | `after` + replayFailure: `throw` | `reject` +} + +const orderedBoundaryProvenanceArbitrary: fc.Arbitrary = + fc.record({ + direction: fc.constantFrom(`asc` as const, `desc` as const), + offset: fc.constantFrom(0 as const, 1 as const), + tied: fc.boolean(), + addedRowPlacement: fc.constantFrom(`before` as const, `after` as const), + replayFailure: fc.constantFrom(`throw` as const, `reject` as const), + }) + +const exhaustiveOrderedBoundaryProvenanceScenarios: ReadonlyArray = + ([`asc`, `desc`] as const).flatMap((direction) => + ([0, 1] as const).flatMap((offset) => + [false, true].flatMap((tied) => + ([`before`, `after`] as const).flatMap((addedRowPlacement) => + ([`throw`, `reject`] as const).map((replayFailure) => ({ + direction, + offset, + tied, + addedRowPlacement, + replayFailure, + })), + ), + ), + ), + ) + +let orderedBoundaryHarnessId = 0 + +async function runOrderedBoundaryProvenanceScenario( + scenario: OrderedBoundaryProvenanceScenario, +): Promise { + type Row = { + id: `a` | `b` | `c` | `z` + rank: number + route: `ordered` | `unrelated` + } + const orderedRows: ReadonlyArray = [ + { id: `a`, rank: scenario.tied ? 5 : 1, route: `ordered` }, + { id: `b`, rank: scenario.tied ? 5 : 2, route: `ordered` }, + { id: `c`, rank: scenario.tied ? 5 : 3, route: `ordered` }, + ] + const addedRow: Row = { + id: `z`, + rank: + scenario.addedRowPlacement === `before` + ? scenario.direction === `asc` + ? 0 + : 6 + : scenario.direction === `asc` + ? scenario.tied + ? 5 + : 99 + : scenario.tied + ? 5 + : -99, + route: `unrelated`, + } + const orderedForDirection = [...orderedRows].sort((left, right) => { + const valueOrder = + scenario.direction === `asc` + ? left.rank - right.rank + : right.rank - left.rank + return valueOrder || left.id.localeCompare(right.id) + }) + const rowsAfterAdditionalDemand = [...orderedRows, addedRow].sort( + (left, right) => { + const valueOrder = + scenario.direction === `asc` + ? left.rank - right.rank + : right.rank - left.rank + return valueOrder || left.id.localeCompare(right.id) + }, + ) + const prefixSize = scenario.offset + 1 + const expectedOrderedPrefix = ( + scenario.addedRowPlacement === `before` + ? rowsAfterAdditionalDemand + : orderedForDirection + ).slice(0, prefixSize) + const history: Array = [ + { + type: `stagePublicationRows`, + publicationId: `initial-publication`, + demandId: `ordered-window`, + rows: orderedForDirection.slice(0, prefixSize).map((row) => ({ + key: row.id, + orderValue: row.rank, + })), + }, + { type: `commitPublication`, publicationId: `initial-publication` }, + // A later row before the prefix changes the ordered publication. A row + // after it remains only unordered-retention data and cannot move its + // continuation boundary. + ...(scenario.addedRowPlacement === `before` + ? ([ + { + type: `stagePublicationRows`, + publicationId: `additional-publication`, + demandId: `ordered-window`, + rows: expectedOrderedPrefix.map((row) => ({ + key: row.id, + orderValue: row.rank, + })), + }, + ] satisfies Array) + : []), + { + type: `stagePublicationRows`, + publicationId: `additional-publication`, + demandId: `unordered-retention`, + rows: [{ key: addedRow.id, orderValue: addedRow.rank }], + }, + { type: `commitPublication`, publicationId: `additional-publication` }, + { type: `truncateSource`, sessionId: `session` }, + { + type: `stagePublicationRows`, + publicationId: `failed-replacement`, + demandId: `ordered-window`, + rows: [ + { + key: expectedOrderedPrefix.at(-1)!.id, + orderValue: + expectedOrderedPrefix.at(-1)!.rank + + (scenario.direction === `asc` ? 100 : -100), + }, + ], + }, + { + type: `rejectDemand`, + ownerId: `ordered-owner`, + demandId: `ordered-window`, + }, + ] + const expectedBoundary = projectOrderedPublicationBoundary(history, { + demandId: `ordered-window`, + direction: scenario.direction, + prefixSize, + }) + if (!expectedBoundary) throw new Error(`Expected an ordered boundary`) + const partialReplayRow: Row = { + id: expectedBoundary.key as Row[`id`], + rank: + expectedBoundary.orderValue + (scenario.direction === `asc` ? 100 : -100), + route: expectedBoundary.key === addedRow.id ? `unrelated` : `ordered`, + } + + let begin!: () => void + let write!: (message: { type: `insert`; value: Row }) => void + let commit!: () => true | Promise + let truncate!: () => void + let phase: `initial` | `replay` | `probe` = `initial` + const loadOptions: Array = [] + const visible = new Map() + const applyRows = async (rows: ReadonlyArray) => { + begin() + for (const row of rows) write({ type: `insert`, value: row }) + const receipt = commit() + if (receipt !== true) await receipt + } + const source = createCollection({ + id: `ordered-boundary-provenance-${orderedBoundaryHarnessId++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + truncate = params.truncate + params.markReady() + return { + loadSubset: (options) => { + loadOptions.push(options) + if (phase === `initial`) { + const rows = options.orderBy ? orderedRows : [addedRow] + return applyRows(rows).then(() => ({ + hasMore: false, + appliedRowKeys: rows.map(({ id }) => id), + })) + } + if (phase === `replay` && options.orderBy) { + if (scenario.replayFailure === `throw`) { + begin() + write({ type: `insert`, value: partialReplayRow }) + const receipt = commit() + if (receipt !== true) void receipt.catch(() => {}) + throw new Error(`ordered replay failed`) + } + return applyRows([partialReplayRow]).then(() => + Promise.reject(new Error(`ordered replay failed`)), + ) + } + return Promise.resolve({ + hasMore: false, + appliedRowKeys: [], + }) + }, + unloadSubset: () => {}, + } + }, + }, + }) + const index = source.createIndex((row) => row.rank, { + indexType: BTreeIndex, + }) + const orderedIndex = + scenario.direction === `asc` ? index : new ReverseIndex(index) + const orderBy = [ + { + expression: new PropRef([`rank`]), + compareOptions: { + direction: scenario.direction, + nulls: `first` as const, + }, + }, + ] + const unrelatedWhere = new Func(`eq`, [ + new PropRef([`route`]), + new Value(`unrelated`), + ]) + const subscription = source.subscribeChanges((changes) => { + for (const change of changes) { + if (change.type === `delete`) visible.delete(change.key as Row[`id`]) + else visible.set(change.key as Row[`id`], change.value) + } + }) + subscription.setOrderByIndex(orderedIndex) + + try { + subscription.requestLimitedSnapshot({ + orderBy, + limit: 1, + offset: scenario.offset, + }) + await flushPromises() + subscription.requestSnapshot({ + where: unrelatedWhere, + optimizedOnly: false, + }) + await flushPromises() + + expect([...visible.keys()].sort()).toEqual( + [ + ...new Set([ + ...rowsAfterAdditionalDemand.slice(0, prefixSize).map(({ id }) => id), + addedRow.id, + ]), + ].sort(), + ) + expect((subscription.orderedBoundaryRow as Row | undefined)?.id).toBe( + expectedBoundary.key, + ) + expect((subscription.orderedBoundaryRow as Row | undefined)?.rank).toBe( + expectedBoundary.orderValue, + ) + + phase = `replay` + begin() + truncate() + const receipt = commit() + if (receipt !== true) await receipt + await flushPromises() + + phase = `probe` + const beforeProbe = loadOptions.length + subscription.requestLimitedSnapshot({ + orderBy, + limit: 1, + offset: scenario.offset, + }) + await flushPromises() + + expect(loadOptions).toHaveLength(beforeProbe + 1) + const cursor = loadOptions.at(-1)?.cursor + expect(cursor?.lastKey).toBe(expectedBoundary.key) + expect(cursor?.whereCurrent).toBeDefined() + expect(cursor?.whereFrom).toBeDefined() + expect( + evaluateReferenceExpression(cursor!.whereCurrent, { + rank: expectedBoundary.orderValue, + }), + ).toBe(true) + expect( + evaluateReferenceExpression(cursor!.whereCurrent, { + rank: expectedBoundary.orderValue + 1, + }), + ).toBe(false) + expect( + evaluateReferenceExpression(cursor!.whereFrom, { + rank: + expectedBoundary.orderValue + (scenario.direction === `asc` ? 1 : -1), + }), + ).toBe(true) + } finally { + subscription.unsubscribe() + await source.cleanup() + } +} + +it(`keeps failed-replay cursors scoped to the last complete ordered publication`, async () => { + for (const scenario of exhaustiveOrderedBoundaryProvenanceScenarios) { + await runOrderedBoundaryProvenanceScenario(scenario) + } +}) + +fcTest.prop([orderedBoundaryProvenanceArbitrary], { + numRuns: 32 * fullFlowMultiplier, + seed: 1778, +})( + `keeps ordered boundary provenance for a fixed seed`, + runOrderedBoundaryProvenanceScenario, +) + +fcTest.prop( + [orderedBoundaryProvenanceArbitrary], + oracleRandomParameters(32 * fullFlowMultiplier, fullFlowReplaySeed), +)( + `keeps ordered boundary provenance for a random or replayed seed`, + runOrderedBoundaryProvenanceScenario, +) + +type AtomicOrderedReplayScenario = { + direction: `asc` | `desc` + initialPublication?: `empty` | `nonempty` + callerContinuation?: `none` | `min-values` | `offset` | `both` + resizeOrder: `grow-shrink` | `shrink-grow` + overlap: boolean + currentOutcome: `resolve` | `reject` + currentExtent: `exhausted` | `continues` + emptyContinuingReplay?: boolean + settleCurrentFirst: boolean + sourceDelta: boolean + otherDemand: `none` | `active` | `released` + otherOutcome?: `resolve` | `reject` + demandSettlementOrder?: `ordered-first` | `other-first` + releaseAfterOrdered?: boolean + terminal?: `settle` | `unsubscribe` +} + +const atomicOrderedReplayArbitrary: fc.Arbitrary = + fc.record({ + direction: fc.constantFrom(`asc` as const, `desc` as const), + initialPublication: fc.constantFrom(`empty` as const, `nonempty` as const), + callerContinuation: fc.constantFrom( + `none` as const, + `min-values` as const, + `offset` as const, + `both` as const, + ), + resizeOrder: fc.constantFrom( + `grow-shrink` as const, + `shrink-grow` as const, + ), + overlap: fc.boolean(), + currentOutcome: fc.constantFrom(`resolve` as const, `reject` as const), + currentExtent: fc.constantFrom(`exhausted` as const, `continues` as const), + emptyContinuingReplay: fc.boolean(), + settleCurrentFirst: fc.boolean(), + sourceDelta: fc.boolean(), + otherDemand: fc.constantFrom( + `none` as const, + `active` as const, + `released` as const, + ), + }) + +const exhaustiveAtomicOrderedReplayScenarios: ReadonlyArray = + ([`empty`, `nonempty`] as const).flatMap((initialPublication) => + ([`asc`, `desc`] as const).flatMap((direction) => + ([`grow-shrink`, `shrink-grow`] as const).flatMap((resizeOrder) => + [false, true].flatMap((overlap) => + ([`resolve`, `reject`] as const).flatMap((currentOutcome) => + ([`exhausted`, `continues`] as const).flatMap((currentExtent) => + [false, true].flatMap((settleCurrentFirst) => + [false, true].flatMap((sourceDelta) => + ([`none`, `active`, `released`] as const).map( + (otherDemand) => ({ + direction, + initialPublication, + resizeOrder, + overlap, + currentOutcome, + currentExtent, + settleCurrentFirst, + sourceDelta, + otherDemand, + }), + ), + ), + ), + ), + ), + ), + ), + ), + ) + +let atomicReplayHarnessId = 0 + +async function runAtomicOrderedReplayScenario( + scenario: AtomicOrderedReplayScenario, +): Promise { + type Row = { + id: + | `old-a` + | `old-b` + | `new-a` + | `new-b` + | `delta` + | `tail` + | `obsolete` + | `partial` + | `old-other` + | `new-other` + rank: number + route: `ordered` | `other` + } + type Outcome = { + hasMore: boolean + appliedRowKeys: ReadonlyArray + } + type PendingReplay = { + options: LoadSubsetOptions + deferred: ReturnType> + } + type PendingAttempt = { + publicationId: string + acquisitions: ReadonlyArray + ordered: PendingReplay + } + + const initialRows: ReadonlyArray = + scenario.initialPublication === `empty` + ? [] + : [ + { id: `old-a`, rank: 1, route: `ordered` }, + { id: `old-b`, rank: 2, route: `ordered` }, + ] + const replacementRows: ReadonlyArray = [ + { id: `new-a`, rank: 1, route: `ordered` }, + { id: `new-b`, rank: 2, route: `ordered` }, + ] + const sourceDelta: Row = { + id: `delta`, + rank: scenario.direction === `asc` ? 0 : 3, + route: `ordered`, + } + const continuationRow: Row = { + id: `tail`, + rank: scenario.direction === `asc` ? 3 : 0, + route: `ordered`, + } + const obsoleteRow: Row = { + id: `obsolete`, + rank: scenario.direction === `asc` ? -1 : 4, + route: `ordered`, + } + const partialRow: Row = { + id: `partial`, + rank: scenario.direction === `asc` ? -2 : 5, + route: `ordered`, + } + const initialOtherRow: Row = { + id: `old-other`, + rank: scenario.direction === `asc` ? 100 : -100, + route: `other`, + } + const initialOtherRows = + scenario.initialPublication === `empty` ? [] : [initialOtherRow] + const replacementOtherRow: Row = { + id: `new-other`, + rank: scenario.direction === `asc` ? 101 : -101, + route: `other`, + } + const orderRows = (rows: ReadonlyArray) => + [...rows].sort((left, right) => { + const valueOrder = + scenario.direction === `asc` + ? left.rank - right.rank + : right.rank - left.rank + return valueOrder || left.id.localeCompare(right.id) + }) + const toModelRows = (rows: ReadonlyArray) => + rows.map(({ id: key, rank: orderValue }) => ({ key, orderValue })) + + let begin!: () => void + let write!: (message: { type: `insert`; value: Row }) => void + let commit!: () => true | Promise + let truncate!: () => void + let initialOrderedLoad = true + let initialOtherLoad = true + let replacementSequence = 0 + let unsubscribed = false + const pending: Array = [] + const history: Array = [ + { + type: `stagePublicationRows`, + publicationId: `initial`, + demandId: `ordered`, + rows: toModelRows(initialRows), + }, + { type: `commitPublication`, publicationId: `initial` }, + ] + + const applyRows = async (rows: ReadonlyArray) => { + begin() + for (const row of rows) write({ type: `insert`, value: row }) + const receipt = commit() + if (receipt !== true) await receipt + } + + const collection = createCollection({ + id: `atomic-ordered-replay-${atomicReplayHarnessId++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + truncate = params.truncate + params.markReady() + return { + loadSubset: (options) => { + if (initialOrderedLoad && options.orderBy) { + initialOrderedLoad = false + return applyRows(initialRows).then(() => ({ + hasMore: false, + appliedRowKeys: initialRows.map(({ id }) => id), + })) + } + if (initialOtherLoad && !options.orderBy) { + initialOtherLoad = false + return applyRows(initialOtherRows).then(() => ({ + hasMore: false, + appliedRowKeys: initialOtherRows.map(({ id }) => id), + })) + } + const deferred = createDeferred() + pending.push({ options, deferred }) + return deferred.promise + }, + unloadSubset: () => {}, + } + }, + }, + }) + const index = collection.createIndex((row) => row.rank, { + indexType: BTreeIndex, + }) + const orderedIndex = + scenario.direction === `asc` ? index : new ReverseIndex(index) + const orderBy = [ + { + expression: new PropRef([`rank`]), + compareOptions: { + direction: scenario.direction, + nulls: `first` as const, + }, + }, + ] + const callerContinuation = scenario.callerContinuation ?? `min-values` + const callerContinuationOptions = { + ...(callerContinuation === `min-values` || callerContinuation === `both` + ? { minValues: [scenario.direction === `asc` ? 0 : 3] } + : {}), + ...(callerContinuation === `offset` || callerContinuation === `both` + ? { offset: 1 } + : {}), + } + const initialWindowSize = + callerContinuation === `offset` || callerContinuation === `both` ? 2 : 1 + const otherWhere = new Func(`eq`, [ + new PropRef([`route`]), + new Value(`other`), + ]) + const visible = new Map() + const publications: Array< + ReadonlyArray<{ key: string; orderValue: number }> + > = [] + const subscription = collection.subscribeChanges((changes) => { + // The projection models semantic publications. requestSnapshot may invoke + // the callback with an empty transport batch, which cannot change readers. + if (changes.length === 0) return + for (const change of changes) { + const key = change.key as Row[`id`] + if (change.type === `delete`) visible.delete(key) + else visible.set(key, change.value) + } + publications.push(toModelRows(orderRows([...visible.values()]))) + }) + subscription.setOrderByIndex(orderedIndex) + + const expectedPublicationProjection = () => + projectAtomicOrderedPublicationState(history, { + demandId: `ordered`, + direction: scenario.direction, + initialWindowSize, + }) + const expectedPublications = () => + projectAtomicOrderedPublications(history, { + demandId: `ordered`, + direction: scenario.direction, + initialWindowSize, + }) + const expectPublicationHistory = () => { + const projection = expectedPublicationProjection() + const expected = projection.publications + expect(publications).toEqual(expected) + if (unsubscribed) return + + // Normal progress may move past the visible prefix. During replacement or + // after replay failure, however, continuation state belongs to the exact + // retained publication. Assert its optional boundary, including the empty + // publication's meaningful `undefined` value. + if (projection.retainsPreviousPublication) { + expect(subscription.orderedBoundaryKey).toBe( + projection.currentPublication?.orderedBoundary?.key, + ) + } + } + const beginReplacement = async () => { + const pendingStart = pending.length + begin() + truncate() + const receipt = commit() + if (receipt !== true) await receipt + await flushPromises() + const acquisitions = pending.slice(pendingStart) + const ordered = acquisitions.find(({ options }) => options.orderBy) + if (!ordered) throw new Error(`Expected an ordered replacement acquisition`) + expect(ordered.options.offset).toBe(0) + expect(ordered.options.cursor).toBeUndefined() + const publicationId = `replacement-${replacementSequence++}` + history.push({ + type: `beginReplacement`, + publicationId, + demandIds: acquisitions.map((acquisition) => + acquisition === ordered ? `ordered` : `other`, + ), + }) + expectPublicationHistory() + return { publicationId, acquisitions, ordered } satisfies PendingAttempt + } + const settle = async ( + replay: PendingAttempt, + outcome: `success` | `failure` | `abort`, + rows: ReadonlyArray, + extent: `exhausted` | `continues` = `exhausted`, + otherOutcome: `success` | `failure` = outcome === `success` + ? `success` + : `failure`, + demandOrder: `ordered-first` | `other-first` = `ordered-first`, + releaseOtherAfterOrdered = false, + appliedOrderedRowKeys: ReadonlyArray = replacementRows.map( + ({ id }) => id, + ), + stageEmptyRows = false, + ) => { + if (rows.length > 0) await applyRows(rows) + if (rows.length > 0 || stageEmptyRows) { + history.push({ + type: `stagePublicationRows`, + publicationId: replay.publicationId, + demandId: `ordered`, + rows: toModelRows(rows), + }) + expectPublicationHistory() + } + const acquisitions = [...replay.acquisitions].sort((left, right) => { + const leftOrdered = left === replay.ordered + const rightOrdered = right === replay.ordered + if (leftOrdered === rightOrdered) return 0 + const orderedFirst = demandOrder === `ordered-first` + return leftOrdered === orderedFirst ? -1 : 1 + }) + for (const acquisition of acquisitions) { + const isOrdered = acquisition === replay.ordered + const demandId = isOrdered ? `ordered` : `other` + const desiredOutcome = isOrdered ? outcome : otherOutcome + const aborted = acquisition.options.signal?.aborted ?? false + const settledOutcome = aborted ? `abort` : desiredOutcome + if (settledOutcome === `success`) { + acquisition.deferred.resolve({ + hasMore: isOrdered ? extent === `continues` : false, + appliedRowKeys: isOrdered + ? appliedOrderedRowKeys + : [replacementOtherRow.id], + }) + } else { + const error = new Error( + settledOutcome === `abort` + ? `obsolete replay aborted` + : `replay failed`, + ) + if (settledOutcome === `abort`) error.name = `AbortError` + acquisition.deferred.reject(error) + } + history.push( + settledOutcome === `success` + ? { + type: `settleReplacement`, + publicationId: replay.publicationId, + demandId, + outcome: settledOutcome, + extent: isOrdered ? extent : `exhausted`, + } + : { + type: `settleReplacement`, + publicationId: replay.publicationId, + demandId, + outcome: settledOutcome, + }, + ) + await flushPromises() + expectPublicationHistory() + + if (isOrdered && releaseOtherAfterOrdered) { + subscription.releaseSnapshot(otherWhere) + const released = replay.acquisitions.find( + (candidate) => candidate !== replay.ordered, + ) + expect(released?.options.signal?.aborted).toBe(true) + history.push({ + type: `releaseDemand`, + ownerId: `other-owner`, + demandId: `other`, + rowKeys: [replacementOtherRow.id], + finalRowOwner: true, + invalidatesAdapterEvidence: true, + }) + expectPublicationHistory() + } + } + } + + try { + subscription.requestLimitedSnapshot({ + orderBy, + limit: 1, + ...callerContinuationOptions, + }) + await flushPromises() + expectPublicationHistory() + + if (scenario.otherDemand !== `none`) { + history.push({ + type: `requestDemand`, + ownerId: `other-owner`, + sessionId: `atomic-session`, + demandId: `other`, + alreadyAborted: false, + }) + subscription.requestSnapshot({ where: otherWhere }) + await flushPromises() + history.push( + { + type: `stagePublicationRows`, + publicationId: `initial`, + demandId: `other`, + rows: toModelRows(initialOtherRows), + }, + { type: `commitPublication`, publicationId: `initial` }, + ) + expectPublicationHistory() + } + + const firstReplay = await beginReplacement() + if (scenario.overlap) { + await applyRows([obsoleteRow]) + history.push({ + type: `stagePublicationRows`, + publicationId: firstReplay.publicationId, + demandId: `ordered`, + rows: toModelRows([obsoleteRow]), + }) + expectPublicationHistory() + } + const currentReplay = scenario.overlap + ? await beginReplacement() + : firstReplay + if (scenario.overlap) { + expect( + firstReplay.acquisitions.every( + ({ options }) => options.signal?.aborted, + ), + ).toBe(true) + } + + const resizeSizes = + scenario.resizeOrder === `grow-shrink` + ? ([2, 0] as const) + : ([0, 2] as const) + for (const size of resizeSizes) { + history.push({ type: `resizeOrderedWindow`, size }) + subscription.ensureOrderedWindowSize(size) + expectPublicationHistory() + } + + if (scenario.otherDemand !== `none`) { + await applyRows([replacementOtherRow]) + history.push({ + type: `stagePublicationRows`, + publicationId: currentReplay.publicationId, + demandId: `other`, + rows: toModelRows([replacementOtherRow]), + }) + expectPublicationHistory() + if (scenario.otherDemand === `released`) { + subscription.releaseSnapshot(otherWhere) + history.push({ + type: `releaseDemand`, + ownerId: `other-owner`, + demandId: `other`, + rowKeys: [replacementOtherRow.id], + finalRowOwner: true, + invalidatesAdapterEvidence: true, + }) + expectPublicationHistory() + } + } + + if (scenario.sourceDelta) { + await applyRows([sourceDelta]) + history.push({ + type: `stagePublicationRows`, + publicationId: currentReplay.publicationId, + demandId: `ordered`, + rows: toModelRows([sourceDelta]), + }) + expectPublicationHistory() + } + + if (scenario.terminal === `unsubscribe`) { + await applyRows([partialRow]) + history.push({ + type: `stagePublicationRows`, + publicationId: currentReplay.publicationId, + demandId: `ordered`, + rows: toModelRows([partialRow]), + }) + expectPublicationHistory() + subscription.unsubscribe() + unsubscribed = true + history.push({ type: `cleanupSession`, sessionId: `atomic-session` }) + expectPublicationHistory() + expect( + currentReplay.acquisitions.every( + ({ options }) => options.signal?.aborted, + ), + ).toBe(true) + await applyRows([continuationRow]) + history.push({ + type: `stagePublicationRows`, + publicationId: currentReplay.publicationId, + demandId: `ordered`, + rows: toModelRows([partialRow, continuationRow]), + }) + expectPublicationHistory() + await settle(currentReplay, `abort`, []) + if (scenario.overlap) await settle(firstReplay, `abort`, []) + expectPublicationHistory() + return + } + + const hasEmptyContinuingReplay = + scenario.emptyContinuingReplay === true && + scenario.currentOutcome === `resolve` && + scenario.currentExtent === `continues` && + scenario.sourceDelta === false && + scenario.otherDemand === `none` + const finalRows = hasEmptyContinuingReplay + ? [] + : [...replacementRows, ...(scenario.sourceDelta ? [sourceDelta] : [])] + const partialFailureRows: ReadonlyArray = [ + { + id: `new-a`, + rank: scenario.direction === `asc` ? 99 : -99, + route: `ordered`, + }, + ] + const settleCurrent = () => + settle( + currentReplay, + scenario.currentOutcome === `resolve` ? `success` : `failure`, + scenario.currentOutcome === `resolve` ? finalRows : partialFailureRows, + scenario.currentExtent, + scenario.otherOutcome === `resolve` + ? `success` + : scenario.otherOutcome === `reject` + ? `failure` + : scenario.currentOutcome === `resolve` + ? `success` + : `failure`, + scenario.demandSettlementOrder, + scenario.releaseAfterOrdered, + hasEmptyContinuingReplay ? [] : replacementRows.map(({ id }) => id), + hasEmptyContinuingReplay, + ) + const settleObsolete = () => settle(firstReplay, `abort`, []) + + if (!scenario.overlap) { + await settleCurrent() + } else if (scenario.settleCurrentFirst) { + await settleCurrent() + await settleObsolete() + } else { + await settleObsolete() + await settleCurrent() + } + + if ( + scenario.currentOutcome === `resolve` && + scenario.currentExtent === `continues` + ) { + if (!hasEmptyContinuingReplay) { + await applyRows([continuationRow]) + history.push({ + type: `stagePublicationRows`, + publicationId: currentReplay.publicationId, + demandId: `ordered`, + rows: toModelRows([...finalRows, continuationRow]), + }) + expectPublicationHistory() + } + subscription.requestLimitedSnapshot({ + orderBy, + limit: 2, + trackLoadSubsetPromise: false, + ...callerContinuationOptions, + }) + await flushPromises() + const continuation = pending.at(-1) + if (!continuation || continuation === currentReplay.ordered) { + throw new Error(`Expected an ordered continuation acquisition`) + } + if (hasEmptyContinuingReplay) { + expect(continuation.options.offset).toBe(0) + expect(continuation.options.cursor).toBeUndefined() + expect(subscription.orderedRetainedWindowSize).toBe(2) + expectPublicationHistory() + return + } + const expectedPrivateBoundary = orderRows(finalRows).slice(0, 2).at(-1)! + // Applied-but-unrefined rows establish a private cursor, not an admitted + // local prefix, so offset remains zero until refinement settles. + expect(continuation.options.offset).toBe(0) + expect(continuation.options.cursor?.lastKey).toBe( + expectedPrivateBoundary.id, + ) + expect(continuation.options.cursor?.whereCurrent).toBeDefined() + expect(continuation.options.cursor?.whereFrom).toBeDefined() + expect( + evaluateReferenceExpression(continuation.options.cursor!.whereCurrent, { + rank: expectedPrivateBoundary.rank, + }), + ).toBe(true) + expect( + evaluateReferenceExpression(continuation.options.cursor!.whereCurrent, { + rank: scenario.direction === `asc` ? 0 : 3, + }), + ).toBe(false) + expect( + evaluateReferenceExpression(continuation.options.cursor!.whereFrom, { + rank: + expectedPrivateBoundary.rank + + (scenario.direction === `asc` ? 1 : -1), + }), + ).toBe(true) + expect( + evaluateReferenceExpression(continuation.options.cursor!.whereFrom, { + rank: expectedPrivateBoundary.rank, + }), + ).toBe(false) + expect( + evaluateReferenceExpression(continuation.options.cursor!.whereFrom, { + rank: scenario.direction === `asc` ? 0 : 3, + }), + ).toBe(false) + continuation.deferred.resolve({ + hasMore: true, + appliedRowKeys: [continuationRow.id], + }) + history.push({ + type: `establishReplacementCoverage`, + publicationId: currentReplay.publicationId, + }) + await flushPromises() + expectPublicationHistory() + } + + const finalProjection = expectedPublicationProjection() + if (finalProjection.retainsPreviousPublication) { + const pendingStart = pending.length + subscription.requestLimitedSnapshot({ + orderBy, + limit: 1, + ...callerContinuationOptions, + trackLoadSubsetPromise: false, + }) + await flushPromises() + const restoration = pending[pendingStart] + if (!restoration) { + throw new Error(`Expected a retained-publication restoration request`) + } + expect(restoration.options.offset).toBe( + finalProjection.currentPublication?.orderedPrefixSize ?? 0, + ) + expect(subscription.orderedRetainedWindowSize).toBe( + Math.max( + 2, + (finalProjection.currentPublication?.orderedPrefixSize ?? 0) + 1, + ), + ) + const expectedBoundary = + finalProjection.currentPublication?.orderedBoundary + if (expectedBoundary === undefined) { + expect(restoration.options.cursor).toBeUndefined() + } else { + expect(restoration.options.cursor).toBeDefined() + expect(restoration.options.cursor?.lastKey).toBe(expectedBoundary.key) + expect( + evaluateReferenceExpression( + restoration.options.cursor!.whereCurrent, + { rank: expectedBoundary.orderValue }, + ), + ).toBe(true) + expect( + evaluateReferenceExpression( + restoration.options.cursor!.whereCurrent, + { rank: scenario.direction === `asc` ? 0 : 3 }, + ), + ).toBe(false) + expect( + evaluateReferenceExpression(restoration.options.cursor!.whereFrom, { + rank: + expectedBoundary.orderValue + + (scenario.direction === `asc` ? 1 : -1), + }), + ).toBe(true) + expect( + evaluateReferenceExpression(restoration.options.cursor!.whereFrom, { + rank: expectedBoundary.orderValue, + }), + ).toBe(false) + expect( + evaluateReferenceExpression(restoration.options.cursor!.whereFrom, { + rank: scenario.direction === `asc` ? 0 : 3, + }), + ).toBe(false) + } + } + + const expectedKeys = expectedPublications().map((rows) => + rows.map(({ key }) => key), + ) + expect(publications.map((rows) => rows.map(({ key }) => key))).toEqual( + expectedKeys, + ) + expect(publications).toHaveLength(expectedPublications().length) + } finally { + for (const replay of pending) + replay.deferred.resolve({ + hasMore: false, + appliedRowKeys: [], + }) + await flushPromises() + if (!unsubscribed) subscription.unsubscribe() + await collection.cleanup() + } +} + +const mixedDemandSettlementScenarios: ReadonlyArray = + ([`asc`, `desc`] as const).flatMap((direction) => [ + ...([`ordered-first`, `other-first`] as const).flatMap( + (demandSettlementOrder) => [ + { + direction, + resizeOrder: `grow-shrink` as const, + overlap: false, + currentOutcome: `resolve` as const, + currentExtent: `exhausted` as const, + settleCurrentFirst: false, + sourceDelta: false, + otherDemand: `active` as const, + otherOutcome: `reject` as const, + demandSettlementOrder, + }, + { + direction, + resizeOrder: `grow-shrink` as const, + overlap: false, + currentOutcome: `reject` as const, + currentExtent: `exhausted` as const, + settleCurrentFirst: false, + sourceDelta: false, + otherDemand: `active` as const, + otherOutcome: `resolve` as const, + demandSettlementOrder, + }, + ], + ), + { + direction, + resizeOrder: `grow-shrink` as const, + overlap: false, + currentOutcome: `resolve` as const, + currentExtent: `exhausted` as const, + settleCurrentFirst: false, + sourceDelta: false, + otherDemand: `active` as const, + otherOutcome: `reject` as const, + demandSettlementOrder: `ordered-first` as const, + releaseAfterOrdered: true, + }, + ]) + +it(`does not reuse caller or public continuation state when an active replacement has no progress`, async () => { + for (const direction of [`asc`, `desc`] as const) { + for (const callerContinuation of [ + `none`, + `min-values`, + `offset`, + `both`, + ] as const) { + await runAtomicOrderedReplayScenario({ + direction, + initialPublication: `nonempty`, + callerContinuation, + resizeOrder: `grow-shrink`, + overlap: false, + currentOutcome: `resolve`, + currentExtent: `continues`, + emptyContinuingReplay: true, + settleCurrentFirst: false, + sourceDelta: false, + otherDemand: `none`, + }) + } + } +}) + +it(`uses only private boundary semantics when an active replacement has progress`, async () => { + for (const direction of [`asc`, `desc`] as const) { + for (const callerContinuation of [`min-values`, `both`] as const) { + await runAtomicOrderedReplayScenario({ + direction, + initialPublication: `nonempty`, + callerContinuation, + resizeOrder: `shrink-grow`, + overlap: false, + currentOutcome: `resolve`, + currentExtent: `continues`, + settleCurrentFirst: false, + sourceDelta: false, + otherDemand: `none`, + }) + } + } +}) + +it(`restores failed replay continuation only from the last complete publication`, async () => { + for (const direction of [`asc`, `desc`] as const) { + for (const initialPublication of [`empty`, `nonempty`] as const) { + for (const callerContinuation of [ + `none`, + `min-values`, + `offset`, + `both`, + ] as const) { + await runAtomicOrderedReplayScenario({ + direction, + initialPublication, + callerContinuation, + resizeOrder: `grow-shrink`, + overlap: false, + currentOutcome: `reject`, + currentExtent: `continues`, + settleCurrentFirst: false, + sourceDelta: false, + otherDemand: `none`, + }) + } + } + } +}) + +it(`keeps mixed demand settlements inside one replacement epoch`, async () => { + for (const scenario of mixedDemandSettlementScenarios) { + await runAtomicOrderedReplayScenario(scenario) + } +}) + +it(`discards pending replacement epochs on teardown`, async () => { + for (const direction of [`asc`, `desc`] as const) { + for (const overlap of [false, true]) { + await runAtomicOrderedReplayScenario({ + direction, + resizeOrder: `grow-shrink`, + overlap, + currentOutcome: `resolve`, + currentExtent: `exhausted`, + settleCurrentFirst: false, + sourceDelta: false, + otherDemand: `none`, + terminal: `unsubscribe`, + }) + } + } +}) + +it(`keeps ordered replacement publication atomic across every bounded history`, async () => { + for (const scenario of exhaustiveAtomicOrderedReplayScenarios) { + await runAtomicOrderedReplayScenario(scenario) + } +}, 30_000) + +fcTest.prop([atomicOrderedReplayArbitrary], { + numRuns: 32 * fullFlowMultiplier, + seed: 17781, +})( + `keeps ordered replacement publication atomic for a fixed seed`, + runAtomicOrderedReplayScenario, +) + +fcTest.prop( + [atomicOrderedReplayArbitrary], + oracleRandomParameters(32 * fullFlowMultiplier, fullFlowReplaySeed), +)( + `keeps ordered replacement publication atomic for a random or replayed seed`, + runAtomicOrderedReplayScenario, +) it(`matches the truncate evidence model across every bounded settlement history`, async () => { for (const scenario of exhaustiveTruncateCoverageScenarios) { @@ -449,13 +3200,13 @@ it(`matches the truncate evidence model across every bounded settlement history` }) fcTest.prop([truncateCoverageScenarioArbitrary], { - numRuns: 12 * truncateMultiplier, + numRuns: 12 * fullFlowMultiplier, seed: 1774, })(`fences pre-truncate evidence for a fixed seed`, runTruncateCoverageScenario) fcTest.prop( [truncateCoverageScenarioArbitrary], - oracleRandomParameters(12 * truncateMultiplier, truncateReplaySeed), + oracleRandomParameters(12 * fullFlowMultiplier, fullFlowReplaySeed), )( `fences pre-truncate evidence for a random or replayed seed`, runTruncateCoverageScenario, diff --git a/packages/db/tests/query/load-subset-subquery.test.ts b/packages/db/tests/query/load-subset-subquery.test.ts index 3f6eee13b..2888753c3 100644 --- a/packages/db/tests/query/load-subset-subquery.test.ts +++ b/packages/db/tests/query/load-subset-subquery.test.ts @@ -337,7 +337,11 @@ describe(`loadSubset with subqueries`, () => { const expectedOrderBy: OrderBy = [ { expression: new PropRef([`scheduled_at`]), - compareOptions: { direction: `desc`, nulls: `first` }, + compareOptions: { + direction: `desc`, + nulls: `first`, + stringSort: `locale`, + }, }, ] @@ -378,7 +382,11 @@ describe(`loadSubset with subqueries`, () => { const expectedOrderBy: OrderBy = [ { expression: new PropRef([`scheduled_at`]), - compareOptions: { direction: `desc`, nulls: `first` }, + compareOptions: { + direction: `desc`, + nulls: `first`, + stringSort: `locale`, + }, }, ] diff --git a/packages/db/tests/query/order-by.test.ts b/packages/db/tests/query/order-by.test.ts index 9b48805eb..1e5ea665f 100644 --- a/packages/db/tests/query/order-by.test.ts +++ b/packages/db/tests/query/order-by.test.ts @@ -1923,6 +1923,7 @@ function createOrderByTests(autoIndex: `off` | `eager`): void { eq(employees.department_id, departments.id), ) .orderBy(({ departments }) => departments.name, `asc`) + .orderBy(({ employees }) => employees.salary, `desc`) .limit(5) .select(({ employees, departments }) => ({ employeeId: employees.id, @@ -1948,6 +1949,12 @@ function createOrderByTests(autoIndex: `off` | `eager`): void { expect(orderByInfo.sourceId).toBe(orderedSource.sourceId) expect(orderByInfo.offset).toBe(0) expect(orderByInfo.limit).toBe(5) + expect( + orderByInfo.orderBy.map( + (clause: { expression: { path: Array } }) => + clause.expression.path, + ), + ).toEqual([[`departments`, `name`]]) } finally { CollectionConfigBuilder.prototype.getConfig = originalGetConfig } @@ -2774,7 +2781,10 @@ describe(`OrderBy with duplicate values`, () => { loadSubsetCursors.push(options.cursor) // Simulate async loading from remote source - return new Promise((resolve) => { + return new Promise<{ + hasMore: boolean + appliedRowKeys: Array + }>((resolve) => { setTimeout(() => { begin() @@ -2798,13 +2808,16 @@ describe(`OrderBy with duplicate values`, () => { } } + const { limit } = options + let hasMore = + limit !== undefined && filteredData.length > limit + // Apply cursor expressions if present (cursor-based pagination) // For proper cursor-based pagination: // - whereCurrent should load ALL ties (no limit) // - whereFrom should load with remaining limit if (options.cursor) { const { whereFrom, whereCurrent } = options.cursor - const { limit } = options try { // Get ALL rows matching whereCurrent (no limit for ties) const whereCurrentFn = @@ -2816,6 +2829,8 @@ describe(`OrderBy with duplicate values`, () => { const whereFromFn = createFilterFunctionFromExpression(whereFrom) const fromData = filteredData.filter(whereFromFn) + hasMore = + limit !== undefined && fromData.length > limit const limitedFromData = limit ? fromData.slice(0, limit) : fromData @@ -2844,7 +2859,6 @@ describe(`OrderBy with duplicate values`, () => { // Apply limit for initial page load (no cursor). // When cursor is present, limit was already applied in the cursor block above. - const { limit } = options const dataToLoad = limit && !options.cursor ? filteredData.slice(0, limit) @@ -2858,7 +2872,10 @@ describe(`OrderBy with duplicate values`, () => { }) commit() - resolve() + resolve({ + hasMore, + appliedRowKeys: dataToLoad.map(({ id }) => id), + }) }, 10) // Small delay to simulate network }) }, @@ -2895,9 +2912,12 @@ describe(`OrderBy with duplicate values`, () => { { id: 4, a: 4, keep: true }, { id: 5, a: 5, keep: true }, ]) - expect(loadSubsetCallCount).toBe(1) - // First loadSubset call (initial page at offset 0) has no cursor + expect(loadSubsetCallCount).toBe(2) + // Local rows do not prove source coverage. The first request acquires + // the prefix; the second expands its complete boundary class so the + // public-key tie-break is safe. expect(loadSubsetCursors[0]).toBeUndefined() + expect(loadSubsetCursors[1]).toMatchObject({ lastKey: 5 }) // Now move to next page (offset 5, limit 5) - this should trigger loadSubset with a cursor const moveToSecondPage = collection.utils.setWindow({ @@ -2919,11 +2939,11 @@ describe(`OrderBy with duplicate values`, () => { { id: 10, a: 5, keep: true }, ]) // we expect 1 new loadSubset call (cursor expressions for whereFrom/whereCurrent are now combined in single call) - expect(loadSubsetCallCount).toBe(2) + expect(loadSubsetCallCount).toBe(3) // Second loadSubset call (pagination) has a cursor with whereFrom and whereCurrent - expect(loadSubsetCursors[1]).toBeDefined() - expect(loadSubsetCursors[1]).toHaveProperty(`whereFrom`) - expect(loadSubsetCursors[1]).toHaveProperty(`whereCurrent`) + expect(loadSubsetCursors[2]).toBeDefined() + expect(loadSubsetCursors[2]).toHaveProperty(`whereFrom`) + expect(loadSubsetCursors[2]).toHaveProperty(`whereCurrent`) // Now move to third page (offset 10, limit 5) // It should advance past the duplicate 5s @@ -2953,7 +2973,7 @@ describe(`OrderBy with duplicate values`, () => { // We expect no more loadSubset calls because when we loaded the previous page // we asked for all data equal to max value and LIMIT values greater than max value // and the LIMIT values greater than max value already loaded the next page - expect(loadSubsetCallCount).toBe(2) + expect(loadSubsetCallCount).toBe(3) }) it(`should correctly advance window when there are duplicate values loaded from both local collection and sync layer`, async () => { @@ -3010,7 +3030,10 @@ describe(`OrderBy with duplicate values`, () => { loadSubsetCursors.push(options.cursor) // Simulate async loading from remote source - return new Promise((resolve) => { + return new Promise<{ + hasMore: boolean + appliedRowKeys: Array + }>((resolve) => { setTimeout(() => { begin() @@ -3034,13 +3057,16 @@ describe(`OrderBy with duplicate values`, () => { } } + const { limit } = options + let hasMore = + limit !== undefined && filteredData.length > limit + // Apply cursor expressions if present (cursor-based pagination) // For proper cursor-based pagination: // - whereCurrent should load ALL ties (no limit) // - whereFrom should load with remaining limit if (options.cursor) { const { whereFrom, whereCurrent } = options.cursor - const { limit } = options try { // Get ALL rows matching whereCurrent (no limit for ties) const whereCurrentFn = @@ -3052,6 +3078,8 @@ describe(`OrderBy with duplicate values`, () => { const whereFromFn = createFilterFunctionFromExpression(whereFrom) const fromData = filteredData.filter(whereFromFn) + hasMore = + limit !== undefined && fromData.length > limit const limitedFromData = limit ? fromData.slice(0, limit) : fromData @@ -3080,7 +3108,6 @@ describe(`OrderBy with duplicate values`, () => { // Apply limit for initial page load (no cursor). // When cursor is present, limit was already applied in the cursor block above. - const { limit } = options const dataToLoad = limit && !options.cursor ? filteredData.slice(0, limit) @@ -3094,7 +3121,10 @@ describe(`OrderBy with duplicate values`, () => { }) commit() - resolve() + resolve({ + hasMore, + appliedRowKeys: dataToLoad.map(({ id }) => id), + }) }, 10) // Small delay to simulate network }) }, @@ -3131,9 +3161,12 @@ describe(`OrderBy with duplicate values`, () => { { id: 4, a: 4, keep: true }, { id: 5, a: 5, keep: true }, ]) - expect(loadSubsetCallCount).toBe(1) - // First loadSubset call (initial page at offset 0) has no cursor + expect(loadSubsetCallCount).toBe(2) + // Local rows do not prove source coverage. The first request acquires + // the prefix; the second expands its complete boundary class so the + // public-key tie-break is safe. expect(loadSubsetCursors[0]).toBeUndefined() + expect(loadSubsetCursors[1]).toMatchObject({ lastKey: 5 }) // Now move to next page (offset 5, limit 5) - this should trigger loadSubset with a cursor const moveToSecondPage = collection.utils.setWindow({ @@ -3155,11 +3188,11 @@ describe(`OrderBy with duplicate values`, () => { { id: 10, a: 5, keep: true }, ]) // we expect 1 new loadSubset call (cursor expressions for whereFrom/whereCurrent are now combined in single call) - expect(loadSubsetCallCount).toBe(2) + expect(loadSubsetCallCount).toBe(3) // Second loadSubset call (pagination) has a cursor with whereFrom and whereCurrent - expect(loadSubsetCursors[1]).toBeDefined() - expect(loadSubsetCursors[1]).toHaveProperty(`whereFrom`) - expect(loadSubsetCursors[1]).toHaveProperty(`whereCurrent`) + expect(loadSubsetCursors[2]).toBeDefined() + expect(loadSubsetCursors[2]).toHaveProperty(`whereFrom`) + expect(loadSubsetCursors[2]).toHaveProperty(`whereCurrent`) // Now move to third page (offset 10, limit 5) // It should advance past the duplicate 5s @@ -3189,7 +3222,7 @@ describe(`OrderBy with duplicate values`, () => { // We expect no more loadSubset calls because when we loaded the previous page // we asked for all data equal to max value and LIMIT values greater than max value // and the LIMIT values greater than max value already loaded the next page - expect(loadSubsetCallCount).toBe(2) + expect(loadSubsetCallCount).toBe(3) }) }) } @@ -3258,7 +3291,10 @@ describe(`OrderBy with Date values and precision differences`, () => { // Capture the cursor for inspection (now contains whereFrom/whereCurrent/lastKey) loadSubsetCursors.push(options.cursor) - return new Promise((resolve) => { + return new Promise<{ + hasMore: boolean + appliedRowKeys: Array + }>((resolve) => { setTimeout(() => { begin() const sortedData = [...testData].sort( @@ -3277,6 +3313,10 @@ describe(`OrderBy with Date values and precision differences`, () => { } } + const { limit } = options + let hasMore = + limit !== undefined && filteredData.length > limit + // Apply cursor expressions if present if (options.cursor) { const { whereFrom, whereCurrent } = options.cursor @@ -3284,6 +3324,11 @@ describe(`OrderBy with Date values and precision differences`, () => { const whereFromFn = createFilterFunctionFromExpression(whereFrom) const fromData = filteredData.filter(whereFromFn) + hasMore = limit !== undefined && fromData.length > limit + const limitedFromData = + limit === undefined + ? fromData + : fromData.slice(0, limit) const whereCurrentFn = createFilterFunctionFromExpression(whereCurrent) @@ -3298,7 +3343,7 @@ describe(`OrderBy with Date values and precision differences`, () => { filteredData.push(item) } } - for (const item of fromData) { + for (const item of limitedFromData) { if (!seenIds.has(item.id)) { seenIds.add(item.id) filteredData.push(item) @@ -3313,17 +3358,20 @@ describe(`OrderBy with Date values and precision differences`, () => { } } - const { limit } = options - const dataToLoad = limit - ? filteredData.slice(0, limit) - : filteredData + const dataToLoad = + limit !== undefined && !options.cursor + ? filteredData.slice(0, limit) + : filteredData dataToLoad.forEach((item) => { write({ type: `insert`, value: item }) }) commit() - resolve() + resolve({ + hasMore, + appliedRowKeys: dataToLoad.map(({ id }) => id), + }) }, 10) }) }, @@ -3352,9 +3400,6 @@ describe(`OrderBy with Date values and precision differences`, () => { const results = Array.from(collection.values()).sort((a, b) => a.id - b.id) expect(results.map((r) => r.id)).toEqual([1, 2, 3, 4, 5]) - // Clear tracked cursors before moving to next page - loadSubsetCursors.length = 0 - // Move to next page - this should trigger the Date precision handling const moveToSecondPage = collection.utils.setWindow({ offset: 5, limit: 5 }) await moveToSecondPage @@ -3389,3 +3434,31 @@ describe(`OrderBy with Date values and precision differences`, () => { expect(ltValue.getTime() - gteValue.getTime()).toBe(1) // 1ms difference }) }) + +it(`uses the public key as a total tie-breaker when one key is NaN`, async () => { + const source = createCollection( + mockSyncCollectionOptions({ + id: `nan-public-key-order`, + getKey: (row: { id: number; rank: number; label: string }) => row.id, + initialData: [ + { id: Number.NaN, rank: 0, label: `NaN` }, + { id: 1, rank: 0, label: `finite` }, + ], + autoIndex: `eager`, + }), + ) + const live = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank, `asc`) + .limit(1), + ) + + try { + await live.preload() + expect(Array.from(live.values(), ({ label }) => label)).toEqual([`finite`]) + } finally { + await live.cleanup() + await source.cleanup() + } +}) diff --git a/packages/db/tests/query/pagination-oracle.property.test.ts b/packages/db/tests/query/pagination-oracle.property.test.ts index 7b878a5f4..a78e13156 100644 --- a/packages/db/tests/query/pagination-oracle.property.test.ts +++ b/packages/db/tests/query/pagination-oracle.property.test.ts @@ -5,7 +5,8 @@ import { createDeferred } from '../../src/deferred.js' import { BTreeIndex } from '../../src/index.js' import { createLiveQueryCollection } from '../../src/query/live-query-collection.js' import { PropRef } from '../../src/query/ir.js' -import { expectAssertionFailure } from '../expected-failure.js' +import { DeduplicatedLoadSubset } from '../../src/query/subset-dedupe.js' +import { makeComparator } from '../../src/utils/comparison.js' import { oracleRandomParameters, readOracleRunConfig, @@ -13,7 +14,7 @@ import { import { evaluateReferenceExpression } from '../reference-expression.js' import { TraceAssertionError } from '../trace-runner.js' import { flushPromises, mockSyncCollectionOptions } from '../utils.js' -import type { LoadSubsetOptions } from '../../src/types.js' +import type { LoadSubsetOptions, LoadSubsetResult } from '../../src/types.js' type PageRow = { id: number @@ -43,6 +44,17 @@ type NullableCursorRow = { rank: number | null } +type LocaleCursorRow = { + id: number + label: string +} + +type AdversarialOrderedRow = { + id: number + rank: number | null | object + label: string +} + type NullableCursorScenario = { rank: number direction: `asc` | `desc` @@ -367,6 +379,27 @@ function rowsForLoadSubset( return [...requested.values()] } +function withAppliedSubsetEvidence( + rows: () => ReadonlyArray, + options: LoadSubsetOptions, + settled: Promise, +) { + return settled.then(() => { + const authoritative = rows() + const requested = rowsForLoadSubset(authoritative, options) + const hasMore = options.cursor + ? authoritative.filter((row) => + Boolean(evaluateReferenceExpression(options.cursor!.whereFrom, row)), + ).length > (options.limit ?? Number.POSITIVE_INFINITY) + : authoritative.length > + (options.offset ?? 0) + (options.limit ?? Number.POSITIVE_INFINITY) + return { + hasMore, + appliedRowKeys: requested.map(({ id }) => id), + } + }) +} + async function runPaginationScenario( scenario: PaginationScenario, ): Promise { @@ -461,39 +494,6 @@ function referenceMultiOrder(scenario: MultiOrderScenario): Array { .map(({ id }) => id) } -function referenceMultiOrderWithoutSecondary( - scenario: MultiOrderScenario, -): Array { - // The current top-K boundary selects rows by the first order term and key, - // then applies the full comparator only to the rows that survived selection. - const selectedIds = new Set( - [...scenario.rows] - .sort( - (left, right) => - compareNullableNumber( - left.primary, - right.primary, - scenario.primary, - ) || left.id - right.id, - ) - .slice(0, scenario.limit) - .map(({ id }) => id), - ) - return scenario.rows - .filter(({ id }) => selectedIds.has(id)) - .sort( - (left, right) => - compareNullableNumber(left.primary, right.primary, scenario.primary) || - compareNullableNumber( - left.secondary, - right.secondary, - scenario.secondary, - ) || - left.id - right.id, - ) - .map(({ id }) => id) -} - async function runMultiOrderScenario( scenario: MultiOrderScenario, ): Promise { @@ -530,43 +530,6 @@ async function runMultiOrderScenario( } } -function isKnownSecondaryOrderBoundaryFailure( - scenario: MultiOrderScenario, - error: unknown, -): boolean { - if ( - !(error instanceof TraceAssertionError) || - error.checkpoint !== 0 || - typeof error.cause !== `object` || - error.cause === null || - !(`actual` in error.cause) || - !(`expected` in error.cause) || - !isNumberArray(error.cause.actual) || - !isNumberArray(error.cause.expected) - ) { - return false - } - - const expected = referenceMultiOrder(scenario) - const defective = referenceMultiOrderWithoutSecondary(scenario) - return ( - defective.join(`,`) !== expected.join(`,`) && - error.cause.actual.join(`,`) === defective.join(`,`) && - error.cause.expected.join(`,`) === expected.join(`,`) - ) -} - -async function runMultiOrderScenarioWithKnownFailures( - scenario: MultiOrderScenario, -): Promise { - try { - await runMultiOrderScenario(scenario) - } catch (error) { - if (isKnownSecondaryOrderBoundaryFailure(scenario, error)) return - throw error - } -} - async function runNullableCursorScenario( scenario: NullableCursorScenario, ): Promise { @@ -582,6 +545,7 @@ async function runNullableCursorScenario( }) || left.id - right.id, ) const pending: Array = [] + const delivered = new Set() let begin!: () => void let write!: (message: { type: `insert`; value: NullableCursorRow }) => void let commit!: () => void @@ -602,7 +566,11 @@ async function runNullableCursorScenario( loadSubset: (options: LoadSubsetOptions) => { const deferred = createDeferred() pending.push({ options, deferred }) - return deferred.promise + return withAppliedSubsetEvidence( + () => orderedRows, + options, + deferred.promise, + ) }, } }, @@ -622,14 +590,21 @@ async function runNullableCursorScenario( try { const preload = live.preload() expect(pending).toHaveLength(1) - const request = pending[0]! - begin() - for (const row of rowsForLoadSubset(orderedRows, request.options)) { - write({ type: `insert`, value: { ...row } }) + // Settling one request can append its boundary-refinement request. + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let index = 0; index < pending.length; index++) { + const request = pending[index]! + begin() + for (const row of rowsForLoadSubset(orderedRows, request.options)) { + if (delivered.has(row.id)) continue + delivered.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + commit() + request.settled = true + request.deferred.resolve() + await flushPromises() } - commit() - request.settled = true - request.deferred.resolve() await preload try { @@ -644,44 +619,6 @@ async function runNullableCursorScenario( } } -// The current ascending cursor boundary can place the non-null row before the -// nulls-first row. Remove this waiver when that request returns row 1. -function isKnownNullableCursorOrderingFailure( - scenario: NullableCursorScenario, - error: unknown, -): boolean { - if ( - scenario.direction !== `asc` || - !(error instanceof TraceAssertionError) || - error.checkpoint !== 0 || - typeof error.cause !== `object` || - error.cause === null || - !(`actual` in error.cause) || - !(`expected` in error.cause) - ) { - return false - } - return ( - isNumberArray(error.cause.actual) && - error.cause.actual.length === 1 && - error.cause.actual[0] === 2 && - isNumberArray(error.cause.expected) && - error.cause.expected.length === 1 && - error.cause.expected[0] === 1 - ) -} - -async function runNullableCursorScenarioWithKnownFailures( - scenario: NullableCursorScenario, -): Promise { - try { - await runNullableCursorScenario(scenario) - } catch (error) { - if (isKnownNullableCursorOrderingFailure(scenario, error)) return - throw error - } -} - async function runPaginationStateScenario( scenario: PaginationStateScenario, ): Promise { @@ -755,351 +692,6 @@ async function runPaginationStateScenario( } } -type ReferencePaginationState = { - rows: Map - window: PaginationWindow -} - -function replayReferenceState( - scenario: PaginationStateScenario, - actionCount: number, -): ReferencePaginationState { - const state: ReferencePaginationState = { - rows: new Map( - scenario.ranks.map((rank, index) => [index + 1, { id: index + 1, rank }]), - ), - window: { ...scenario.initialWindow }, - } - - for (const action of scenario.actions.slice(0, actionCount)) { - if (action.type === `window`) { - state.window = { offset: action.offset, limit: action.limit } - } else if (action.type === `put`) { - state.rows.set(action.id, { id: action.id, rank: action.rank }) - } else { - state.rows.delete(action.id) - } - } - return state -} - -function isPageRowArray(value: unknown): value is Array { - return ( - Array.isArray(value) && - value.every( - (row) => - typeof row === `object` && - row !== null && - `id` in row && - typeof row.id === `number` && - `rank` in row && - typeof row.rank === `number`, - ) - ) -} - -type PageRowDifference = { - checkpoint: number - actual: Array - expected: Array -} - -function readPageRowDifference( - error: unknown, - acceptsCheckpoint: (checkpoint: number) => boolean = (checkpoint) => - checkpoint >= 1, -): PageRowDifference | undefined { - if ( - !(error instanceof TraceAssertionError) || - !acceptsCheckpoint(error.checkpoint) || - typeof error.cause !== `object` || - error.cause === null || - !(`actual` in error.cause) || - !(`expected` in error.cause) || - !isPageRowArray(error.cause.actual) || - !isPageRowArray(error.cause.expected) - ) { - return undefined - } - - return { - checkpoint: error.checkpoint, - actual: error.cause.actual, - expected: error.cause.expected, - } -} - -function readPageRowDifferenceAtCheckpoint( - error: unknown, - checkpoint: number, -): PageRowDifference | undefined { - return readPageRowDifference(error, (value) => value === checkpoint) -} - -function sameRows( - left: ReadonlyArray, - right: ReadonlyArray, -): boolean { - return ( - left.length === right.length && - left.every( - (row, index) => - row.id === right[index]!.id && row.rank === right[index]!.rank, - ) - ) -} - -function comparePageRows( - left: PageRow, - right: PageRow, - direction: `asc` | `desc`, -): number { - const directionFactor = direction === `asc` ? 1 : -1 - return (left.rank - right.rank) * directionFactor || left.id - right.id -} - -function replayOrderedSubscriptionWindow( - scenario: PaginationStateScenario, - actionCount: number, -): Array { - const rows = new Map( - scenario.ranks.map((rank, index) => [index + 1, { id: index + 1, rank }]), - ) - const initialRows = [...rows.values()] - const sentRows = new Map( - referenceWindowRows(initialRows, scenario.direction, { - offset: 0, - limit: scenario.initialWindow.offset + scenario.initialWindow.limit, - }).map((row) => [row.id, row]), - ) - let biggest = referenceWindowRows( - [...sentRows.values()], - scenario.direction, - { offset: 0, limit: sentRows.size }, - ).at(-1) - let window = { ...scenario.initialWindow } - - const currentResult = () => - referenceWindowRows([...sentRows.values()], scenario.direction, window) - - const refill = () => { - const orderedRows = referenceWindowRows( - [...rows.values()], - scenario.direction, - { - offset: 0, - limit: rows.size, - }, - ) - while (biggest !== undefined) { - const currentLength = currentResult().length - if (currentLength >= window.limit) break - const needed = window.limit - currentLength - const atCursor = orderedRows.filter( - (row) => row.rank === biggest!.rank && !sentRows.has(row.id), - ) - const afterCursor = orderedRows - .filter( - (row) => - comparePageRows( - { id: 0, rank: row.rank }, - { id: 0, rank: biggest!.rank }, - scenario.direction, - ) > 0 && !sentRows.has(row.id), - ) - .slice(0, Math.max(0, needed - atCursor.length)) - const loaded = [...atCursor, ...afterCursor] - if (loaded.length === 0) break - - for (const row of loaded) { - sentRows.set(row.id, { ...row }) - if (comparePageRows(biggest, row, scenario.direction) < 0) { - biggest = row - } - } - } - } - - for (const action of scenario.actions.slice(0, actionCount)) { - if (action.type === `window`) { - window = { offset: action.offset, limit: action.limit } - } else if (action.type === `put`) { - const previous = rows.get(action.id) - if (previous?.rank !== action.rank) { - const row = { id: action.id, rank: action.rank } - rows.set(action.id, row) - sentRows.set(row.id, { ...row }) - if ( - biggest === undefined || - comparePageRows(biggest, row, scenario.direction) < 0 - ) { - biggest = row - } - } - } else { - rows.delete(action.id) - sentRows.delete(action.id) - } - refill() - } - - return currentResult() -} - -function isKnownOrderedSubscriptionCoverageFailure( - scenario: PaginationStateScenario, - error: unknown, -): boolean { - const difference = readPageRowDifference(error) - if (!difference) return false - - const fullState = replayReferenceState(scenario, difference.checkpoint) - const expected = referenceWindowRows( - [...fullState.rows.values()], - scenario.direction, - fullState.window, - ) - const defective = replayOrderedSubscriptionWindow( - scenario, - difference.checkpoint, - ) - return ( - !sameRows(defective, expected) && - sameRows(difference.actual, defective) && - sameRows(difference.expected, expected) - ) -} - -function isNumberArray(value: unknown): value is Array { - return Array.isArray(value) && value.every((item) => typeof item === `number`) -} - -function isKnownOnDemandOffsetUnderfetch( - scenario: PaginationScenario, - error: unknown, -): boolean { - if ( - !(error instanceof TraceAssertionError) || - error.checkpoint < 1 || - typeof error.cause !== `object` || - error.cause === null || - !(`actual` in error.cause) || - !(`expected` in error.cause) || - !isNumberArray(error.cause.actual) || - !isNumberArray(error.cause.expected) - ) { - return false - } - - const actual = error.cause.actual - const expected = error.cause.expected - const window = scenario.windows[error.checkpoint] - if (window === undefined) return false - const authoritative = referenceWindow( - scenario.ranks.map((rank, index) => ({ id: index + 1, rank })), - scenario.direction, - window, - ) - const defective = replayOnDemandPaginationWindow(scenario, error.checkpoint) - return ( - expected.length === authoritative.length && - expected.every((id, index) => id === authoritative[index]) && - (defective.length !== authoritative.length || - defective.some((id, index) => id !== authoritative[index])) && - actual.length === defective.length && - actual.every((id, index) => id === defective[index]) - ) -} - -function replayOnDemandPaginationWindow( - scenario: PaginationScenario, - checkpoint: number, -): Array { - const authoritativeRows = referenceWindowRows( - scenario.ranks.map((rank, index) => ({ id: index + 1, rank })), - scenario.direction, - { offset: 0, limit: scenario.ranks.length }, - ) - const initialWindow = scenario.windows[0]! - const delivered = new Map( - authoritativeRows - .slice(0, initialWindow.offset + initialWindow.limit) - .map((row) => [row.id, row]), - ) - let biggest = referenceWindowRows( - [...delivered.values()], - scenario.direction, - { offset: 0, limit: delivered.size }, - ).at(-1) - - if (initialWindow.limit === 0) { - return referenceWindow( - [...delivered.values()], - scenario.direction, - scenario.windows[checkpoint]!, - ) - } - - for (const window of scenario.windows.slice(0, checkpoint + 1)) { - const current = referenceWindowRows( - [...delivered.values()], - scenario.direction, - window, - ) - const needed = window.limit - current.length - if (needed <= 0 || biggest === undefined) continue - - const atCursor = authoritativeRows.filter( - (row) => row.rank === biggest!.rank, - ) - const afterCursor = authoritativeRows - .filter((row) => comparePageRows(biggest!, row, scenario.direction) < 0) - .slice(0, needed) - for (const row of [...atCursor, ...afterCursor]) { - if (!delivered.has(row.id)) delivered.set(row.id, row) - if (comparePageRows(biggest, row, scenario.direction) < 0) biggest = row - } - } - - const window = scenario.windows[checkpoint]! - return referenceWindow([...delivered.values()], scenario.direction, window) -} - -function assertionDifference( - checkpoint: number, - actual: unknown, - expected: unknown, -): TraceAssertionError { - try { - expect(actual).toEqual(expected) - } catch (error) { - return new TraceAssertionError(checkpoint, error) - } - throw new Error(`test difference must not be equal`) -} - -async function runPaginationStateScenarioWithKnownFailures( - scenario: PaginationStateScenario, -): Promise { - try { - await runPaginationStateScenario(scenario) - } catch (error) { - if (isKnownOrderedSubscriptionCoverageFailure(scenario, error)) return - throw error - } -} - -async function runOnDemandPaginationScenarioWithKnownFailures( - scenario: PaginationScenario, -): Promise { - try { - await runOnDemandPaginationScenario(scenario) - } catch (error) { - if (isKnownOnDemandOffsetUnderfetch(scenario, error)) return - throw error - } -} - async function runOnDemandPaginationScenario( scenario: PaginationScenario, ): Promise { @@ -1131,7 +723,7 @@ async function runOnDemandPaginationScenario( loads.push({ ...options }) const requested = rowsForLoadSubset(orderedRows, options) - return new Promise((resolve) => { + const settled = new Promise((resolve) => { queueMicrotask(() => { begin() for (const row of requested) { @@ -1143,6 +735,11 @@ async function runOnDemandPaginationScenario( resolve() }) }) + return withAppliedSubsetEvidence( + () => orderedRows, + options, + settled, + ) }, } }, @@ -1160,7 +757,9 @@ async function runOnDemandPaginationScenario( try { await live.preload() - expect(loads.length).toBeGreaterThan(0) + if (initialWindow.limit > 0) { + expect(loads.length).toBeGreaterThan(0) + } try { expect(Array.from(live.values(), ({ id }) => id)).toEqual( referenceWindow(authoritativeRows, scenario.direction, initialWindow), @@ -1192,7 +791,8 @@ async function runOnDemandPaginationScenario( compareOptions: { direction: `asc`, nulls: `first` }, }, ] - for (const load of loads) expect(load.orderBy).toEqual(expectedOrderBy) + for (const load of loads) + expect(load.orderBy).toMatchObject(expectedOrderBy) } finally { live.cleanup() source.cleanup() @@ -1242,7 +842,11 @@ async function expectOnDemandWindowsAreCompletionOrderIndependent( loadSubset: (options: LoadSubsetOptions) => { const deferred = createDeferred() pending.push({ options, deferred }) - return deferred.promise + return withAppliedSubsetEvidence( + () => authoritativeRows, + options, + deferred.promise, + ) }, } }, @@ -1269,7 +873,13 @@ async function expectOnDemandWindowsAreCompletionOrderIndependent( const request = pending[index]! apply(request.options) request.deferred.resolve() - await Promise.resolve() + await flushPromises() + } + for (let index = 2; index < pending.length; index++) { + const request = pending[index]! + apply(request.options) + request.deferred.resolve() + await flushPromises() } await first await second @@ -1284,9 +894,155 @@ async function expectOnDemandWindowsAreCompletionOrderIndependent( } } +async function runAdversarialOrderedProviderScenario(options: { + providerRows: ReadonlyArray + initialRows?: ReadonlyArray + order: + | { kind: `rank`; direction: `asc` | `desc`; nulls: `first` | `last` } + | { + kind: `reference` + direction?: `asc` | `desc` + nulls?: `first` | `last` + } + | { kind: `locale` } + limit: number + expectedIds: ReadonlyArray + useOffsetWhenAvailable?: boolean + providerPageCap?: number + reportedExtent?: `computed` | `continues` | `unknown` | `exhausted` + widenTo?: number + expectNoProgress?: boolean +}): Promise> { + const loads: Array = [] + const delivered = new Set(options.initialRows?.map(({ id }) => id) ?? []) + const source = createCollection({ + id: `pagination-adversarial-order-source-${collectionSequence++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + if (options.initialRows?.length) { + begin() + for (const row of options.initialRows) { + write({ type: `insert`, value: { ...row } }) + } + commit() + } + markReady() + return { + loadSubset: (loadOptions: LoadSubsetOptions) => { + loads.push(loadOptions) + if (loads.length > options.providerRows.length * 4 + 4) { + throw new Error( + `Ordered refinement exceeded its finite source work bound: ${JSON.stringify( + loads.map(({ limit, offset, cursor }) => ({ + limit, + offset, + lastKey: cursor?.lastKey, + })), + )}`, + ) + } + const providerMatch = options.useOffsetWhenAvailable + ? options.providerRows.slice( + loadOptions.offset ?? 0, + loadOptions.limit === undefined + ? undefined + : (loadOptions.offset ?? 0) + loadOptions.limit, + ) + : rowsForLoadSubset(options.providerRows, loadOptions) + const requested = + options.providerPageCap === undefined + ? providerMatch + : providerMatch.slice(0, options.providerPageCap) + begin() + for (const row of requested) { + if (delivered.has(row.id)) continue + delivered.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + const receipt = commit() + const requestedIds = requested.map(({ id }) => id) + const hasMore = + options.reportedExtent === undefined || + options.reportedExtent === `computed` + ? requestedIds.length < options.providerRows.length + : options.reportedExtent === `continues` + ? true + : options.reportedExtent === `exhausted` + ? false + : undefined + return Promise.resolve(receipt).then(() => ({ + hasMore, + appliedRowKeys: requestedIds, + })) + }, + } + }, + }, + }) + const live = createLiveQueryCollection((query) => { + const from = query.from({ row: source }) + const ordered = + options.order.kind === `locale` + ? from.orderBy(({ row }) => row.label, { + direction: `asc`, + nulls: `first`, + stringSort: `locale`, + locale: `en-US`, + localeOptions: { numeric: true }, + }) + : from.orderBy( + ({ row }) => row.rank, + options.order.kind === `reference` + ? { + direction: options.order.direction ?? `asc`, + nulls: options.order.nulls ?? `first`, + } + : { + direction: options.order.direction, + nulls: options.order.nulls, + }, + ) + return ordered.limit(options.limit).select(({ row }) => ({ id: row.id })) + }) + + try { + await live.preload() + expect(Array.from(live.values(), ({ id }) => id)).toEqual( + options.expectedIds, + ) + if (options.widenTo !== undefined) { + const loadCount = loads.length + const widened = live.utils.setWindow({ + offset: 0, + limit: options.widenTo, + }) + if (widened instanceof Promise) await widened + if (options.expectNoProgress) { + expect(live.utils.lastSubsetError).toMatchObject({ + name: `OrderedLoadNoProgressError`, + }) + } + expect(loads.length).toBeGreaterThan(loadCount) + } + // Snapshot observations before cleanup. Teardown must not create fresh + // source demand, and callers must not mistake such work for the scenario's + // final refinement request. + return [...loads] + } finally { + live.cleanup() + source.cleanup() + } +} + async function runPendingMutationScenario( scenario: PendingMutationScenario, timing: `before-response` | `after-response`, + finalLimitAfterMutation?: number, ): Promise { const rows = new Map( scenario.ranks.map((rank, index) => [index + 1, { id: index + 1, rank }]), @@ -1300,8 +1056,7 @@ async function runPendingMutationScenario( 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 initialCoverageRequests = scenario.responseOutcome === `reject` ? 2 : 0 let begin!: () => void let write!: (message: { type: `insert` | `update` | `delete` @@ -1327,13 +1082,24 @@ async function runPendingMutationScenario( params.markReady() return { loadSubset: (options: LoadSubsetOptions) => { - if (establishInitialCoverageSynchronously) { - establishInitialCoverageSynchronously = false - return true + if (initialCoverageRequests > 0) { + initialCoverageRequests-- + return Promise.resolve({ + hasMore: true, + appliedRowKeys: [firstDelivered.id], + }) } const deferred = createDeferred() pending.push({ options, deferred }) - return deferred.promise + return withAppliedSubsetEvidence( + () => + referenceWindowRows([...rows.values()], scenario.direction, { + offset: 0, + limit: rows.size, + }), + options, + deferred.promise, + ) }, } }, @@ -1366,7 +1132,10 @@ async function runPendingMutationScenario( } const settlePending = async () => { - for (const request of pending) { + // Settling one request can append its boundary-refinement request. + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let index = 0; index < pending.length; index++) { + const request = pending[index]! if (request.settled) continue request.settled = true const orderedRows = referenceWindowRows( @@ -1385,7 +1154,7 @@ async function runPendingMutationScenario( } commit() request.deferred.resolve() - await Promise.resolve() + await flushPromises() } } @@ -1400,8 +1169,17 @@ async function runPendingMutationScenario( await preload if (timing === `after-response`) { applyMutation() - await Promise.resolve() + await flushPromises() + if (finalLimitAfterMutation !== undefined) { + finalLimit = finalLimitAfterMutation + const widened = live.utils.setWindow({ + offset: 0, + limit: finalLimit, + }) + if (widened instanceof Promise) outstanding.push(widened) + } await settlePending() + await Promise.all(outstanding) } } else { await preload @@ -1468,7 +1246,7 @@ async function runPendingMutationScenario( referenceWindowRows( [...rows.values()].filter(({ id }) => deliveredIds.has(id)), scenario.direction, - { offset: 0, limit: finalLimit }, + { offset: 0, limit: deliveredIds.size }, ), ) } @@ -1480,99 +1258,6 @@ async function runPendingMutationScenario( } } -function pendingMutationRows( - scenario: PendingMutationScenario, -): Map { - const rows = new Map( - scenario.ranks.map((rank, index) => [index + 1, { id: index + 1, rank }]), - ) - if (scenario.mutation.type === `delete`) { - rows.delete(scenario.mutation.id) - } else { - rows.set(scenario.mutation.row.id, { ...scenario.mutation.row }) - } - return rows -} - -function isKnownSettledTopKMembershipFailure( - scenario: PendingMutationScenario, - timing: `before-response` | `after-response`, - error: unknown, -): boolean { - if (scenario.responseOutcome !== `resolve` || timing !== `after-response`) { - return false - } - const difference = readPageRowDifferenceAtCheckpoint(error, 0) - if (!difference) return false - - const initialRows = scenario.ranks.map((rank, index) => ({ - id: index + 1, - rank, - })) - const initialVisibleIds = new Set( - referenceWindowRows(initialRows, scenario.direction, { - offset: 0, - limit: scenario.limit, - }).map(({ id }) => id), - ) - const finalRows = pendingMutationRows(scenario) - const expected = referenceWindowRows( - [...finalRows.values()], - scenario.direction, - { offset: 0, limit: scenario.limit }, - ) - const defective = referenceWindowRows( - [...finalRows.values()].filter(({ id }) => initialVisibleIds.has(id)), - scenario.direction, - { offset: 0, limit: scenario.limit }, - ) - - return ( - !sameRows(defective, expected) && - sameRows(difference.actual, defective) && - sameRows(difference.expected, expected) - ) -} - -function isKnownRejectedCursorRetryFailure( - scenario: PendingMutationScenario, - error: unknown, -): boolean { - if (scenario.responseOutcome !== `reject`) return false - if (!(error instanceof PendingMutationTraceAssertionError)) return false - - const difference = readPageRowDifferenceAtCheckpoint(error, 0) - if (!difference) return false - - const finalRows = pendingMutationRows(scenario) - const finalLimit = scenario.limit + 1 - const expected = referenceWindowRows( - [...finalRows.values()], - scenario.direction, - { offset: 0, limit: finalLimit }, - ) - const defective = error.deliveredRows - - return ( - !sameRows(defective, expected) && - sameRows(difference.actual, defective) && - sameRows(difference.expected, expected) - ) -} - -async function runPendingMutationScenarioWithKnownFailures( - scenario: PendingMutationScenario, - timing: `before-response` | `after-response`, -): Promise { - try { - await runPendingMutationScenario(scenario, timing) - } catch (error) { - if (isKnownSettledTopKMembershipFailure(scenario, timing, error)) return - if (isKnownRejectedCursorRetryFailure(scenario, error)) return - throw error - } -} - async function runRejectedCursorRetryAfterMutation(): Promise { const rows = new Map([ [1, { id: 1, rank: 0 }], @@ -1584,7 +1269,7 @@ async function runRejectedCursorRetryAfterMutation(): Promise { 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 initialCoverageRequests = 2 let begin!: () => void let write!: (message: { type: `insert` | `update`; value: PageRow }) => void let commit!: () => void @@ -1606,13 +1291,24 @@ async function runRejectedCursorRetryAfterMutation(): Promise { params.markReady() return { loadSubset: (options: LoadSubsetOptions) => { - if (establishInitialCoverageSynchronously) { - establishInitialCoverageSynchronously = false - return true + if (initialCoverageRequests > 0) { + initialCoverageRequests-- + return Promise.resolve({ + hasMore: true, + appliedRowKeys: [1], + }) } const deferred = createDeferred() pending.push({ options, deferred }) - return deferred.promise + return withAppliedSubsetEvidence( + () => + referenceWindowRows([...rows.values()], `asc`, { + offset: 0, + limit: rows.size, + }), + options, + deferred.promise, + ) }, } }, @@ -1640,7 +1336,7 @@ async function runRejectedCursorRetryAfterMutation(): Promise { } commit() request.deferred.resolve() - await Promise.resolve() + await flushPromises() } try { @@ -1724,7 +1420,15 @@ async function runPendingHistoryScenario( loadSubset: (options: LoadSubsetOptions) => { const deferred = createDeferred() pending.push({ options, deferred }) - return deferred.promise + return withAppliedSubsetEvidence( + () => + referenceWindowRows([...rows.values()], scenario.direction, { + offset: 0, + limit: rows.size, + }), + options, + deferred.promise, + ) }, } }, @@ -1763,7 +1467,7 @@ async function runPendingHistoryScenario( } commit() request.deferred.resolve() - await Promise.resolve() + await flushPromises() } const track = (result: true | Promise): void => { @@ -1782,6 +1486,17 @@ async function runPendingHistoryScenario( await settle(pending[0]!) for (let index = 1; index < pending.length; index++) { + if (index > rows.size * 4) { + throw new Error( + `Ordered continuation exceeded its finite source work bound: ${JSON.stringify( + pending.map(({ options }) => ({ + limit: options.limit, + offset: options.offset, + lastKey: options.cursor?.lastKey, + })), + )}`, + ) + } await settle(pending[index]!) } await Promise.all(outstanding) @@ -1820,55 +1535,6 @@ function changedRankValue(previous: number, requested: number): number { : requested } -function pendingHistoryRows( - scenario: PendingHistoryScenario, -): Map { - const rows = new Map( - scenario.ranks.map((rank, index) => [index + 1, { id: index + 1, rank }]), - ) - const first = referenceWindowRows([...rows.values()], scenario.direction, { - offset: 0, - limit: 1, - })[0]! - const afterFirst = changedRankValue(first.rank, scenario.firstRank) - const afterSecond = changedRankValue(afterFirst, scenario.secondRank) - rows.set(first.id, { ...first, rank: afterSecond }) - return rows -} - -function isKnownLatePendingHistoryUnderfill( - scenario: PendingHistoryScenario, - error: unknown, -): boolean { - if (!(error instanceof PendingHistoryTraceAssertionError)) { - return false - } - - const difference = readPageRowDifferenceAtCheckpoint(error, 0) - if (!difference) return false - const authoritative = referenceWindowRows( - [...pendingHistoryRows(scenario).values()], - scenario.direction, - { offset: 0, limit: scenario.wideLimit }, - ) - return ( - sameRows(difference.expected, authoritative) && - !sameRows(error.deliveredRows, authoritative) && - sameRows(difference.actual, error.deliveredRows) - ) -} - -async function runPendingHistoryScenarioWithKnownFailures( - scenario: PendingHistoryScenario, -): Promise { - try { - await runPendingHistoryScenario(scenario) - } catch (error) { - if (isKnownLatePendingHistoryUnderfill(scenario, error)) return - throw error - } -} - async function expectInflightRequestFillsNewWindow(): Promise { const rows: Array = [ { id: 1, rank: 0 }, @@ -1901,7 +1567,11 @@ async function expectInflightRequestFillsNewWindow(): Promise { loadSubset: (options: LoadSubsetOptions) => { const deferred = createDeferred() pending.push({ options, deferred }) - return deferred.promise + return withAppliedSubsetEvidence( + () => rows, + options, + deferred.promise, + ) }, } }, @@ -1936,9 +1606,11 @@ async function expectInflightRequestFillsNewWindow(): Promise { expect(pending).toHaveLength(1) await settle(pending[0]!) + await flushPromises() + expect(pending).toHaveLength(2) + await settle(pending[1]!) await preload if (setWindow instanceof Promise) await setWindow - expect(pending).toHaveLength(1) try { expect(Array.from(live.values(), ({ id }) => id)).toEqual([3, 4]) @@ -1990,14 +1662,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`discovered trace: loads an on-demand window after a zero limit`, async () => { - await expectAssertionFailure(runOnDemandPaginationScenario, { - checkpoint: 1, - classify: ({ actual, expected }) => - isNumberArray(actual) && - actual.join(`,`) === `1` && - isNumberArray(expected) && - expected.join(`,`) === `1,2`, - })({ + await runOnDemandPaginationScenario({ ranks: [0, 0], direction: `asc`, windows: [ @@ -2007,6 +1672,444 @@ describe(`pagination recomputation oracle`, () => { }) }) + it(`widens an offset on-demand window after starting at zero limit`, async () => { + await runOnDemandPaginationScenario({ + ranks: [-1, 0, 0, 0, -1, 0], + direction: `asc`, + windows: [ + { offset: 1, limit: 0 }, + { offset: 4, limit: 1 }, + { offset: 4, limit: 2 }, + { offset: 0, limit: 0 }, + ], + }) + }) + + it(`keeps synchronous limited satisfaction local to the active window`, async () => { + const rows: Array = [ + { id: 1, rank: 1 }, + { id: 2, rank: 2 }, + { id: 3, rank: 3 }, + ] + const requests: Array = [] + const delivered = new Set() + const source = createCollection({ + id: `pagination-sync-limited-source-${collectionSequence++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: (options: LoadSubsetOptions) => { + requests.push(options) + begin() + for (const row of rowsForLoadSubset(rows, options)) { + if (delivered.has(row.id)) continue + delivered.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + commit() + return true + }, + } + }, + }, + }) + const live = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank, `asc`) + .orderBy(({ row }) => row.id, `asc`) + .limit(1), + ) + + try { + await live.preload() + await flushPromises() + expect(Array.from(live.values(), ({ id }) => id)).toEqual([1]) + expect(source._sync.getLoadSubsetCoverage()).toEqual([]) + + const widened = live.utils.setWindow({ offset: 0, limit: 2 }) + if (widened instanceof Promise) await widened + await flushPromises() + + expect(Array.from(live.values(), ({ id }) => id)).toEqual([1, 2]) + expect(requests).toHaveLength(2) + expect(requests[0]?.limit).toBe(1) + expect(requests[1]).toMatchObject({ limit: 2, offset: 0 }) + expect(requests[1]?.cursor).toBeUndefined() + } finally { + live.cleanup() + source.cleanup() + } + }) + + it(`admits only applied rows when the source extent is unknown`, async () => { + const providerRows: Array = [ + { id: 1, rank: 1 }, + { id: 2, rank: 2 }, + { id: 3, rank: 3 }, + ] + const delivered = new Set() + const source = createCollection({ + id: `pagination-unknown-extent-source-${collectionSequence++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + write({ type: `insert`, value: { id: 99, rank: -1 } }) + commit() + markReady() + return { + loadSubset: (options: LoadSubsetOptions) => { + const requested = rowsForLoadSubset(providerRows, options) + begin() + for (const row of requested) { + if (delivered.has(row.id)) continue + delivered.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + const receipt = commit() + return Promise.resolve(receipt).then(() => ({ + hasMore: undefined, + appliedRowKeys: requested.map(({ id }) => id), + })) + }, + } + }, + }, + }) + const live = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank, `asc`) + .orderBy(({ row }) => row.id, `asc`) + .limit(2), + ) + + try { + await live.preload() + expect(Array.from(live.values(), ({ id }) => id)).toEqual([1, 2]) + } finally { + live.cleanup() + source.cleanup() + } + }) + + it.each([ + [`unknown`, undefined, [1, 2, 3], `covering`], + [`unknown`, undefined, [1, 2, 3], `narrower`], + [`continues`, true, [1, 2, 3], `covering`], + [`continues`, true, [1, 2, 3], `narrower`], + [`exhausted`, false, [99, 1, 2], `covering`], + [`exhausted`, false, [99, 1, 2], `narrower`], + ] satisfies ReadonlyArray< + readonly [ + string, + boolean | undefined, + ReadonlyArray, + `covering` | `narrower`, + ] + >)( + `projects a shared covering acquisition into exact and narrower windows (%s, release %s first)`, + async (_extent, hasMore, expectedCovering, releaseFirst) => { + const providerRows: Array = [ + { id: 1, rank: 1 }, + { id: 2, rank: 2 }, + { id: 3, rank: 3 }, + { id: 4, rank: 4 }, + ] + const settlement = createDeferred() + const physicalLoads: Array = [] + let begin!: () => void + let write!: (change: { type: `insert`; value: PageRow }) => void + let commit!: () => void + let deduplicated!: DeduplicatedLoadSubset + const source = createCollection({ + id: `pagination-shared-provenance-source-${collectionSequence++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + begin() + write({ type: `insert`, value: { id: 99, rank: -1 } }) + commit() + params.markReady() + deduplicated = new DeduplicatedLoadSubset({ + loadSubset: (options) => { + physicalLoads.push(options) + const requested = rowsForLoadSubset(providerRows, options) + begin() + for (const row of requested) { + write({ type: `insert`, value: { ...row } }) + } + const receipt = commit() + return Promise.all([receipt, settlement.promise]).then( + () => + ({ + hasMore, + appliedRowKeys: requested.map(({ id }) => id), + }) satisfies LoadSubsetResult, + ) + }, + }) + return { + loadSubset: (options) => deduplicated.loadSubset(options), + } + }, + }, + }) + const covering = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank, `asc`) + .orderBy(({ row }) => row.id, `asc`) + .limit(3), + ) + const narrower = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank, `asc`) + .orderBy(({ row }) => row.id, `asc`) + .limit(2), + ) + + try { + const coveringReady = covering.preload() + const narrowerReady = narrower.preload() + await flushPromises() + expect(physicalLoads).toHaveLength(1) + settlement.resolve() + await Promise.all([coveringReady, narrowerReady]) + expect(Array.from(covering.values(), ({ id }) => id)).toEqual( + expectedCovering, + ) + expect(Array.from(narrower.values(), ({ id }) => id)).toEqual( + expectedCovering.slice(0, 2), + ) + + const covered = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank, `asc`) + .orderBy(({ row }) => row.id, `asc`) + .limit(1), + ) + try { + await covered.preload() + expect(Array.from(covered.values(), ({ id }) => id)).toEqual( + expectedCovering.slice(0, 1), + ) + } finally { + covered.cleanup() + } + + if (releaseFirst === `covering`) { + covering.cleanup() + expect(Array.from(narrower.values(), ({ id }) => id)).toEqual( + expectedCovering.slice(0, 2), + ) + } else { + narrower.cleanup() + expect(Array.from(covering.values(), ({ id }) => id)).toEqual( + expectedCovering, + ) + } + } finally { + covering.cleanup() + narrower.cleanup() + source.cleanup() + } + }, + ) + + it(`tracks an asynchronous prefix refresh after synchronous satisfaction`, async () => { + const rows: Array = [ + { id: 1, rank: 1 }, + { id: 2, rank: 2 }, + { id: 3, rank: 3 }, + ] + const requests: Array = [] + const delivered = new Set() + const refinement = createDeferred() + let loadCount = 0 + const source = createCollection({ + id: `pagination-async-refinement-source-${collectionSequence++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + const publish = (options: LoadSubsetOptions) => { + const appliedRowKeys: Array = [] + begin() + for (const row of rowsForLoadSubset(rows, options)) { + if (delivered.has(row.id)) continue + delivered.add(row.id) + appliedRowKeys.push(row.id) + write({ type: `insert`, value: { ...row } }) + } + commit() + return appliedRowKeys + } + + return { + loadSubset: (options: LoadSubsetOptions) => { + requests.push(options) + loadCount += 1 + if (loadCount === 1) { + publish(options) + return true + } + + return refinement.promise.then(() => ({ + hasMore: false, + appliedRowKeys: publish(options), + })) + }, + } + }, + }, + }) + const live = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank, `asc`) + .orderBy(({ row }) => row.id, `asc`) + .limit(1), + ) + + try { + await live.preload() + expect(Array.from(live.values(), ({ id }) => id)).toEqual([1]) + expect(requests.map(({ limit }) => limit)).toEqual([1]) + + const widened = live.utils.setWindow({ offset: 0, limit: 2 }) + expect(widened).toBeInstanceOf(Promise) + await flushPromises() + expect(requests.map(({ limit }) => limit)).toEqual([1, 2]) + expect(requests[1]).toMatchObject({ offset: 0 }) + expect(requests[1]?.cursor).toBeUndefined() + const settledBeforeRefinement = await Promise.race([ + Promise.resolve(widened).then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 10)), + ]) + expect(settledBeforeRefinement).toBe(false) + + refinement.resolve() + if (widened instanceof Promise) await widened + expect(Array.from(live.values(), ({ id }) => id)).toEqual([1, 2]) + } finally { + live.cleanup() + source.cleanup() + } + }) + + it(`refines locale-ordered continuations locally when predicate IR cannot express the collation`, async () => { + const rows: Array = [ + { id: 1, label: `item2` }, + { id: 2, label: `item10` }, + { id: 3, label: `item11` }, + ] + const pending: Array = [] + const delivered = new Set() + let begin!: () => void + let write!: (message: { type: `insert`; value: LocaleCursorRow }) => void + let commit!: () => void + const source = createCollection({ + id: `pagination-locale-cursor-source-${collectionSequence++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + params.markReady() + return { + loadSubset: (options: LoadSubsetOptions) => { + const deferred = createDeferred() + pending.push({ options, deferred }) + return withAppliedSubsetEvidence( + () => rows, + options, + deferred.promise, + ) + }, + } + }, + }, + }) + const live = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.label, { + direction: `asc`, + nulls: `first`, + stringSort: `locale`, + locale: `en-US`, + localeOptions: { numeric: true }, + }) + .orderBy(({ row }) => row.id, `asc`) + .limit(1), + ) + + try { + const preload = live.preload() + expect(pending).toHaveLength(1) + // Settling one request can append its boundary-refinement request. + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let index = 0; index < pending.length; index++) { + const request = pending[index]! + begin() + for (const row of rowsForLoadSubset(rows, request.options)) { + if (delivered.has(row.id)) continue + delivered.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + commit() + request.deferred.resolve() + await flushPromises() + } + await preload + + expect(pending).toHaveLength(2) + const refinement = pending[1]! + expect(refinement.options.cursor).toBeUndefined() + expect(refinement.options.limit).toBeUndefined() + expect(refinement.options.offset).toBeUndefined() + + const widened = live.utils.setWindow({ offset: 0, limit: 2 }) + expect(widened).toBe(true) + + expect(Array.from(live.values(), ({ id }) => id)).toEqual([1, 2]) + } finally { + for (const request of pending) request.deferred.resolve() + live.cleanup() + source.cleanup() + } + }) + const nullableBoundaryRows: ReadonlyArray = [ { id: 1, primary: null, secondary: 2 }, { id: 2, primary: null, secondary: 0 }, @@ -2025,7 +2128,6 @@ describe(`pagination recomputation oracle`, () => { secondary: { direction: `asc`, nulls: `first` }, limit: 1, }, - { actual: [1], expected: [2] }, ], [ `orders a descending nullable boundary by its second term`, @@ -2035,7 +2137,6 @@ describe(`pagination recomputation oracle`, () => { secondary: { direction: `desc`, nulls: `first` }, limit: 1, }, - undefined, ], [ `orders an ascending and descending mixed nullable boundary`, @@ -2045,7 +2146,6 @@ describe(`pagination recomputation oracle`, () => { secondary: { direction: `desc`, nulls: `first` }, limit: 1, }, - undefined, ], [ `discovered trace: orders a descending and ascending mixed nullable boundary`, @@ -2055,7 +2155,6 @@ describe(`pagination recomputation oracle`, () => { secondary: { direction: `asc`, nulls: `first` }, limit: 1, }, - { actual: [1], expected: [2] }, ], [ `uses the public key to break a complete tuple tie`, @@ -2068,7 +2167,6 @@ describe(`pagination recomputation oracle`, () => { secondary: { direction: `asc`, nulls: `last` }, limit: 1, }, - undefined, ], [ `discovered trace: places nulls last in an ascending nullable boundary`, @@ -2078,7 +2176,6 @@ describe(`pagination recomputation oracle`, () => { secondary: { direction: `asc`, nulls: `last` }, limit: 1, }, - { actual: [4], expected: [5] }, ], [ `places nulls last in a descending nullable boundary`, @@ -2088,35 +2185,18 @@ describe(`pagination recomputation oracle`, () => { secondary: { direction: `desc`, nulls: `last` }, limit: 1, }, - undefined, ], - ] satisfies ReadonlyArray< - readonly [ - string, - MultiOrderScenario, - { actual: ReadonlyArray; expected: ReadonlyArray }?, - ] - >)(`%s`, async (_name, scenario, expectedFailure) => { - if (!expectedFailure) { - await runMultiOrderScenario(scenario) - return - } - await expectAssertionFailure(runMultiOrderScenario, { - checkpoint: 0, - classify: ({ actual, expected }) => - isNumberArray(actual) && - actual.join(`,`) === expectedFailure.actual.join(`,`) && - isNumberArray(expected) && - expected.join(`,`) === expectedFailure.expected.join(`,`), - })(scenario) - }) + ] satisfies ReadonlyArray)( + `%s`, + async (_name, scenario) => runMultiOrderScenario(scenario), + ) fcTest.prop([multiOrderScenarioArbitrary], { numRuns: orderedScenarioRuns, seed: 1663, })( `matches multi-column nullable ordering for a fixed seed`, - runMultiOrderScenarioWithKnownFailures, + runMultiOrderScenario, ) fcTest.prop( @@ -2124,7 +2204,7 @@ describe(`pagination recomputation oracle`, () => { oracleRandomParameters(orderedScenarioRuns, replaySeed), )( `matches multi-column nullable ordering for a random or replayed seed`, - runMultiOrderScenarioWithKnownFailures, + runMultiOrderScenario, ) fcTest.prop([nullableCursorScenarioArbitrary], { @@ -2132,7 +2212,7 @@ describe(`pagination recomputation oracle`, () => { seed: 1665, })( `matches nullable cursor ordering while an async response is pending for a fixed seed`, - runNullableCursorScenarioWithKnownFailures, + runNullableCursorScenario, ) fcTest.prop( @@ -2140,37 +2220,9 @@ describe(`pagination recomputation oracle`, () => { oracleRandomParameters(transitionScenarioRuns, replaySeed), )( `matches nullable cursor ordering while an async response is pending for a random or replayed seed`, - runNullableCursorScenarioWithKnownFailures, + runNullableCursorScenario, ) - it(`rejects collateral output from the nullable cursor classifier`, () => { - expect( - isKnownNullableCursorOrderingFailure( - { rank: 0, direction: `asc` }, - assertionDifference(0, [], [1]), - ), - ).toBe(false) - }) - - it(`rejects collateral output from the secondary-order classifier`, () => { - const scenario: MultiOrderScenario = { - rows: [ - { id: 2, primary: -2, secondary: 0 }, - { id: 1, primary: -2, secondary: null }, - ], - primary: { direction: `asc`, nulls: `first` }, - secondary: { direction: `asc`, nulls: `last` }, - limit: 1, - } - - expect( - isKnownSecondaryOrderBoundaryFailure( - scenario, - assertionDifference(0, [], [2]), - ), - ).toBe(false) - }) - it.each([ [`boundary insert`, { type: `insert`, row: { id: 5, rank: 0.5 } }], [`visible delete`, { type: `delete`, id: 1 }], @@ -2193,28 +2245,353 @@ describe(`pagination recomputation oracle`, () => { }, ) - it(`discovered trace: a settled rank update refreshes top-k membership`, async () => { - const scenario: PendingMutationScenario = { - ranks: [0, 0, 1], - direction: `desc`, - limit: 1, - mutation: { type: `update`, row: { id: 3, rank: 0 } }, - responseOutcome: `resolve`, + it.each([ + [`insert`, { type: `insert`, row: { id: 9, rank: 0.5 } }], + [`delete`, { type: `delete`, id: 1 }], + [`rank update`, { type: `update`, row: { id: 2, rank: 10 } }], + ] satisfies ReadonlyArray)( + `revalidates a finite ordered prefix after a settled SSE %s`, + async (_name, mutation) => { + await runPendingMutationScenario( + { + ranks: [0, 1, 2, 3, 4, 5, 6, 7], + direction: `asc`, + limit: 2, + mutation, + responseOutcome: `resolve`, + }, + `after-response`, + ) + }, + ) + + it(`retains a finite inactive prefix across shrink, SSE, and re-expansion`, async () => { + const rows = new Map( + Array.from({ length: 5 }, (_, index) => [ + index + 1, + { id: index + 1, rank: index + 1 }, + ]), + ) + const delivered = new Set() + let begin!: () => void + let write!: (message: { type: `insert`; value: PageRow }) => void + let commit!: () => void + const source = createCollection({ + id: `pagination-retained-prefix-live-${collectionSequence++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + params.markReady() + return { + loadSubset: (options: LoadSubsetOptions) => { + const ordered = [...rows.values()].sort( + (left, right) => left.rank - right.rank || left.id - right.id, + ) + const requested = rowsForLoadSubset(ordered, options) + begin() + for (const row of requested) { + if (delivered.has(row.id)) continue + delivered.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + const receipt = commit() + return Promise.resolve(receipt).then(() => ({ + hasMore: requested.length < ordered.length, + appliedRowKeys: requested.map(({ id }) => id), + })) + }, + } + }, + }, + }) + const live = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank, `asc`) + .orderBy(({ row }) => row.id, `asc`) + .limit(3), + ) + + try { + await live.preload() + expect(Array.from(live.values(), ({ id }) => id)).toEqual([1, 2, 3]) + + await live.utils.setWindow({ offset: 0, limit: 1 }) + const inserted = { id: 9, rank: 2.5 } + rows.set(inserted.id, inserted) + delivered.add(inserted.id) + begin() + write({ type: `insert`, value: inserted }) + commit() + await flushPromises() + + await live.utils.setWindow({ offset: 0, limit: 3 }) + expect(Array.from(live.values(), ({ id }) => id)).toEqual([1, 2, 9]) + } finally { + live.cleanup() + source.cleanup() } - await expectAssertionFailure( - () => runPendingMutationScenario(scenario, `after-response`), + }) + + it.each([`asc`, `desc`] as const)( + `refreshes from the start when one SSE batch moves the retained prefix (%s)`, + async (direction) => { + const rows = new Map( + Array.from({ length: 6 }, (_, index) => [ + index + 1, + { id: index + 1, rank: index + 1 }, + ]), + ) + const delivered = new Set() + const loads: Array = [] + let begin!: () => void + let write!: (message: { + type: `insert` | `update` + value: PageRow + }) => void + let commit!: () => void + const source = createCollection({ + id: `pagination-batch-prefix-refresh-${collectionSequence++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + params.markReady() + return { + loadSubset: (options: LoadSubsetOptions) => { + loads.push(options) + const settled = new Promise((resolve) => { + queueMicrotask(() => { + const ordered = referenceWindowRows( + [...rows.values()], + direction, + { offset: 0, limit: rows.size }, + ) + begin() + for (const row of rowsForLoadSubset(ordered, options)) { + if (delivered.has(row.id)) continue + delivered.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + commit() + resolve() + }) + }) + return withAppliedSubsetEvidence( + () => + referenceWindowRows([...rows.values()], direction, { + offset: 0, + limit: rows.size, + }), + options, + settled, + ) + }, + } + }, + }, + }) + const live = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank, direction) + .orderBy(({ row }) => row.id, `asc`) + .limit(2), + ) + + try { + await live.preload() + expect(Array.from(live.values(), ({ id }) => id)).toEqual( + direction === `asc` ? [1, 2] : [6, 5], + ) + + begin() + const movedIds = direction === `asc` ? [1, 2, 3, 4] : [3, 4, 5, 6] + for (const id of movedIds) { + const row = { + id, + rank: direction === `asc` ? 100 + id : -100 - id, + } + rows.set(id, row) + write({ type: `update`, value: { ...row } }) + } + commit() + for (let index = 0; index < 5; index++) await flushPromises() + + expect(Array.from(live.values(), ({ id }) => id)).toEqual( + direction === `asc` ? [5, 6] : [2, 1], + ) + expect(loads.at(-1)).toMatchObject({ offset: 0, limit: 2 }) + expect(loads.at(-1)?.cursor).toBeUndefined() + } finally { + live.cleanup() + source.cleanup() + } + }, + ) + + it.each([ + [`insert`, { type: `insert`, row: { id: 7, rank: 0 } }, [7, 1], [7, 1, 2]], + [`update`, { type: `update`, row: { id: 2, rank: -1 } }, [2, 1], [2, 1, 3]], + [`delete`, { type: `delete`, id: 1 }, [2, 3], [2, 3, 4]], + ] satisfies ReadonlyArray< + readonly [ + string, + PendingMutation, + ReadonlyArray, + ReadonlyArray, + ] + >)( + `keeps an SSE %s that arrives during boundary refinement`, + async (_name, mutation, expectedIds, expectedWideIds) => { + const rows = new Map( + Array.from({ length: 6 }, (_, index) => [ + index + 1, + { id: index + 1, rank: index + 1 }, + ]), + ) + const delivered = new Set() + const pending: Array = [] + let begin!: () => void + let write!: (message: { + type: `insert` | `update` | `delete` + value: PageRow + }) => void + let commit!: () => void + const source = createCollection({ + id: `pagination-pending-refinement-sse-${collectionSequence++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + params.markReady() + return { + loadSubset: (options: LoadSubsetOptions) => { + const deferred = createDeferred() + pending.push({ options, deferred }) + return withAppliedSubsetEvidence( + () => + referenceWindowRows([...rows.values()], `asc`, { + offset: 0, + limit: rows.size, + }), + options, + deferred.promise, + ) + }, + } + }, + }, + }) + const live = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank, `asc`) + .orderBy(({ row }) => row.id, `asc`) + .limit(2), + ) + + const settle = async (request: PendingCursorLoad) => { + const ordered = referenceWindowRows([...rows.values()], `asc`, { + offset: 0, + limit: rows.size, + }) + begin() + for (const row of rowsForLoadSubset(ordered, request.options)) { + if (delivered.has(row.id)) continue + delivered.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + commit() + request.deferred.resolve() + await flushPromises() + } + + try { + const preload = live.preload() + expect(pending).toHaveLength(1) + await settle(pending[0]!) + expect(pending).toHaveLength(2) + + begin() + if (mutation.type === `delete`) { + const row = rows.get(mutation.id)! + rows.delete(mutation.id) + delivered.delete(mutation.id) + write({ type: `delete`, value: { ...row } }) + } else { + rows.set(mutation.row.id, { ...mutation.row }) + delivered.add(mutation.row.id) + write({ type: mutation.type, value: { ...mutation.row } }) + } + commit() + + await settle(pending[1]!) + expect(pending).toHaveLength(3) + expect(pending[2]?.options).toMatchObject({ offset: 0, limit: 2 }) + expect(pending[2]?.options.cursor).toBeUndefined() + for (let index = 2; index < pending.length; index++) { + await settle(pending[index]!) + } + await preload + + expect(Array.from(live.values(), ({ id }) => id)).toEqual(expectedIds) + + const pendingBeforeWiden = pending.length + const widened = live.utils.setWindow({ offset: 0, limit: 3 }) + await flushPromises() + expect(pending).toHaveLength(pendingBeforeWiden + 1) + expect(pending[pendingBeforeWiden]?.options.offset).toBe(3) + expect(pending[pendingBeforeWiden]?.options.cursor).toBeDefined() + for (let index = pendingBeforeWiden; index < pending.length; index++) { + await settle(pending[index]!) + } + if (widened instanceof Promise) await widened + expect(Array.from(live.values(), ({ id }) => id)).toEqual( + expectedWideIds, + ) + } finally { + for (const request of pending) request.deferred.resolve() + live.cleanup() + source.cleanup() + } + }, + ) + + it(`does not use a new row beyond finite coverage as a widening boundary`, async () => { + await runPendingMutationScenario( { - checkpoint: 0, - classify: ({ actual, expected }) => - isPageRowArray(actual) && - sameRows(actual, [{ id: 3, rank: 0 }]) && - isPageRowArray(expected) && - sameRows(expected, [{ id: 1, rank: 0 }]), + ranks: [0, 1, 2, 3, 4, 5, 6, 7], + direction: `asc`, + limit: 2, + mutation: { type: `insert`, row: { id: 9, rank: 4.5 } }, + responseOutcome: `resolve`, }, - )() + `after-response`, + 5, + ) }) - it(`rejects collateral output from the settled top-k classifier`, () => { + it(`discovered trace: a settled rank update refreshes top-k membership`, async () => { const scenario: PendingMutationScenario = { ranks: [0, 0, 1], direction: `desc`, @@ -2222,45 +2599,10 @@ describe(`pagination recomputation oracle`, () => { mutation: { type: `update`, row: { id: 3, rank: 0 } }, responseOutcome: `resolve`, } - - expect( - isKnownSettledTopKMembershipFailure( - scenario, - `after-response`, - assertionDifference(0, [{ id: 2, rank: 0 }], [{ id: 1, rank: 0 }]), - ), - ).toBe(false) - }) - - it(`discovered trace: a rejected cursor does not treat a live insert as remote coverage`, async () => { - const scenario: PendingMutationScenario = { - ranks: [0, -1, 0], - direction: `asc`, - limit: 1, - mutation: { type: `insert`, row: { id: 4, rank: 0 } }, - responseOutcome: `reject`, - } - - await expectAssertionFailure( - () => runPendingMutationScenario(scenario, `before-response`), - { - checkpoint: 0, - classify: ({ actual, expected }) => - isPageRowArray(actual) && - sameRows(actual, [ - { id: 2, rank: -1 }, - { id: 4, rank: 0 }, - ]) && - isPageRowArray(expected) && - sameRows(expected, [ - { id: 2, rank: -1 }, - { id: 1, rank: 0 }, - ]), - }, - )() + await runPendingMutationScenario(scenario, `after-response`) }) - it(`rejects collateral output from the rejected-cursor retry classifier`, () => { + it(`a rejected cursor does not treat a live insert as remote coverage`, async () => { const scenario: PendingMutationScenario = { ranks: [0, -1, 0], direction: `asc`, @@ -2269,24 +2611,7 @@ describe(`pagination recomputation oracle`, () => { responseOutcome: `reject`, } - const collateral = assertionDifference( - 0, - [{ id: 4, rank: 0 }], - [ - { id: 2, rank: -1 }, - { id: 1, rank: 0 }, - ], - ) - - expect( - isKnownRejectedCursorRetryFailure( - scenario, - new PendingMutationTraceAssertionError(collateral.cause, [ - { id: 2, rank: -1 }, - { id: 4, rank: 0 }, - ]), - ), - ).toBe(false) + await runPendingMutationScenario(scenario, `before-response`) }) fcTest.prop([pendingMutationScenarioArbitrary, responseTimingArbitrary], { @@ -2294,7 +2619,7 @@ describe(`pagination recomputation oracle`, () => { seed: 1660, })( `matches recomputation when source mutations cross a pending cursor response for a fixed seed`, - runPendingMutationScenarioWithKnownFailures, + runPendingMutationScenario, ) it.each( @@ -2314,7 +2639,7 @@ describe(`pagination recomputation oracle`, () => { : mutationKind === `update` ? { type: `update`, row: { id: 2, rank: -1 } } : { type: `delete`, id: 2 } - await runPendingMutationScenarioWithKnownFailures( + await runPendingMutationScenario( { ranks: [0, 1, 2, 3], direction: `asc`, @@ -2332,19 +2657,12 @@ describe(`pagination recomputation oracle`, () => { oracleRandomParameters(transitionScenarioRuns, replaySeed), )( `matches recomputation when source mutations cross a pending cursor response for a random or replayed seed`, - runPendingMutationScenarioWithKnownFailures, + runPendingMutationScenario, ) it( `discovered trace: retries a rejected cursor after a source and window transition`, - expectAssertionFailure(runRejectedCursorRetryAfterMutation, { - checkpoint: 0, - classify: ({ actual, expected }) => - isNumberArray(actual) && - actual.join(`,`) === `1,4` && - isNumberArray(expected) && - expected.join(`,`) === `2,3,1`, - }), + runRejectedCursorRetryAfterMutation, ) fcTest.prop([pendingHistoryScenarioArbitrary], { @@ -2352,7 +2670,7 @@ describe(`pagination recomputation oracle`, () => { seed: 1664, })( `matches recomputation across multi-action pending histories for a fixed seed`, - runPendingHistoryScenarioWithKnownFailures, + runPendingHistoryScenario, ) fcTest.prop( @@ -2360,132 +2678,14 @@ describe(`pagination recomputation oracle`, () => { oracleRandomParameters(transitionScenarioRuns, replaySeed), )( `matches recomputation across multi-action pending histories for a random or replayed seed`, - runPendingHistoryScenarioWithKnownFailures, + runPendingHistoryScenario, ) - it(`rejects collateral output from the late pending-history classifier`, () => { - const scenario: PendingHistoryScenario = { - ranks: [0, 0, 0, 0], - direction: `asc`, - initialLimit: 2, - narrowLimit: 1, - wideLimit: 3, - firstRank: 0, - secondRank: 0, - } - const expectedRows = referenceWindowRows( - [...pendingHistoryRows(scenario).values()], - scenario.direction, - { offset: 0, limit: scenario.wideLimit }, - ) - const cause = assertionDifference( - 0, - { - rows: [{ id: 4, rank: 0 }], - modeledDeliveredRows: expectedRows.slice(0, 2), - }, - { - rows: expectedRows, - modeledDeliveredRows: expectedRows.slice(0, 2), - }, - ) - - expect(isKnownLatePendingHistoryUnderfill(scenario, cause)).toBe(false) - }) - it( `discovered trace: an in-flight request does not underfill a new window`, - expectAssertionFailure(expectInflightRequestFillsNewWindow, { - checkpoint: 0, - classify: ({ actual, expected }) => - isNumberArray(actual) && - actual.length === 0 && - isNumberArray(expected) && - expected.join(`,`) === `3,4`, - }), + expectInflightRequestFillsNewWindow, ) - it(`rejects collateral loss from the ordered-subscription classifier`, () => { - const scenario: PaginationStateScenario = { - ranks: [0, 1, 2], - direction: `asc`, - initialWindow: { offset: 0, limit: 3 }, - actions: [{ type: `put`, id: 4, rank: 2 }], - } - const expected = [ - { id: 1, rank: 0 }, - { id: 2, rank: 1 }, - { id: 3, rank: 2 }, - ] - - expect( - isKnownOrderedSubscriptionCoverageFailure( - scenario, - assertionDifference(1, [expected[0]!], expected), - ), - ).toBe(false) - }) - - it(`rejects arbitrary leading loss after an offset shift`, () => { - const scenario: PaginationStateScenario = { - ranks: [0, 1, 2, 3], - direction: `asc`, - initialWindow: { offset: 0, limit: 1 }, - actions: [ - { type: `put`, id: 5, rank: -1 }, - { type: `window`, offset: 1, limit: 3 }, - ], - } - const expected = [ - { id: 1, rank: 0 }, - { id: 2, rank: 1 }, - { id: 3, rank: 2 }, - ] - - expect( - isKnownOrderedSubscriptionCoverageFailure( - scenario, - assertionDifference(2, [expected[2]!], expected), - ), - ).toBe(false) - }) - - it(`rejects excessive suffix loss from the on-demand classifier`, () => { - const scenario: PaginationScenario = { - ranks: [0, 1, 2, 3], - direction: `asc`, - windows: [ - { offset: 0, limit: 1 }, - { offset: 1, limit: 3 }, - ], - } - - expect( - isKnownOnDemandOffsetUnderfetch( - scenario, - assertionDifference(1, [2], [2, 3, 4]), - ), - ).toBe(false) - }) - - it(`rejects a corrupted expectation from the on-demand classifier`, () => { - const scenario: PaginationScenario = { - ranks: [0, 0, 0, 0, 0, 0, 1], - direction: `asc`, - windows: [ - { offset: 0, limit: 1 }, - { offset: 2, limit: 5 }, - ], - } - - expect( - isKnownOnDemandOffsetUnderfetch( - scenario, - assertionDifference(1, [3, 4, 5, 6], [3, 4, 5, 6, 99]), - ), - ).toBe(false) - }) - it(`discovered trace: a row moving across an offset window must refill its boundary`, async () => { const scenario: PaginationStateScenario = { ranks: [0, 0, 0], @@ -2493,14 +2693,7 @@ describe(`pagination recomputation oracle`, () => { initialWindow: { offset: 1, limit: 1 }, actions: [{ type: `put`, id: 1, rank: -1 }], } - await expectAssertionFailure(runPaginationStateScenario, { - checkpoint: 1, - classify: ({ actual, expected }) => - isPageRowArray(actual) && - isPageRowArray(expected) && - sameRows(actual, [{ id: 1, rank: -1 }]) && - sameRows(expected, [{ id: 3, rank: 0 }]), - })(scenario) + await runPaginationStateScenario(scenario) }) it(`retains authoritative rows when a later window admits a prior insert`, async () => { @@ -2513,21 +2706,7 @@ describe(`pagination recomputation oracle`, () => { { type: `window`, offset: 0, limit: 3 }, ], } - await expectAssertionFailure(runPaginationStateScenario, { - checkpoint: 2, - classify: ({ actual, expected }) => - isPageRowArray(actual) && - isPageRowArray(expected) && - sameRows(actual, [ - { id: 1, rank: 0 }, - { id: 3, rank: 1 }, - ]) && - sameRows(expected, [ - { id: 1, rank: 0 }, - { id: 2, rank: 0 }, - { id: 3, rank: 1 }, - ]), - })(scenario) + await runPaginationStateScenario(scenario) }) it(`restores an out-of-window insert when a later offset selects it`, async () => { @@ -2540,14 +2719,7 @@ describe(`pagination recomputation oracle`, () => { { type: `window`, offset: 2, limit: 1 }, ], } - await expectAssertionFailure(runPaginationStateScenario, { - checkpoint: 2, - classify: ({ actual, expected }) => - isPageRowArray(actual) && - actual.length === 0 && - isPageRowArray(expected) && - sameRows(expected, [{ id: 3, rank: 1 }]), - })(scenario) + await runPaginationStateScenario(scenario) }) it(`restores an out-of-window rank update when a later offset selects it`, async () => { @@ -2560,14 +2732,7 @@ describe(`pagination recomputation oracle`, () => { { type: `window`, offset: 2, limit: 1 }, ], } - await expectAssertionFailure(runPaginationStateScenario, { - checkpoint: 2, - classify: ({ actual, expected }) => - isPageRowArray(actual) && - actual.length === 0 && - isPageRowArray(expected) && - sameRows(expected, [{ id: 2, rank: 1 }]), - })(scenario) + await runPaginationStateScenario(scenario) }) it(`discovered trace: inserting at an empty offset boundary refills the window`, async () => { @@ -2581,14 +2746,7 @@ describe(`pagination recomputation oracle`, () => { { type: `put`, id: 4, rank: 1 }, ], } - await expectAssertionFailure(runPaginationStateScenario, { - checkpoint: 3, - classify: ({ actual, expected }) => - isPageRowArray(actual) && - actual.length === 0 && - isPageRowArray(expected) && - sameRows(expected, [{ id: 4, rank: 1 }]), - })(scenario) + await runPaginationStateScenario(scenario) }) it(`discovered trace: an insert before a later offset does not skip its new boundary`, async () => { @@ -2602,14 +2760,7 @@ describe(`pagination recomputation oracle`, () => { ], } - await expectAssertionFailure(runPaginationStateScenario, { - checkpoint: 2, - classify: ({ actual, expected }) => - isPageRowArray(actual) && - sameRows(actual, [{ id: 9, rank: 1 }]) && - isPageRowArray(expected) && - sameRows(expected, [{ id: 8, rank: 1 }]), - })(scenario) + await runPaginationStateScenario(scenario) }) it(`discovered trace: an async cursor loads the full offset window`, async () => { @@ -2621,14 +2772,7 @@ describe(`pagination recomputation oracle`, () => { { offset: 2, limit: 5 }, ], } - await expectAssertionFailure(runOnDemandPaginationScenario, { - checkpoint: 1, - classify: ({ actual, expected }) => - isNumberArray(actual) && - isNumberArray(expected) && - actual.join(`,`) === `3,4,5,6` && - expected.join(`,`) === `3,4,5,6,7`, - })(scenario) + await runOnDemandPaginationScenario(scenario) }) it(`discovered trace: an async cursor crosses an offset before filling one row`, async () => { @@ -2640,14 +2784,7 @@ describe(`pagination recomputation oracle`, () => { { offset: 2, limit: 1 }, ], } - await expectAssertionFailure(runOnDemandPaginationScenario, { - checkpoint: 1, - classify: ({ actual, expected }) => - isNumberArray(actual) && - actual.length === 0 && - isNumberArray(expected) && - expected.join(`,`) === `2`, - })(scenario) + await runOnDemandPaginationScenario(scenario) }) fcTest.prop([scenarioArbitrary], { @@ -2668,7 +2805,7 @@ describe(`pagination recomputation oracle`, () => { seed: 1658, })( `matches full recomputation across source and window transitions for a fixed seed`, - runPaginationStateScenarioWithKnownFailures, + runPaginationStateScenario, ) fcTest.prop( @@ -2676,7 +2813,7 @@ describe(`pagination recomputation oracle`, () => { oracleRandomParameters(transitionScenarioRuns, replaySeed), )( `matches full recomputation across source and window transitions for a random or replayed seed`, - runPaginationStateScenarioWithKnownFailures, + runPaginationStateScenario, ) it(`discovered trace: a rank update must refill a top-1 window`, async () => { @@ -2686,17 +2823,7 @@ describe(`pagination recomputation oracle`, () => { initialWindow: { offset: 0, limit: 1 }, actions: [{ type: `put`, id: 1, rank: 1 }], } - const staleMembership = [{ id: 1, rank: 1 }] - const expected = [{ id: 2, rank: 0 }] - - await expectAssertionFailure(runPaginationStateScenario, { - checkpoint: 1, - classify: (difference) => - isPageRowArray(difference.actual) && - isPageRowArray(difference.expected) && - sameRows(difference.actual, staleMembership) && - sameRows(difference.expected, expected), - })(scenario) + await runPaginationStateScenario(scenario) }) it(`ignores an out-of-window insert when refilling after a delete`, async () => { @@ -2709,25 +2836,7 @@ describe(`pagination recomputation oracle`, () => { { type: `delete`, id: 2 }, ], } - const defective = [ - { id: 1, rank: 100 }, - { id: 3, rank: 80 }, - { id: 5, rank: 10 }, - ] - const expected = [ - { id: 1, rank: 100 }, - { id: 3, rank: 80 }, - { id: 4, rank: 70 }, - ] - - await expectAssertionFailure(runPaginationStateScenario, { - checkpoint: 2, - classify: (difference) => - isPageRowArray(difference.actual) && - isPageRowArray(difference.expected) && - sameRows(difference.actual, defective) && - sameRows(difference.expected, expected), - })(scenario) + await runPaginationStateScenario(scenario) }) it(`ignores an out-of-window rank update when refilling after a delete`, async () => { @@ -2741,14 +2850,7 @@ describe(`pagination recomputation oracle`, () => { ], } - await expectAssertionFailure(runPaginationStateScenario, { - checkpoint: 2, - classify: ({ actual, expected }) => - isPageRowArray(actual) && - sameRows(actual, [{ id: 2, rank: 1 }]) && - isPageRowArray(expected) && - sameRows(expected, [{ id: 3, rank: 0 }]), - })(scenario) + await runPaginationStateScenario(scenario) }) it(`ignores an out-of-window rank update when the visible row leaves`, async () => { @@ -2762,14 +2864,7 @@ describe(`pagination recomputation oracle`, () => { ], } - await expectAssertionFailure(runPaginationStateScenario, { - checkpoint: 2, - classify: ({ actual, expected }) => - isPageRowArray(actual) && - sameRows(actual, [{ id: 2, rank: 1 }]) && - isPageRowArray(expected) && - sameRows(expected, [{ id: 3, rank: 0 }]), - })(scenario) + await runPaginationStateScenario(scenario) }) it(`refills untouched rows when widening after an out-of-window rank update`, async () => { @@ -2783,21 +2878,7 @@ describe(`pagination recomputation oracle`, () => { ], } - await expectAssertionFailure(runPaginationStateScenario, { - checkpoint: 2, - classify: ({ actual, expected }) => - isPageRowArray(actual) && - sameRows(actual, [ - { id: 1, rank: 0 }, - { id: 2, rank: -1 }, - ]) && - isPageRowArray(expected) && - sameRows(expected, [ - { id: 1, rank: 0 }, - { id: 3, rank: 0 }, - { id: 2, rank: -1 }, - ]), - })(scenario) + await runPaginationStateScenario(scenario) }) it(`rebuilds the full boundary when widening after an out-of-window rank update`, async () => { @@ -2811,24 +2892,7 @@ describe(`pagination recomputation oracle`, () => { ], } - await expectAssertionFailure(runPaginationStateScenario, { - checkpoint: 2, - classify: ({ actual, expected }) => - isPageRowArray(actual) && - sameRows(actual, [ - { id: 1, rank: 1 }, - { id: 2, rank: 0 }, - { id: 3, rank: 0 }, - { id: 4, rank: 0 }, - ]) && - isPageRowArray(expected) && - sameRows(expected, [ - { id: 1, rank: 1 }, - { id: 5, rank: 1 }, - { id: 2, rank: 0 }, - { id: 3, rank: 0 }, - ]), - })(scenario) + await runPaginationStateScenario(scenario) }) it(`ignores an out-of-window insert when widening a tied window`, async () => { @@ -2841,44 +2905,250 @@ describe(`pagination recomputation oracle`, () => { { type: `window`, offset: 0, limit: 2 }, ], } - const defective = [ - { id: 1, rank: 0 }, - { id: 3, rank: 0 }, - ] - const expected = [ + await runPaginationStateScenario(scenario) + }) + + it(`expands a multi-column boundary before choosing top-K`, async () => { + await expectMultiOrderBoundaryMatches() + }) + + it(`expands a provider tie before applying the public-key tie-breaker`, async () => { + const loads = await runAdversarialOrderedProviderScenario({ + providerRows: [ + { id: 2, rank: 0, label: `second` }, + { id: 1, rank: 0, label: `first` }, + { id: 3, rank: 1, label: `third` }, + ], + order: { kind: `rank`, direction: `asc`, nulls: `first` }, + limit: 1, + expectedIds: [1], + }) + + expect(loads).toHaveLength(2) + expect(loads[1]?.cursor).toBeDefined() + }) + + it(`does not derive an ordered boundary from another demand's local row`, async () => { + const unrelated = { id: 100, rank: 100, label: `unrelated` } + const loads = await runAdversarialOrderedProviderScenario({ + providerRows: [ + { id: 1, rank: 1, label: `first` }, + { id: 2, rank: 2, label: `second` }, + unrelated, + ], + initialRows: [unrelated], + order: { kind: `rank`, direction: `asc`, nulls: `first` }, + limit: 1, + expectedIds: [1], + }) + + expect(loads[0]?.offset).toBe(0) + expect(loads[0]?.cursor).toBeUndefined() + }) + + it(`refines an initial locale window without trusting provider collation`, async () => { + const loads = await runAdversarialOrderedProviderScenario({ + // Lexical provider order disagrees with locale numeric order. + providerRows: [ + { id: 2, rank: 0, label: `item10` }, + { id: 1, rank: 0, label: `item2` }, + ], + order: { kind: `locale` }, + limit: 1, + expectedIds: [1], + useOffsetWhenAvailable: true, + }) + + expect(loads).toHaveLength(2) + expect(loads[1]?.limit).toBeUndefined() + expect(loads[1]?.offset).toBeUndefined() + }) + + it.each([`continues`, `unknown`] as const)( + `does not treat an unbounded capped locale request as full coverage when extent is %s`, + async (reportedExtent) => { + const loads = await runAdversarialOrderedProviderScenario({ + providerRows: [ + { id: 1, rank: 0, label: `item2` }, + { id: 2, rank: 0, label: `item10` }, + { id: 3, rank: 0, label: `item11` }, + ], + order: { kind: `locale` }, + limit: 1, + expectedIds: [1], + providerPageCap: 1, + reportedExtent, + widenTo: 2, + expectNoProgress: true, + }) + + expect(loads[1]?.limit).toBeUndefined() + expect(loads[1]?.offset).toBeUndefined() + expect(loads.map(({ limit }) => limit)).toEqual([1, undefined, undefined]) + }, + ) + + it(`refines an initial reference-ordered window locally`, async () => { + const first = { value: `first` } + const second = { value: `second` } + // Fix their runtime reference order before the provider returns the + // opposite prefix. + makeComparator({ direction: `asc`, nulls: `first` })(first, second) + + const loads = await runAdversarialOrderedProviderScenario({ + providerRows: [ + { id: 2, rank: second, label: `second` }, + { id: 1, rank: first, label: `first` }, + ], + order: { kind: `reference` }, + limit: 1, + expectedIds: [1], + useOffsetWhenAvailable: true, + }) + + expect(loads).toHaveLength(2) + expect(loads[1]?.limit).toBeUndefined() + expect(loads[1]?.offset).toBeUndefined() + }) + + it.each( + ([`asc`, `desc`] as const).flatMap((direction) => + ([`first`, `last`] as const).map((nulls) => ({ direction, nulls })), + ), + )( + `refines invalid Date ties with an unbounded local-order request ($direction, nulls $nulls)`, + async ({ direction, nulls }) => { + const invalid = new Date(Number.NaN) + const loads = await runAdversarialOrderedProviderScenario({ + providerRows: [ + { id: 2, rank: invalid, label: `second` }, + { id: 1, rank: invalid, label: `first` }, + ], + order: { kind: `reference`, direction, nulls }, + limit: 1, + expectedIds: [1], + useOffsetWhenAvailable: true, + }) + + expect(loads).toHaveLength(2) + expect(loads[1]?.limit).toBeUndefined() + expect(loads[1]?.offset).toBeUndefined() + }, + ) + + it(`keeps ascending public-key ties when reusing a descending index`, async () => { + const rows: Array = [ + { id: 3, rank: 1 }, { id: 1, rank: 0 }, { id: 2, rank: 0 }, ] + const source = createCollection( + mockSyncCollectionOptions({ + id: `pagination-reversed-index-ties-${collectionSequence++}`, + initialData: rows, + getKey: (row: PageRow) => row.id, + }), + ) + source.createIndex((row) => row.rank, { + indexType: BTreeIndex, + options: { + compareOptions: { + direction: `asc`, + nulls: `first`, + stringSort: `locale`, + }, + }, + }) + const live = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank, `desc`) + .limit(2), + ) - await expectAssertionFailure(runPaginationStateScenario, { - checkpoint: 2, - classify: (difference) => - isPageRowArray(difference.actual) && - isPageRowArray(difference.expected) && - sameRows(difference.actual, defective) && - sameRows(difference.expected, expected), - })(scenario) + try { + await live.preload() + expect(Array.from(live.values(), ({ id }) => id)).toEqual([3, 1]) + } finally { + live.cleanup() + source.cleanup() + } }) - it(`expands a multi-column boundary before choosing top-K`, async () => { - await expectAssertionFailure(expectMultiOrderBoundaryMatches, { - checkpoint: 0, - classify: ({ actual, expected }) => - Array.isArray(actual) && - actual.every((value) => typeof value === `number`) && - Array.isArray(expected) && - expected.every((value) => typeof value === `number`) && - actual.join(`,`) === `2,3,1,4` && - expected.join(`,`) === `2,3,1,5`, - })() + it.each([{ ids: [1, Number.NaN] }, { ids: [Number.NaN, 1] }])( + `keeps finite public keys before NaN across insertion order`, + async ({ ids }) => { + const source = createCollection( + mockSyncCollectionOptions({ + id: `pagination-nan-key-order-${collectionSequence++}`, + initialData: ids.map((id) => ({ id, rank: 0 })), + getKey: (row: PageRow) => row.id, + autoIndex: `eager`, + }), + ) + const live = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank, `asc`) + .limit(1), + ) + + try { + await live.preload() + expect(Array.from(live.values(), ({ id }) => id)).toEqual([1]) + } finally { + live.cleanup() + source.cleanup() + } + }, + ) + + it(`stabilizes an on-demand window with a NaN public-key tie`, async () => { + const loads = await runAdversarialOrderedProviderScenario({ + providerRows: [ + { id: 1, rank: 0, label: `finite` }, + { id: Number.NaN, rank: 0, label: `nan` }, + ], + order: { kind: `rank`, direction: `asc`, nulls: `first` }, + limit: 1, + expectedIds: [1], + }) + + expect(loads).toHaveLength(2) }) + it.each([ + { direction: `asc`, nulls: `first`, expectedIds: [1, 2] }, + { direction: `asc`, nulls: `last`, expectedIds: [2, 3] }, + { direction: `desc`, nulls: `first`, expectedIds: [1, 3] }, + { direction: `desc`, nulls: `last`, expectedIds: [3, 2] }, + ] as const)( + `keeps null placement and $direction across source refinement ($nulls)`, + async ({ direction, nulls, expectedIds }) => { + const providerRows = [ + { id: 1, rank: null, label: `null` }, + { id: 2, rank: 0, label: `zero` }, + { id: 3, rank: 1, label: `one` }, + ].sort( + (left, right) => + compareNullableNumber(left.rank, right.rank, { direction, nulls }) || + left.id - right.id, + ) + await runAdversarialOrderedProviderScenario({ + providerRows, + order: { kind: `rank`, direction, nulls }, + limit: 2, + expectedIds, + }) + }, + ) + fcTest.prop([scenarioArbitrary], { numRuns: transitionScenarioRuns, seed: 1659, })( `matches full recomputation when exact async cursor loads widen ordered coverage for a fixed seed`, - runOnDemandPaginationScenarioWithKnownFailures, + runOnDemandPaginationScenario, ) fcTest.prop( @@ -2886,7 +3156,7 @@ describe(`pagination recomputation oracle`, () => { oracleRandomParameters(transitionScenarioRuns, replaySeed), )( `matches full recomputation when exact async cursor loads widen ordered coverage for a random or replayed seed`, - runOnDemandPaginationScenarioWithKnownFailures, + runOnDemandPaginationScenario, ) it.each([`forward`, `reverse`] as const)( diff --git a/packages/db/tests/query/subset-error-matrix.test.ts b/packages/db/tests/query/subset-error-matrix.test.ts index c161f2465..a7fbe8764 100644 --- a/packages/db/tests/query/subset-error-matrix.test.ts +++ b/packages/db/tests/query/subset-error-matrix.test.ts @@ -8,6 +8,7 @@ type Delivery = `throw` | `reject` type Consumer = `effect` | `live` type StartupPath = `direct` | `ordered` | `lazy` type IncrementalPath = Exclude +type FailureValue = `error` | `nan` | `undefined` type Row = { id: number @@ -22,6 +23,16 @@ type FailureCase = { delivery: Delivery } +type IncrementalFailureCase = FailureCase & { + failureValue: FailureValue +} + +type CleanupFailureCase = { + name: string + consumer: Consumer + failure: unknown +} + const row: Row = { id: 1, rank: 1, parentId: 1 } // Every query form can fail while it acquires initial coverage. @@ -40,25 +51,42 @@ const startupCases: ReadonlyArray> = ( // Direct queries have no automatic later demand. Ordered refills and lazy // relationship routes do, so only those paths have incremental cells. -const incrementalCases: ReadonlyArray> = ( +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, - })), + ([`throw`, `reject`] as const).flatMap((delivery) => + ([`error`, `nan`, `undefined`] as const).map((failureValue) => ({ + name: `${consumer} ${path} ${delivery} ${failureValue}`, + consumer, + path, + delivery, + failureValue, + })), + ), ), ) -function fail(delivery: Delivery, error: Error): Promise { +const cleanupFailureObject = { kind: `cleanup-failure` } +const cleanupFailureCases: ReadonlyArray = ( + [`effect`, `live`] as const +).flatMap((consumer) => [ + { name: `${consumer} undefined`, consumer, failure: undefined }, + { name: `${consumer} NaN`, consumer, failure: Number.NaN }, + { name: `${consumer} object`, consumer, failure: cleanupFailureObject }, +]) + +function fail(delivery: Delivery, error: unknown): Promise { if (delivery === `throw`) throw error return Promise.reject(error) } -function createFailingSource(id: string, delivery: Delivery, error: Error) { +function createFailingSource( + id: string, + delivery: Delivery, + error: unknown, + onLoad = () => {}, +) { return createCollection({ id, getKey: (item) => item.id, @@ -69,7 +97,10 @@ function createFailingSource(id: string, delivery: Delivery, error: Error) { sync: ({ markReady }) => { markReady() return { - loadSubset: () => fail(delivery, error), + loadSubset: () => { + onLoad() + return fail(delivery, error) + }, } }, }, @@ -218,18 +249,23 @@ describe(`loadSubset failure matrix`, () => { 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}` + async ({ consumer, path, delivery, failureValue }) => { + const error: unknown = + failureValue === `nan` + ? Number.NaN + : failureValue === `undefined` + ? undefined + : new Error(`${consumer} ${path} incremental failed`) + const suffix = `${consumer}-${path}-${delivery}-${failureValue}` let triggerFailure: () => void let primary: RowCollection let child: RowCollection + let loadCount = 0 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, @@ -270,6 +306,7 @@ describe(`loadSubset failure matrix`, () => { `failure-matrix-incremental-child-${suffix}`, delivery, error, + () => loadCount++, ) triggerFailure = () => { primary.utils.begin() @@ -286,7 +323,12 @@ describe(`loadSubset failure matrix`, () => { triggerFailure() await flushFailures() - expect(sourceErrors).toEqual([error]) + expect(sourceErrors).toHaveLength(1) + if (failureValue === `error`) { + expect(sourceErrors[0]).toBe(error) + } else { + expect(sourceErrors[0]).toBeInstanceOf(Error) + } expect(effect.disposed).toBe(true) } finally { await effect.dispose() @@ -299,12 +341,14 @@ describe(`loadSubset failure matrix`, () => { await flushFailures() expect(live.status).toBe(path === `lazy` ? `error` : `ready`) - expect(live.utils.lastSubsetError).toBe(error) + expect(Object.is(live.utils.lastSubsetError, error)).toBe(true) } finally { await live.cleanup() } } + expect(loadCount).toBe(path === `ordered` ? 2 : 1) + expect(primary.subscriberCount).toBe(0) if (path === `lazy`) expect(child.subscriberCount).toBe(0) } finally { @@ -316,4 +360,90 @@ describe(`loadSubset failure matrix`, () => { } }, ) + + it.each(cleanupFailureCases)( + `does not mistake an unreported cleanup failure for a source error: $name`, + async ({ consumer, failure }) => { + const suffix = `${consumer}-${ + failure === undefined + ? `undefined` + : typeof failure === `number` + ? `nan` + : `object` + }` + const parent = createStaticSource(`cleanup-failure-parent-${suffix}`, [ + row, + ]) + let unloadCount = 0 + const child = createCollection({ + id: `cleanup-failure-child-${suffix}`, + getKey: (item) => item.id, + syncMode: `on-demand`, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: () => true, + unloadSubset: () => { + unloadCount++ + if (unloadCount === 1) throw failure + }, + } + }, + }, + }) + const sourceErrors: Array = [] + const effect = + consumer === `effect` + ? createEffect({ + query: (q) => + q + .from({ item: parent }) + .leftJoin({ child }, ({ item, child: childRow }) => + eq(item.id, childRow.parentId), + ), + onBatch: () => {}, + onSourceError: (error) => sourceErrors.push(error), + }) + : undefined + const live = + consumer === `live` + ? createLiveQueryCollection((q) => + q + .from({ item: parent }) + .leftJoin({ child }, ({ item, child: childRow }) => + eq(item.id, childRow.parentId), + ), + ) + : undefined + + try { + if (live) await live.preload() + await flushFailures() + + let didThrow = false + let thrown: unknown + try { + parent.utils.begin() + parent.utils.write({ type: `delete`, value: row }) + parent.utils.commit() + } catch (error) { + didThrow = true + thrown = error + } + + expect(didThrow).toBe(true) + expect(Object.is(thrown, failure)).toBe(true) + expect(sourceErrors).toEqual([]) + if (live) expect(live.utils.lastSubsetError).toBeUndefined() + } finally { + if (effect) await effect.dispose() + if (live) await live.cleanup() + expect(unloadCount).toBe(2) + await Promise.all([parent.cleanup(), child.cleanup()]) + } + }, + ) }) diff --git a/packages/db/tests/query/total-order.test.ts b/packages/db/tests/query/total-order.test.ts new file mode 100644 index 000000000..5fd59990e --- /dev/null +++ b/packages/db/tests/query/total-order.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { PropRef } from '../../src/query/ir.js' +import { TotalOrder } from '../../src/query/total-order.js' +import type { CollectionLike } from '../../src/types.js' + +type Row = { + rank: number | null + label: string +} + +const collection = { + compareOptions: { stringSort: `lexical` as const }, +} as CollectionLike + +describe(`TotalOrder`, () => { + it(`orders every term before the public-key tie-breaker`, () => { + const order = new TotalOrder( + [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `last` }, + }, + { + expression: new PropRef([`label`]), + compareOptions: { + direction: `asc`, + nulls: `first`, + stringSort: `locale`, + locale: `en-US`, + localeOptions: { numeric: true }, + }, + }, + ], + collection, + ) + const rows: Array = [ + [9, { rank: 0, label: `item10` }], + [4, { rank: 0, label: `item2` }], + [2, { rank: 0, label: `item2` }], + [1, { rank: null, label: `item1` }], + ] + + expect( + rows.sort(order.compareEntries.bind(order)).map(([key]) => key), + ).toEqual([2, 4, 9, 1]) + }) + + it(`uses the same comparison for rows and stored boundaries`, () => { + const order = new TotalOrder( + [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `desc`, nulls: `first` }, + }, + ], + collection, + ) + const left: readonly [number, Row] = [2, { rank: 3, label: `a` }] + const right: readonly [number, Row] = [1, { rank: 3, label: `b` }] + + expect(order.compareEntries(left, right)).toBe( + order.compareBoundary( + order.boundary(left[1], left[0]), + order.boundary(right[1], right[0]), + ), + ) + }) + + it(`orders NaN public keys apart from finite numeric keys`, () => { + const order = new TotalOrder( + [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ], + collection, + ) + const finite: readonly [number, Row] = [1, { rank: 0, label: `finite` }] + const notANumber: readonly [number, Row] = [ + Number.NaN, + { rank: 0, label: `nan` }, + ] + + expect(order.compareEntries(finite, notANumber)).not.toBe(0) + expect(order.compareEntries(notANumber, finite)).toBe( + -order.compareEntries(finite, notANumber), + ) + }) +}) diff --git a/packages/db/tests/query/window-state.test.ts b/packages/db/tests/query/window-state.test.ts new file mode 100644 index 000000000..2828c5baf --- /dev/null +++ b/packages/db/tests/query/window-state.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from 'vitest' +import { PropRef } from '../../src/query/ir.js' +import { WindowState } from '../../src/query/live/window-state.js' +import type { CollectionImpl } from '../../src/collection/index.js' +import type { ChangeMessage } from '../../src/types.js' + +type Row = { id: number; rank: number | null } + +function mockCollection(rows: ReadonlyArray): CollectionImpl { + const changes = rows.map( + (value): ChangeMessage => ({ + type: `insert`, + key: value.id, + value, + }), + ) + return { + compareOptions: { stringSort: `lexical` }, + currentStateAsChanges: () => changes, + entries: () => rows.map((row) => [row.id, row] as const)[Symbol.iterator](), + } as unknown as CollectionImpl +} + +describe(`WindowState`, () => { + it(`retains live changes that arrive before initial coverage settles`, () => { + const rows = [ + { id: 1, rank: 1 }, + { id: 2, rank: 2 }, + { id: 3, rank: 3 }, + ] + const window = new WindowState( + mockCollection(rows), + [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ], + undefined, + 2, + ) + + window.admitChanges( + rows.slice(1).map((value) => ({ type: `insert`, key: value.id, value })), + ) + window.admitChanges([{ type: `insert`, key: 1, value: rows[0]! }]) + window.recordInitialCoverage([2, 3], false) + + expect(window.requestBoundary()).toEqual({ key: 2, values: [2] }) + expect(window.requiresPrefixRefresh).toBe(true) + }) + + it(`tracks changes that enter retained coverage while the active window is narrow`, () => { + const rows = [ + { id: 1, rank: 1 }, + { id: 2, rank: 2 }, + { id: 7, rank: 2.5 }, + { id: 3, rank: 3 }, + ] + const window = new WindowState( + mockCollection(rows), + [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ], + undefined, + 3, + ) + + window.recordInitialCoverage([1, 2, 3], false) + window.recordContinuationCoverage([], false, 3, window.coverageRevision) + window.ensureSize(1) + window.admitChanges([{ type: `insert`, key: 7, value: rows[2]! }]) + window.ensureSize(3) + + expect(window.reconcile(new Map()).map(({ key }) => key)).toEqual([1, 2, 7]) + expect(window.requiresPrefixRefresh).toBe(true) + }) + + it(`does not reuse an ordered boundary across truncate generations`, () => { + const window = new WindowState( + mockCollection([ + { id: 1, rank: 1 }, + { id: 2, rank: 2 }, + { id: 3, rank: 3 }, + { id: 4, rank: 4 }, + ]), + [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ], + undefined, + 2, + ) + + window.recordInitialCoverage([1], false) + window.recordContinuationCoverage([2], false, 2, window.coverageRevision) + expect(window.requestBoundary()).toEqual({ key: 2, values: [2] }) + + window.resetCoverage() + expect(window.requestBoundary()).toBeUndefined() + + window.recordInitialCoverage([3], false) + window.recordContinuationCoverage([4], false, 2, window.coverageRevision) + expect(window.requestBoundary()).toEqual({ key: 4, values: [4] }) + }) + + it(`does not promote continuation coverage across a window revision`, () => { + const rows = [ + { id: 2, rank: 0 }, + { id: 1, rank: 1 }, + { id: 3, rank: 2 }, + ] + const window = new WindowState( + mockCollection(rows), + [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ], + undefined, + 1, + ) + + window.recordInitialCoverage([1], false) + window.recordContinuationCoverage([1], false, 1, window.coverageRevision) + const requestRevision = window.coverageRevision + + window.admitChanges([{ type: `insert`, key: rows[0]!.id, value: rows[0]! }]) + window.recordContinuationCoverage([3], false, 2, requestRevision) + + expect(window.coverageRevision).toBeGreaterThan(requestRevision) + expect(window.coversActiveWindow).toBe(false) + expect(window.requiresPrefixRefresh).toBe(true) + }) + + it.each([ + { extent: `continues`, rowKeys: [1, 2] }, + { extent: `unknown`, rowKeys: undefined }, + ] as const)( + `does not establish full coverage from $extent continuation evidence`, + ({ rowKeys }) => { + const window = new WindowState( + mockCollection([ + { id: 1, rank: 1 }, + { id: 2, rank: 2 }, + ]), + [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ], + undefined, + 1, + ) + + window.recordInitialCoverage([1], false) + window.recordContinuationCoverage( + rowKeys, + false, + 2, + window.coverageRevision, + ) + window.ensureSize(3) + + expect(window.coversActiveWindow).toBe(false) + }, + ) + + it.each([ + { direction: `asc`, nulls: `first`, expected: { key: 3, values: [2] } }, + { direction: `asc`, nulls: `last`, expected: { key: 1, values: [null] } }, + { direction: `desc`, nulls: `first`, expected: { key: 2, values: [1] } }, + { direction: `desc`, nulls: `last`, expected: { key: 1, values: [null] } }, + ] as const)( + `keeps a failed replay boundary on the last complete publication ($direction, nulls $nulls)`, + ({ direction, nulls, expected }) => { + const window = new WindowState( + mockCollection([{ id: 2, rank: 100 }]), + [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction, nulls }, + }, + ], + undefined, + 1, + ) + const lastCompletePublication = new Map([ + [1, { id: 1, rank: null }], + [2, { id: 2, rank: 1 }], + [3, { id: 3, rank: 2 }], + ]) + + expect(window.boundary(lastCompletePublication)).toEqual(expected) + }, + ) + + it.each( + ([`asc`, `desc`] as const).flatMap((direction) => + ([`first`, `last`] as const).flatMap((nulls) => + [2, 4].map((requestedPrefix) => ({ + direction, + nulls, + requestedPrefix, + })), + ), + ), + )( + `keeps outcome-free satisfaction local ($direction, nulls $nulls, prefix $requestedPrefix)`, + ({ direction, nulls, requestedPrefix }) => { + const window = new WindowState( + mockCollection([ + { id: 1, rank: null }, + { id: 2, rank: 1 }, + { id: 3, rank: 2 }, + ]), + [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction, nulls }, + }, + ], + undefined, + requestedPrefix, + ) + + window.recordLocalRequestSatisfaction(requestedPrefix) + + expect(window.localPrefixSize).toBe(Math.min(requestedPrefix, 3)) + expect(window.coversActiveWindow).toBe(requestedPrefix <= 3) + expect(window.requestBoundary()).toBeUndefined() + expect(window.progressBoundary()?.key).toBe(Math.min(requestedPrefix, 3)) + expect(window.requiresPrefixRefresh).toBe(true) + }, + ) +}) diff --git a/packages/db/tests/reference-expression.ts b/packages/db/tests/reference-expression.ts index 51716b901..b0cf19a3d 100644 --- a/packages/db/tests/reference-expression.ts +++ b/packages/db/tests/reference-expression.ts @@ -43,6 +43,10 @@ export function evaluateReferenceExpression( return args.some(Boolean) case `not`: return !args[0] + case `isNull`: + return args[0] === null + case `isUndefined`: + return args[0] === undefined case `eq`: return args[0] === args[1] case `gt`: diff --git a/packages/electric-db-collection/tests/electric-live-query.test.ts b/packages/electric-db-collection/tests/electric-live-query.test.ts index 8bd5ac7b8..572fb90be 100644 --- a/packages/electric-db-collection/tests/electric-live-query.test.ts +++ b/packages/electric-db-collection/tests/electric-live-query.test.ts @@ -1,11 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { + BasicIndex, createCollection, createLiveQueryCollection, eq, gt, lt, - BasicIndex, } from '@tanstack/db' import { electricCollectionOptions } from '../src/electric' import type { ElectricCollectionUtils } from '../src/electric' @@ -1206,9 +1206,9 @@ describe(`Electric Collection - loadSubset deduplication`, () => { // Wait for the existing live query to re-request data after truncate await new Promise((resolve) => setTimeout(resolve, 0)) - // The existing live query re-requests its data after truncate - // After must-refetch, the query requests data again (1 initial + 1 after truncate) - expect(mockRequestSnapshot).toHaveBeenCalledTimes(2) + // Truncate replays the exact demand once. Electric does not yet return an + // applied outcome, so the empty local prefix then requests one refill. + expect(mockRequestSnapshot).toHaveBeenCalledTimes(3) // Create the same live query again after reset // This should NOT be deduped because the reset cleared the deduplication state, @@ -1227,8 +1227,8 @@ describe(`Electric Collection - loadSubset deduplication`, () => { await new Promise((resolve) => setTimeout(resolve, 0)) // Should have more calls - the different query triggered a new request - // 1 initial + 1 after must-refetch + 1 for new query = 3 - expect(mockRequestSnapshot).toHaveBeenCalledTimes(3) + // 1 initial + 1 replay + 1 outcome-free refill + 1 new query = 4 + expect(mockRequestSnapshot).toHaveBeenCalledTimes(4) }) it(`should deduplicate unlimited queries regardless of orderBy`, async () => { diff --git a/packages/electric-db-collection/tests/electric.test.ts b/packages/electric-db-collection/tests/electric.test.ts index c913f8d97..552cbd802 100644 --- a/packages/electric-db-collection/tests/electric.test.ts +++ b/packages/electric-db-collection/tests/electric.test.ts @@ -2723,7 +2723,7 @@ describe(`Electric Integration`, () => { ) }) - it(`retains Electric coverage when the adapter cannot unload it`, async () => { + it(`invalidates Electric dedupe when core releases its rows`, async () => { const testCollection = createCollection( electricCollectionOptions({ id: `on-demand-unload-coverage-test`, @@ -2743,7 +2743,7 @@ describe(`Electric Integration`, () => { testCollection._sync.unloadSubset(options) await testCollection._sync.loadSubset(options) - expect(mockRequestSnapshot).toHaveBeenCalledTimes(1) + expect(mockRequestSnapshot).toHaveBeenCalledTimes(2) } finally { await testCollection.cleanup() }