From 774f4a39467b3ae95b73a3d846ea33350ca46a7a Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 18 Aug 2026 23:21:07 +0100 Subject: [PATCH 01/18] test(db): add loadSubset and pagination oracles --- AGENTS.md | 7 + .../query/load-subset-join-dedupe.test.ts | 148 ++ .../query/load-subset-oracle.property.test.ts | 726 +++++++++ .../tests/query/load-subset-subquery.test.ts | 153 +- .../query/pagination-oracle.property.test.ts | 1377 +++++++++++++++++ .../tests/electric.test.ts | 41 + .../load-subset-lifecycle-oracle.test.ts | 249 +++ .../tests/trailbase.test.ts | 46 +- 8 files changed, 2744 insertions(+), 3 deletions(-) create mode 100644 packages/db/tests/query/load-subset-join-dedupe.test.ts create mode 100644 packages/db/tests/query/load-subset-oracle.property.test.ts create mode 100644 packages/db/tests/query/pagination-oracle.property.test.ts create mode 100644 packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts diff --git a/AGENTS.md b/AGENTS.md index a92ff46761..683d6d749b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -368,6 +368,13 @@ test('ignores snapshot that resolves after up-to-date message', async () => { }) ``` +### Name Tests After Behavior + +Test names should state the behavior they prove. Do not put issue or pull +request numbers in test names; those references become stale and make the test +suite harder to read. When an external report contains essential context that +the test cannot express, link it in a nearby comment instead. + ### Test Corner Cases Common corner cases to consider: diff --git a/packages/db/tests/query/load-subset-join-dedupe.test.ts b/packages/db/tests/query/load-subset-join-dedupe.test.ts new file mode 100644 index 0000000000..5bebdf0b47 --- /dev/null +++ b/packages/db/tests/query/load-subset-join-dedupe.test.ts @@ -0,0 +1,148 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createCollection } from '../../src/collection/index.js' +import { BasicIndex } from '../../src/indexes/basic-index.js' +import { extractSimpleComparisons } from '../../src/query/expression-helpers.js' +import { createLiveQueryCollection, eq } from '../../src/query/index.js' +import { flushPromises } from '../utils.js' +import type { + ChangeMessageOrDeleteKeyMessage, + LoadSubsetOptions, +} from '../../src/types.js' + +type Parent = { id: number; name: string } +type Child = { id: number; parentId: number; title: string } + +const parents = [ + { id: 1, name: `A` }, + { id: 2, name: `B` }, + { id: 3, name: `C` }, +] +const children = [ + { id: 10, parentId: 1, title: `A1` }, + { id: 11, parentId: 1, title: `A2` }, + { id: 20, parentId: 2, title: `B1` }, +] + +let sequence = 0 +const cleanups: Array<() => void> = [] + +function createParents() { + let begin!: () => void + let write!: (message: ChangeMessageOrDeleteKeyMessage) => void + let commit!: () => void + const collection = createCollection({ + id: `join-dedupe-parents-${sequence++}`, + getKey: (parent) => parent.id, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + begin() + for (const parent of parents) write({ type: `insert`, value: parent }) + commit() + params.markReady() + }, + }, + }) + cleanups.push(() => collection.cleanup()) + return { + collection, + insert: (parent: Parent) => { + begin() + write({ type: `insert`, value: parent }) + commit() + }, + } +} + +function createChildren() { + const loads: Array = [] + const collection = createCollection({ + id: `join-dedupe-children-${sequence++}`, + getKey: (child) => child.id, + syncMode: `on-demand`, + autoIndex: `eager`, + defaultIndexType: BasicIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + for (const child of children) write({ type: `insert`, value: child }) + commit() + markReady() + return { + loadSubset: vi.fn((options: LoadSubsetOptions) => { + loads.push(options) + return Promise.resolve() + }), + } + }, + }, + }) + cleanups.push(() => collection.cleanup()) + return { collection, loads } +} + +function createJoinedQuery( + parentCollection: ReturnType[`collection`], + childCollection: ReturnType[`collection`], +) { + const live = createLiveQueryCollection((query) => + query + .from({ parent: parentCollection }) + .join({ child: childCollection }, ({ parent, child }) => + eq(child.parentId, parent.id), + ), + ) + cleanups.push(() => live.cleanup()) + return live +} + +describe(`loadSubset join-key deduplication`, () => { + afterEach(() => { + for (const cleanup of cleanups.splice(0).reverse()) cleanup() + }) + + it(`does not reload the same join predicate on repeated preload`, async () => { + const { collection: parentCollection } = createParents() + const { collection: childCollection, loads } = createChildren() + const live = createJoinedQuery(parentCollection, childCollection) + + await live.preload() + const loadCount = loads.length + expect(loadCount).toBeGreaterThan(0) + + await live.preload() + expect(loads).toHaveLength(loadCount) + }) + + it(`requests only a newly inserted join key`, async () => { + const { collection: parentCollection, insert } = createParents() + const { collection: childCollection, loads } = createChildren() + const live = createJoinedQuery(parentCollection, childCollection) + + await live.preload() + const loadCount = loads.length + + insert({ id: 4, name: `D` }) + await flushPromises() + + const newLoads = loads.slice(loadCount) + expect(newLoads).toHaveLength(1) + + const [load] = newLoads + if (!load) { + throw new Error(`Expected one child transport load`) + } + expect(load).toEqual({ + where: expect.anything(), + orderBy: undefined, + limit: undefined, + subscription: expect.anything(), + }) + expect(load.where).toBeDefined() + expect(extractSimpleComparisons(load.where)).toEqual([ + { field: [`parentId`], operator: `in`, value: [4] }, + ]) + }) +}) diff --git a/packages/db/tests/query/load-subset-oracle.property.test.ts b/packages/db/tests/query/load-subset-oracle.property.test.ts new file mode 100644 index 0000000000..d70ac2f2bf --- /dev/null +++ b/packages/db/tests/query/load-subset-oracle.property.test.ts @@ -0,0 +1,726 @@ +import { fc, test as fcTest } from '@fast-check/vitest' +import { describe, expect, it, vi } from 'vitest' +import { createCollection } from '../../src/collection/index.js' +import { createDeferred } from '../../src/deferred.js' +import { createOptimisticAction } from '../../src/optimistic-action.js' +import { createLiveQueryCollection, eq } from '../../src/query/index.js' +import { DeduplicatedLoadSubset } from '../../src/query/subset-dedupe.js' +import { Func, PropRef, Value } from '../../src/query/ir.js' +import { createTransaction } from '../../src/transactions.js' +import { expectAssertionFailure } from '../expected-failure.js' +import { TraceAssertionError } from '../trace-runner.js' +import type { BasicExpression } from '../../src/query/ir.js' +import type { LoadSubsetOptions } from '../../src/types.js' + +type PredicateSpec = + | { kind: `all` } + | { kind: `eq`; value: number } + | { kind: `in`; values: ReadonlyArray } + | { + kind: `range` + operator: `gt` | `gte` | `lt` | `lte` + value: number + } + +type AsyncScenario = { + first: ReadonlyArray + second: ReadonlyArray + firstOutcome: `resolve` | `reject` + secondOutcome: `resolve` | `reject` + deliveryOrder: `forward` | `reverse` + resetBeforeSettlement: boolean +} + +type RangeOperator = Extract[`operator`] + +type WindowRequest = { + direction: `asc` | `desc` + offset: number + limit: number +} + +type PersistedLoadRow = { + id: string + projectId: string +} + +type OptimisticDerivedRow = { + id: string + value: string +} + +// The generated predicates only compare against integers from -3 through 3. +// These points cover every distinct truth partition: both unbounded tails, +// every equality point, and every open interval between adjacent thresholds. +const valueDomain = [ + -4, -3, -2.5, -2, -1.5, -1, -0.5, 0, 0.5, 1, 1.5, 2, 2.5, 3, 4, +] as const +const scoreRef = new PropRef([`score`]) +const rankRef = new PropRef([`rank`]) + +const predicateSpecArbitrary: fc.Arbitrary = fc.oneof( + { weight: 1, arbitrary: fc.constant({ kind: `all` as const }) }, + { + weight: 3, + arbitrary: fc + .integer({ min: -3, max: 3 }) + .map((value) => ({ kind: `eq` as const, value })), + }, + { + weight: 3, + arbitrary: fc + .uniqueArray(fc.integer({ min: -3, max: 3 }), { + minLength: 1, + maxLength: 7, + }) + .map((values) => ({ kind: `in` as const, values })), + }, + { + weight: 4, + arbitrary: fc.record({ + kind: fc.constant(`range` as const), + operator: fc.constantFrom(`gt`, `gte`, `lt`, `lte`), + value: fc.integer({ min: -3, max: 3 }), + }), + }, +) + +const requestTraceArbitrary = fc.array(predicateSpecArbitrary, { + minLength: 1, + maxLength: 20, +}) + +const inValuesArbitrary = fc.uniqueArray(fc.integer({ min: -3, max: 3 }), { + minLength: 1, + maxLength: 7, +}) + +// A rejected request with an in-flight deduplicated waiter currently creates a +// detached rejected promise inside DeduplicatedLoadSubset. Keep that discovered +// defect out of this green settlement corpus; it is pinned separately below. +const asyncScenarioArbitrary: fc.Arbitrary = fc + .record({ + first: inValuesArbitrary, + second: inValuesArbitrary, + firstOutcome: fc.constantFrom( + `resolve`, + `reject`, + ), + secondOutcome: fc.constantFrom( + `resolve`, + `reject`, + ), + deliveryOrder: fc.constantFrom( + `forward`, + `reverse`, + ), + resetBeforeSettlement: fc.boolean(), + }) + .map((scenario) => + scenario.firstOutcome === `reject` && + scenario.second.every((value) => scenario.first.includes(value)) + ? { ...scenario, firstOutcome: `resolve` } + : scenario, + ) + +const windowRequestArbitrary: fc.Arbitrary = fc.record({ + direction: fc.constantFrom(`asc`, `desc`), + offset: fc.integer({ min: 0, max: 6 }), + limit: fc.integer({ min: 1, max: 6 }), +}) + +const windowTraceArbitrary = fc.array(windowRequestArbitrary, { + minLength: 1, + maxLength: 20, +}) + +function toWhere( + predicate: PredicateSpec, +): BasicExpression | undefined { + switch (predicate.kind) { + case `all`: + return undefined + case `eq`: + return new Func(`eq`, [scoreRef, new Value(predicate.value)]) + case `in`: + return new Func(`in`, [scoreRef, new Value([...predicate.values])]) + case `range`: + return new Func(predicate.operator, [ + scoreRef, + new Value(predicate.value), + ]) + } +} + +function evaluateExpression( + expression: BasicExpression, + score: number, +): unknown { + switch (expression.type) { + case `ref`: + if (expression.path.at(-1) !== `score`) { + throw new Error(`Unsupported reference: ${expression.path.join(`.`)}`) + } + return score + case `val`: + return expression.value + case `func`: { + const args = expression.args.map((argument) => + evaluateExpression(argument, score), + ) + switch (expression.name) { + case `eq`: + return args[0] === args[1] + case `gt`: + return Number(args[0]) > Number(args[1]) + case `gte`: + return Number(args[0]) >= Number(args[1]) + case `lt`: + return Number(args[0]) < Number(args[1]) + case `lte`: + return Number(args[0]) <= Number(args[1]) + case `in`: + if (!Array.isArray(args[1])) { + throw new Error(`IN requires an array`) + } + return args[1].includes(args[0]) + case `and`: + return args.every(Boolean) + case `or`: + return args.some(Boolean) + case `not`: + return !args[0] + default: + throw new Error(`Unsupported predicate function: ${expression.name}`) + } + } + } +} + +function matchingValues( + where: BasicExpression | undefined, +): Set { + return new Set( + valueDomain.filter( + (score) => + where === undefined || evaluateExpression(where, score) === true, + ), + ) +} + +function difference(left: ReadonlySet, right: ReadonlySet) { + return new Set([...left].filter((value) => !right.has(value))) +} + +function isSubset(left: ReadonlySet, right: ReadonlySet) { + return [...left].every((value) => right.has(value)) +} + +function expectSetEqual( + actual: ReadonlySet, + expected: ReadonlySet, +): void { + expect([...actual].sort()).toEqual([...expected].sort()) +} + +function runCoverageTrace(trace: ReadonlyArray): void { + const covered = new Set() + const loads: Array = [] + const dedupe = new DeduplicatedLoadSubset({ + loadSubset: (options) => { + loads.push(options) + return true + }, + }) + + for (const predicate of trace) { + const where = toWhere(predicate) + const requested = matchingValues(where) + const missing = difference(requested, covered) + const loadCountBefore = loads.length + + const result = dedupe.loadSubset({ where }) + + expect(result).toBe(true) + expect(loads.length - loadCountBefore).toBeLessThanOrEqual(1) + if (loads.length === loadCountBefore) { + expect(missing.size).toBe(0) + } else { + expect(loads).toHaveLength(loadCountBefore + 1) + const loaded = matchingValues(loads.at(-1)?.where) + expectSetEqual(difference(missing, loaded), new Set()) + expectSetEqual(difference(loaded, requested), new Set()) + for (const value of loaded) covered.add(value) + } + + expectSetEqual(difference(requested, covered), new Set()) + } +} + +function countLoads(trace: ReadonlyArray): number { + let loads = 0 + const dedupe = new DeduplicatedLoadSubset({ + loadSubset: () => { + loads++ + return true + }, + }) + for (const predicate of trace) { + dedupe.loadSubset({ where: toWhere(predicate) }) + } + return loads +} + +function toWindowOptions(request: WindowRequest): LoadSubsetOptions { + return { + offset: request.offset, + limit: request.limit, + orderBy: [ + { + expression: rankRef, + compareOptions: { + direction: request.direction, + nulls: `last`, + stringSort: `lexical`, + }, + }, + ], + } +} + +function windowPositions(request: WindowRequest): Set { + return new Set( + Array.from({ length: request.limit }, (_, index) => request.offset + index), + ) +} + +function runWindowCoverageTrace(trace: ReadonlyArray): void { + const coveredByOrder = new Map<`asc` | `desc`, Set>([ + [`asc`, new Set()], + [`desc`, new Set()], + ]) + const loads: Array = [] + const dedupe = new DeduplicatedLoadSubset({ + loadSubset: (options) => { + loads.push(options) + return true + }, + }) + + for (const request of trace) { + const requested = windowPositions(request) + const covered = coveredByOrder.get(request.direction)! + const missing = difference(requested, covered) + const callsBefore = loads.length + + dedupe.loadSubset(toWindowOptions(request)) + + expect(loads.length - callsBefore).toBeLessThanOrEqual(1) + if (loads.length === callsBefore) { + expectSetEqual(missing, new Set()) + } else { + const loaded = loads.at(-1)! + expect(loaded.offset ?? 0).toBe(request.offset) + expect(loaded.limit).toBe(request.limit) + expect(loaded.orderBy?.[0]?.compareOptions.direction).toBe( + request.direction, + ) + for (const position of requested) covered.add(position) + } + expectSetEqual(difference(requested, covered), new Set()) + } +} + +function countWindowLoads(trace: ReadonlyArray): number { + let loads = 0 + const dedupe = new DeduplicatedLoadSubset({ + loadSubset: () => { + loads++ + return true + }, + }) + for (const request of trace) dedupe.loadSubset(toWindowOptions(request)) + return loads +} + +async function runAsyncScenario(scenario: AsyncScenario): Promise { + const requests: Array<{ + options: LoadSubsetOptions + deferred: ReturnType> + }> = [] + const dedupe = new DeduplicatedLoadSubset({ + loadSubset: (options) => { + const deferred = createDeferred() + // The source promise is intentionally rejectable. Observe it directly as + // well as through the dedupe wrapper so Vitest never mistakes a generated + // transport rejection for an unhandled test error. + void deferred.promise.catch(() => undefined) + requests.push({ options, deferred }) + return deferred.promise + }, + }) + + const firstResult = dedupe.loadSubset({ + where: toWhere({ kind: `in`, values: scenario.first }), + }) + const secondResult = dedupe.loadSubset({ + where: toWhere({ kind: `in`, values: scenario.second }), + }) + expect(firstResult).toBeInstanceOf(Promise) + expect(secondResult).toBeInstanceOf(Promise) + if (!(firstResult instanceof Promise) || !(secondResult instanceof Promise)) { + throw new Error(`Initial async requests must return promises`) + } + + const firstSet = new Set(scenario.first) + const secondSet = new Set(scenario.second) + const secondCoveredByFirst = isSubset(secondSet, firstSet) + expect(requests).toHaveLength(secondCoveredByFirst ? 1 : 2) + expect(firstResult === secondResult).toBe(secondCoveredByFirst) + + if (scenario.resetBeforeSettlement) dedupe.reset() + + const outcomes = [scenario.firstOutcome, scenario.secondOutcome] as const + const deliveryIndices = + scenario.deliveryOrder === `forward` + ? requests.map((_, index) => index) + : requests.map((_, index) => index).reverse() + const callerOutcomePromise = Promise.allSettled([firstResult, secondResult]) + for (const index of deliveryIndices) { + const request = requests[index]! + const outcome = outcomes[index]! + if (outcome === `resolve`) request.deferred.resolve() + else request.deferred.reject(new Error(`request ${index} failed`)) + } + + const callerOutcomes = await callerOutcomePromise + const expectedFirstStatus = + scenario.firstOutcome === `resolve` ? `fulfilled` : `rejected` + const expectedSecondStatus = secondCoveredByFirst + ? expectedFirstStatus + : scenario.secondOutcome === `resolve` + ? `fulfilled` + : `rejected` + expect(callerOutcomes.map(({ status }) => status)).toEqual([ + expectedFirstStatus, + expectedSecondStatus, + ]) + + const successfullyCovered = new Set() + if (!scenario.resetBeforeSettlement) { + if (scenario.firstOutcome === `resolve`) { + for (const value of firstSet) successfullyCovered.add(value) + } + if (!secondCoveredByFirst && scenario.secondOutcome === `resolve`) { + for (const value of secondSet) successfullyCovered.add(value) + } + } + + const callsBeforeRetry = requests.length + const retry = dedupe.loadSubset({ + where: toWhere({ kind: `in`, values: scenario.second }), + }) + const retryWasCovered = isSubset(secondSet, successfullyCovered) + if (retry === true) { + expect(retryWasCovered).toBe(true) + expect(retry).toBe(true) + expect(requests).toHaveLength(callsBeforeRetry) + } else { + expect(retry).toBeInstanceOf(Promise) + expect(requests).toHaveLength(callsBeforeRetry + 1) + const retriedValues = matchingValues(requests.at(-1)?.options.where) + const missingRetryValues = difference(secondSet, successfullyCovered) + expectSetEqual(difference(missingRetryValues, retriedValues), new Set()) + expectSetEqual(difference(retriedValues, secondSet), new Set()) + requests.at(-1)?.deferred.resolve() + await retry + } +} + +function readPositiveInteger(name: string, fallback: number): number { + const raw = process.env[name] + if (raw === undefined) return fallback + + const value = Number(raw) + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error(`${name} must be a positive integer`) + } + return value +} + +function readSeed(): number | undefined { + const raw = process.env.TANSTACK_DB_ORACLE_SEED + if (raw === undefined) return undefined + + const seed = Number(raw) + if (!Number.isSafeInteger(seed)) { + throw new Error(`TANSTACK_DB_ORACLE_SEED must be an integer`) + } + return seed +} + +const runs = 40 * readPositiveInteger(`TANSTACK_DB_ORACLE_RUNS_MULTIPLIER`, 1) +const replaySeed = readSeed() +const randomParameters = + replaySeed === undefined + ? { numRuns: runs } + : { numRuns: runs, seed: replaySeed } + +let collectionSequence = 0 + +async function expectPersistingLoadIsApplied(persisting: boolean) { + const rows: Array = [ + { id: `r1`, projectId: `p1` }, + { id: `r2`, projectId: `p1` }, + ] + let loadCalls = 0 + const source = createCollection({ + id: `load-subset-applied-oracle-${collectionSequence++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: () => { + loadCalls += 1 + begin() + for (const row of rows) { + write({ type: `insert`, value: { ...row } }) + } + commit() + return Promise.resolve() + }, + } + }, + }, + }) + const persistence = createDeferred() + const transaction = createTransaction({ + mutationFn: () => persistence.promise, + }) + if (persisting) { + transaction.mutate(() => source.insert({ id: `other`, projectId: `p2` })) + expect(transaction.state).toBe(`persisting`) + } + const live = createLiveQueryCollection((query) => + query.from({ row: source }).where(({ row }) => eq(row.projectId, `p1`)), + ) + + try { + const result = await live.toArrayWhenReady() + expect(loadCalls).toBe(1) + try { + expect(result.map(({ id }) => id).sort()).toEqual([`r1`, `r2`]) + } catch (error) { + throw new TraceAssertionError(0, error) + } + } finally { + if (persisting) { + persistence.resolve() + await transaction.isPersisted.promise + } + live.cleanup() + source.cleanup() + } +} + +async function expectDerivedSyncDuringOptimisticMutation(): Promise { + let begin!: () => void + let write!: (message: { type: `insert`; value: OptimisticDerivedRow }) => void + let commit!: () => void + const source = createCollection({ + id: `optimistic-derived-source-${collectionSequence++}`, + getKey: (row) => row.id, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + params.markReady() + }, + }, + }) + const derived = createLiveQueryCollection({ + query: (query) => + query + .from({ row: source }) + .select(({ row }) => ({ id: row.id, value: row.value })), + getKey: (row) => row.id, + startSync: true, + }) + const persistence = createDeferred() + // Query collections currently expose read-side virtual properties in their + // insert input type even though the runtime accepts the plain selected row. + const insertDerived = derived.insert.bind(derived) as unknown as ( + row: OptimisticDerivedRow, + ) => ReturnType + const insertOptimistically = createOptimisticAction({ + onMutate: insertDerived, + mutationFn: () => persistence.promise, + }) + + await derived.preload() + const transaction = insertOptimistically({ + id: `optimistic`, + value: `optimistic`, + }) + try { + begin() + write({ type: `insert`, value: { id: `synced`, value: `synced` } }) + commit() + + try { + expect([...derived.keys()].sort()).toEqual([`optimistic`, `synced`]) + } catch (error) { + throw new TraceAssertionError(0, error) + } + } finally { + persistence.resolve() + await transaction.isPersisted.promise + derived.cleanup() + source.cleanup() + } +} + +async function expectDeduplicatedWaiterInstallsRejectionHandler(): Promise { + const deferred = createDeferred() + const catchSpy = vi.spyOn(Promise.prototype, `catch`) + const dedupe = new DeduplicatedLoadSubset({ + loadSubset: () => deferred.promise, + }) + + const first = dedupe.loadSubset({ + where: toWhere({ kind: `in`, values: [1, 2] }), + }) + try { + dedupe.loadSubset({ where: toWhere({ kind: `eq`, value: 1 }) }) + try { + expect(catchSpy.mock.calls.map(([handler]) => handler)).not.toContain( + undefined, + ) + } catch (error) { + throw new TraceAssertionError(0, error) + } + } finally { + catchSpy.mockRestore() + deferred.resolve() + if (first !== true) await first + } +} + +describe(`loadSubset coverage oracle`, () => { + fcTest.prop([requestTraceArbitrary], { numRuns: runs, seed: 1657 })( + `matches finite-domain coverage for a fixed seed`, + runCoverageTrace, + ) + + fcTest.prop([requestTraceArbitrary], randomParameters)( + `matches finite-domain coverage for a random or replayed seed`, + runCoverageTrace, + ) + + fcTest.prop([asyncScenarioArbitrary], { numRuns: runs, seed: 1658 })( + `settles, retries, and resets in-flight set requests for a fixed seed`, + runAsyncScenario, + ) + + fcTest.prop([asyncScenarioArbitrary], randomParameters)( + `settles, retries, and resets in-flight set requests for a random or replayed seed`, + runAsyncScenario, + ) + + fcTest.prop([windowTraceArbitrary], { numRuns: runs, seed: 1659 })( + `never treats uncovered ordered windows as loaded for a fixed seed`, + runWindowCoverageTrace, + ) + + fcTest.prop([windowTraceArbitrary], randomParameters)( + `never treats uncovered ordered windows as loaded for a random or replayed seed`, + runWindowCoverageTrace, + ) + + it( + `discovered trace: an in-flight deduplicated waiter installs a rejection handler`, + expectAssertionFailure(expectDeduplicatedWaiterInstallsRejectionHandler, { + checkpoint: 0, + }), + ) + + it(`applies loaded rows when no mutation is persisting`, async () => { + await expectPersistingLoadIsApplied(false) + }) + + it(`applies loaded rows before resolving readiness behind a persisting mutation`, async () => { + await expectAssertionFailure(expectPersistingLoadIsApplied, { + checkpoint: 0, + classify: ({ actual, expected }) => + Array.isArray(actual) && + actual.length === 0 && + Array.isArray(expected) && + expected.join(`,`) === `r1,r2`, + })(true) + }) + + it(`publishes synced source rows while a derived mutation persists`, async () => { + await expectAssertionFailure(expectDerivedSyncDuringOptimisticMutation, { + checkpoint: 0, + classify: ({ actual, expected }) => + Array.isArray(actual) && + actual.join(`,`) === `optimistic` && + Array.isArray(expected) && + expected.join(`,`) === `optimistic,synced`, + })() + }) + + it( + `discovered trace: adjacent ordered windows do not cover their combined window`, + expectAssertionFailure( + () => + Promise.resolve().then(() => { + expect( + countWindowLoads([ + { direction: `asc`, offset: 0, limit: 2 }, + { direction: `asc`, offset: 2, limit: 2 }, + { direction: `asc`, offset: 0, limit: 4 }, + ]), + ).toBe(2) + }), + { message: /expected 3 to be 2/ }, + ), + ) + + it( + `discovered trace: complementary ranges redundantly reload an all-data request`, + expectAssertionFailure( + () => + Promise.resolve().then(() => { + expect( + countLoads([ + { kind: `range`, operator: `gt`, value: 0 }, + { kind: `range`, operator: `lte`, value: 0 }, + { kind: `all` }, + ]), + ).toBe(2) + }), + { message: /expected 3 to be 2/ }, + ), + ) + + it( + `discovered trace: a range plus boundary point redundantly reloads a covered set`, + expectAssertionFailure( + () => + Promise.resolve().then(() => { + expect( + countLoads([ + { kind: `range`, operator: `gt`, value: 0 }, + { kind: `eq`, value: 0 }, + { kind: `in`, values: [0, 1] }, + ]), + ).toBe(2) + }), + { message: /expected 3 to be 2/ }, + ), + ) +}) diff --git a/packages/db/tests/query/load-subset-subquery.test.ts b/packages/db/tests/query/load-subset-subquery.test.ts index 27ce7207c7..8dcc29ce42 100644 --- a/packages/db/tests/query/load-subset-subquery.test.ts +++ b/packages/db/tests/query/load-subset-subquery.test.ts @@ -1,10 +1,18 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createCollection } from '../../src/collection/index.js' import { and, + coalesce, createLiveQueryCollection, eq, + gt, gte, + inArray, + isNull, + lt, + lte, + not, + or, } from '../../src/query/index.js' import { PropRef, Value } from '../../src/query/ir.js' import type { Collection } from '../../src/collection/index.js' @@ -13,7 +21,8 @@ import type { NonSingleResult, UtilsRecord, } from '../../src/types.js' -import type { OrderBy } from '../../src/query/ir.js' +import type { BasicExpression, OrderBy } from '../../src/query/ir.js' +import type { Ref } from '../../src/query/index.js' // Sample types for testing type Order = { @@ -74,8 +83,95 @@ type OrdersCollection = Collection< > & NonSingleResult +type ForwardingCase = { + name: string + build: (order: Ref) => BasicExpression + expected: BasicExpression +} + +const forwardingCases: ReadonlyArray = [ + { + name: `equality`, + build: (order) => eq(order.status, `queued`), + expected: eq(new PropRef([`status`]), new Value(`queued`)), + }, + { + name: `greater than`, + build: (order) => gt(order.id, 1), + expected: gt(new PropRef([`id`]), new Value(1)), + }, + { + name: `greater than or equal`, + build: (order) => gte(order.id, 1), + expected: gte(new PropRef([`id`]), new Value(1)), + }, + { + name: `less than`, + build: (order) => lt(order.id, 3), + expected: lt(new PropRef([`id`]), new Value(3)), + }, + { + name: `less than or equal`, + build: (order) => lte(order.id, 3), + expected: lte(new PropRef([`id`]), new Value(3)), + }, + { + name: `IN`, + build: (order) => inArray(order.id, [1, 2, 3]), + expected: inArray(new PropRef([`id`]), [1, 2, 3]), + }, + { + name: `NOT`, + build: (order) => not(eq(order.status, `completed`)), + expected: not(eq(new PropRef([`status`]), new Value(`completed`))), + }, + { + name: `IS NULL`, + build: (order) => isNull(order.status), + expected: isNull(new PropRef([`status`])), + }, + { + name: `OR`, + build: (order) => + or(eq(order.status, `queued`), eq(order.status, `completed`)), + expected: or( + eq(new PropRef([`status`]), new Value(`queued`)), + eq(new PropRef([`status`]), new Value(`completed`)), + ), + }, + { + name: `nested AND/OR`, + build: (order) => + and( + gt(order.id, 1), + or(eq(order.status, `queued`), eq(order.status, `completed`)), + ), + expected: and( + gt(new PropRef([`id`]), new Value(1)), + or( + eq(new PropRef([`status`]), new Value(`queued`)), + eq(new PropRef([`status`]), new Value(`completed`)), + ), + ), + }, +] + describe(`loadSubset with subqueries`, () => { let chargesCollection: ChargersCollection + const cleanups: Array<{ cleanup: () => void | Promise }> = [] + + afterEach(async () => { + const results = await Promise.allSettled( + cleanups + .splice(0) + .reverse() + .map((value) => value.cleanup()), + ) + const failure = results.find( + (result): result is PromiseRejectedResult => result.status === `rejected`, + ) + if (failure) throw failure.reason + }) beforeEach(() => { // Create charges collection @@ -93,6 +189,7 @@ describe(`loadSubset with subqueries`, () => { }, }, }) + cleanups.push(chargesCollection) }) function createOrdersCollectionWithTracking(): { @@ -126,6 +223,24 @@ describe(`loadSubset with subqueries`, () => { return { collection, loadSubsetCalls } } + it.each(forwardingCases)( + `forwards the $name predicate exactly once`, + async ({ build, expected }) => { + const { collection: ordersCollection, loadSubsetCalls } = + createOrdersCollectionWithTracking() + const query = createLiveQueryCollection((q) => + q.from({ order: ordersCollection }).where(({ order }) => build(order)), + ) + cleanups.push(ordersCollection, query) + + await query.preload() + expect(loadSubsetCalls).toHaveLength(1) + expect(loadSubsetCalls[0]?.where).toEqual(expected) + expect(loadSubsetCalls[0]?.orderBy).toBeUndefined() + expect(loadSubsetCalls[0]?.limit).toBeUndefined() + }, + ) + it(`should call loadSubset with where clause for direct query`, async () => { const today = `2024-01-12` const { collection: ordersCollection, loadSubsetCalls } = @@ -137,6 +252,7 @@ describe(`loadSubset with subqueries`, () => { .where(({ order }) => gte(order.scheduled_at, today)) .where(({ order }) => eq(order.status, `queued`)), ) + cleanups.push(ordersCollection, directQuery) await directQuery.preload() @@ -175,6 +291,7 @@ describe(`loadSubset with subqueries`, () => { eq(charge.address_id, prepaidOrder.address_id), ) }) + cleanups.push(ordersCollection, subqueryQuery) await subqueryQuery.preload() @@ -204,6 +321,7 @@ describe(`loadSubset with subqueries`, () => { .orderBy(({ order }) => order.scheduled_at, `desc`) .limit(2), ) + cleanups.push(ordersCollection, directQuery) await directQuery.preload() @@ -244,6 +362,7 @@ describe(`loadSubset with subqueries`, () => { eq(charge.address_id, prepaidOrder.address_id), ) }) + cleanups.push(ordersCollection, subqueryQuery) await subqueryQuery.preload() @@ -265,4 +384,34 @@ describe(`loadSubset with subqueries`, () => { expect(lastCall!.orderBy).toEqual(expectedOrderBy) }) + + it(`does not forward a computed subquery order to loadSubset`, async () => { + const { collection: ordersCollection, loadSubsetCalls } = + createOrdersCollectionWithTracking() + + const query = createLiveQueryCollection((q) => { + const orderedOrders = q + .from({ order: ordersCollection }) + .select(({ order }) => ({ + address_id: order.address_id, + sortKey: coalesce(order.scheduled_at, `1970-01-01`), + })) + .orderBy(({ $selected }) => $selected.sortKey, `desc`) + .limit(2) + + return q + .from({ charge: chargesCollection }) + .fullJoin({ order: orderedOrders }, ({ charge, order }) => + eq(charge.address_id, order.address_id), + ) + }) + cleanups.push(ordersCollection, query) + + await query.preload() + + expect(loadSubsetCalls).not.toHaveLength(0) + const lastCall = loadSubsetCalls.at(-1) + expect(lastCall?.orderBy).toBeUndefined() + expect(lastCall?.limit).toBeUndefined() + }) }) diff --git a/packages/db/tests/query/pagination-oracle.property.test.ts b/packages/db/tests/query/pagination-oracle.property.test.ts new file mode 100644 index 0000000000..8f453469ca --- /dev/null +++ b/packages/db/tests/query/pagination-oracle.property.test.ts @@ -0,0 +1,1377 @@ +import { fc, test as fcTest } from '@fast-check/vitest' +import { describe, expect, it } from 'vitest' +import { createCollection } from '../../src/collection/index.js' +import { createDeferred } from '../../src/deferred.js' +import { BTreeIndex } from '../../src/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 { TraceAssertionError } from '../trace-runner.js' +import { mockSyncCollectionOptions } from '../utils.js' +import type { BasicExpression } from '../../src/query/ir.js' +import type { LoadSubsetOptions } from '../../src/types.js' + +type PageRow = { + id: number + rank: number +} + +type MultiOrderRow = { + id: number + primary: number + secondary: number +} + +type Window = { + offset: number + limit: number +} + +type PaginationScenario = { + ranks: ReadonlyArray + direction: `asc` | `desc` + windows: ReadonlyArray +} + +type PaginationAction = + | ({ type: `window` } & Window) + | { type: `put`; id: number; rank: number } + | { type: `delete`; id: number } + +type PaginationStateScenario = { + ranks: ReadonlyArray + direction: `asc` | `desc` + initialWindow: Window + actions: ReadonlyArray +} + +type PendingCursorLoad = { + options: LoadSubsetOptions + deferred: ReturnType> +} + +const scenarioArbitrary: fc.Arbitrary = fc.record({ + ranks: fc.array(fc.integer({ min: -2, max: 2 }), { + minLength: 1, + maxLength: 12, + }), + direction: fc.constantFrom(`asc`, `desc`), + windows: fc.array( + fc.record({ + offset: fc.integer({ min: 0, max: 12 }), + limit: fc.integer({ min: 1, max: 8 }), + }), + { minLength: 1, maxLength: 12 }, + ), +}) + +const windowArbitrary: fc.Arbitrary = fc.record({ + offset: fc.integer({ min: 0, max: 12 }), + limit: fc.integer({ min: 1, max: 8 }), +}) + +const paginationActionArbitrary: fc.Arbitrary = fc.oneof( + { + weight: 2, + arbitrary: windowArbitrary.map((window) => ({ + type: `window` as const, + ...window, + })), + }, + { + weight: 3, + arbitrary: fc.record({ + type: fc.constant(`put` as const), + id: fc.integer({ min: 1, max: 16 }), + rank: fc.integer({ min: -2, max: 2 }), + }), + }, + { + weight: 2, + arbitrary: fc.record({ + type: fc.constant(`delete` as const), + id: fc.integer({ min: 1, max: 16 }), + }), + }, +) + +const stateScenarioArbitrary: fc.Arbitrary = fc.record( + { + ranks: fc.array(fc.integer({ min: -2, max: 2 }), { + minLength: 1, + maxLength: 12, + }), + direction: fc.constantFrom(`asc`, `desc`), + initialWindow: windowArbitrary, + actions: fc.array(paginationActionArbitrary, { + minLength: 1, + maxLength: 20, + }), + }, +) + +function readPositiveInteger(name: string, fallback: number): number { + const raw = process.env[name] + if (raw === undefined) return fallback + + const value = Number(raw) + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error(`${name} must be a positive integer`) + } + return value +} + +function readSeed(): number | undefined { + const raw = process.env.TANSTACK_DB_ORACLE_SEED + if (raw === undefined) return undefined + + const seed = Number(raw) + if (!Number.isSafeInteger(seed)) { + throw new Error(`TANSTACK_DB_ORACLE_SEED must be an integer`) + } + return seed +} + +const multiplier = readPositiveInteger(`TANSTACK_DB_ORACLE_RUNS_MULTIPLIER`, 1) +const runs = 12 * multiplier +const replaySeed = readSeed() +const randomParameters = + replaySeed === undefined + ? { numRuns: runs } + : { numRuns: runs, seed: replaySeed } + +let collectionSequence = 0 + +function referenceWindow( + rows: ReadonlyArray, + direction: `asc` | `desc`, + window: Window, +): Array { + return referenceWindowRows(rows, direction, window).map(({ id }) => id) +} + +function referenceWindowRows( + rows: ReadonlyArray, + direction: `asc` | `desc`, + window: Window, +): Array { + const directionFactor = direction === `asc` ? 1 : -1 + return [...rows] + .sort( + (left, right) => + (left.rank - right.rank) * directionFactor || left.id - right.id, + ) + .slice(window.offset, window.offset + window.limit) + .map((row) => ({ ...row })) +} + +function readReference(expression: BasicExpression, row: PageRow): unknown { + if (expression.type === `val`) return expression.value + if (expression.type === `ref`) { + let value: unknown = row + for (const segment of expression.path) { + if (typeof value !== `object` || value === null) return undefined + value = (value as Record)[segment] + } + return value + } + + const args = expression.args.map((argument) => readReference(argument, row)) + switch (expression.name) { + case `and`: + return args.every(Boolean) + case `or`: + return args.some(Boolean) + case `eq`: + return args[0] === args[1] + case `gt`: + return compareReferenceValues(args[0], args[1]) > 0 + case `gte`: + return compareReferenceValues(args[0], args[1]) >= 0 + case `lt`: + return compareReferenceValues(args[0], args[1]) < 0 + case `lte`: + return compareReferenceValues(args[0], args[1]) <= 0 + default: + throw new Error(`unsupported reference expression: ${expression.name}`) + } +} + +function compareReferenceValues(left: unknown, right: unknown): number { + if (typeof left === `number` && typeof right === `number`) { + return left === right ? 0 : left < right ? -1 : 1 + } + if (typeof left === `string` && typeof right === `string`) { + return left === right ? 0 : left < right ? -1 : 1 + } + throw new Error(`cursor comparison requires like-typed numbers or strings`) +} + +function rowsForLoadSubset( + rows: ReadonlyArray, + options: LoadSubsetOptions, +): Array { + if (!options.cursor) { + const start = options.offset ?? 0 + const end = + options.limit === undefined ? rows.length : start + options.limit + return rows.slice(start, end) + } + + const current = rows.filter((row) => + Boolean(readReference(options.cursor!.whereCurrent, row)), + ) + const from = rows.filter((row) => + Boolean(readReference(options.cursor!.whereFrom, row)), + ) + const limitedFrom = + options.limit === undefined ? from : from.slice(0, options.limit) + const requested = new Map() + for (const row of [...current, ...limitedFrom]) requested.set(row.id, row) + return [...requested.values()] +} + +async function runPaginationScenario( + scenario: PaginationScenario, +): Promise { + const rows = scenario.ranks.map((rank, index) => ({ id: index + 1, rank })) + const initialWindow = scenario.windows[0]! + const source = createCollection( + mockSyncCollectionOptions({ + id: `pagination-oracle-source-${collectionSequence++}`, + initialData: rows.map((row) => ({ ...row })), + getKey: (row: PageRow) => row.id, + autoIndex: `eager`, + }), + ) + const live = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank, scenario.direction) + .orderBy(({ row }) => row.id, `asc`) + .offset(initialWindow.offset) + .limit(initialWindow.limit) + .select(({ row }) => ({ id: row.id, rank: row.rank })), + ) + + try { + await live.preload() + for (const window of scenario.windows) { + const result = live.utils.setWindow(window) + if (result instanceof Promise) await result + + expect(Array.from(live.values(), ({ id }) => id)).toEqual( + referenceWindow(rows, scenario.direction, window), + ) + } + } finally { + live.cleanup() + source.cleanup() + } +} + +async function expectMultiOrderBoundaryMatches(): Promise { + const rows: Array = [ + { id: 1, primary: 0, secondary: 2 }, + { id: 2, primary: 0, secondary: 0 }, + { id: 3, primary: 0, secondary: 1 }, + { id: 4, primary: 1, secondary: 1 }, + { id: 5, primary: 1, secondary: 0 }, + { id: 6, primary: 2, secondary: 0 }, + ] + const source = createCollection( + mockSyncCollectionOptions({ + id: `pagination-multi-order-oracle-source-${collectionSequence++}`, + initialData: rows.map((row) => ({ ...row })), + getKey: (row: MultiOrderRow) => row.id, + autoIndex: `eager`, + }), + ) + const live = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.primary, `asc`) + .orderBy(({ row }) => row.secondary, `asc`) + .orderBy(({ row }) => row.id, `asc`) + .limit(4) + .select(({ row }) => ({ id: row.id })), + ) + + try { + await live.preload() + try { + expect(Array.from(live.values(), ({ id }) => id)).toEqual([2, 3, 1, 5]) + } catch (error) { + throw new TraceAssertionError(0, error) + } + } finally { + live.cleanup() + source.cleanup() + } +} + +async function runPaginationStateScenario( + scenario: PaginationStateScenario, +): Promise { + const rows = new Map( + scenario.ranks.map((rank, index) => [index + 1, { id: index + 1, rank }]), + ) + let currentWindow = scenario.initialWindow + const sourceOptions = mockSyncCollectionOptions({ + id: `pagination-state-oracle-source-${collectionSequence++}`, + initialData: [...rows.values()].map((row) => ({ ...row })), + getKey: (row: PageRow) => row.id, + autoIndex: `eager` as const, + }) + const source = createCollection(sourceOptions) + const live = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank, scenario.direction) + .orderBy(({ row }) => row.id, `asc`) + .offset(currentWindow.offset) + .limit(currentWindow.limit) + .select(({ row }) => ({ id: row.id, rank: row.rank })), + ) + + const expectCurrentWindow = (checkpoint: number) => { + try { + expect( + Array.from(live.values(), ({ id, rank }) => ({ id, rank })), + ).toEqual( + referenceWindowRows( + [...rows.values()], + scenario.direction, + currentWindow, + ), + ) + } catch (error) { + throw new TraceAssertionError(checkpoint, error) + } + } + + try { + await live.preload() + expectCurrentWindow(0) + + for (const [index, action] of scenario.actions.entries()) { + if (action.type === `window`) { + currentWindow = { offset: action.offset, limit: action.limit } + const result = live.utils.setWindow(currentWindow) + if (result instanceof Promise) await result + } else if (action.type === `put`) { + const row = { id: action.id, rank: action.rank } + const type = rows.has(action.id) ? `update` : `insert` + rows.set(action.id, row) + sourceOptions.utils.begin() + sourceOptions.utils.write({ type, value: { ...row } }) + sourceOptions.utils.commit() + } else { + const row = rows.get(action.id) + if (row) { + rows.delete(action.id) + sourceOptions.utils.begin() + sourceOptions.utils.write({ type: `delete`, value: { ...row } }) + sourceOptions.utils.commit() + } + } + expectCurrentWindow(index + 1) + } + } finally { + live.cleanup() + source.cleanup() + } +} + +type ReferencePaginationState = { + rows: Map + window: Window +} + +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): PageRowDifference | undefined { + if ( + !(error instanceof TraceAssertionError) || + error.checkpoint < 1 || + 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 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]), + ) + const sentIds = new Set(sentRows.keys()) + 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 = () => { + while (biggest !== undefined && currentResult().length < window.limit) { + const needed = window.limit - currentResult().length + const orderedRows = referenceWindowRows( + [...rows.values()], + scenario.direction, + { offset: 0, limit: rows.size }, + ) + const atCursor = orderedRows.filter( + (row) => row.rank === biggest!.rank && !sentIds.has(row.id), + ) + const afterCursor = orderedRows + .filter( + (row) => + comparePageRows( + { id: 0, rank: row.rank }, + { id: 0, rank: biggest!.rank }, + scenario.direction, + ) > 0 && !sentIds.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) { + sentIds.add(row.id) + 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) + sentIds.add(row.id) + sentRows.set(row.id, { ...row }) + if ( + biggest === undefined || + comparePageRows(biggest, row, scenario.direction) < 0 + ) { + biggest = row + } + } + } else { + rows.delete(action.id) + if (sentIds.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) + + 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 { + const authoritativeRows = scenario.ranks.map((rank, index) => ({ + id: index + 1, + rank, + })) + const directionFactor = scenario.direction === `asc` ? 1 : -1 + const orderedRows = [...authoritativeRows].sort( + (left, right) => + (left.rank - right.rank) * directionFactor || left.id - right.id, + ) + const deliveredIds = new Set() + const loads: Array = [] + const initialWindow = scenario.windows[0]! + + const source = createCollection({ + id: `pagination-on-demand-oracle-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) => { + loads.push({ ...options }) + const requested = rowsForLoadSubset(orderedRows, options) + + return new Promise((resolve) => { + queueMicrotask(() => { + begin() + for (const row of requested) { + if (deliveredIds.has(row.id)) continue + deliveredIds.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + commit() + resolve() + }) + }) + }, + } + }, + }, + }) + const live = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank, scenario.direction) + .orderBy(({ row }) => row.id, `asc`) + .offset(initialWindow.offset) + .limit(initialWindow.limit) + .select(({ row }) => ({ id: row.id, rank: row.rank })), + ) + + try { + await live.preload() + expect(loads.length).toBeGreaterThan(0) + + for (const [index, window] of scenario.windows.entries()) { + const result = live.utils.setWindow(window) + if (result instanceof Promise) await result + + try { + expect(Array.from(live.values(), ({ id }) => id)).toEqual( + referenceWindow(authoritativeRows, scenario.direction, window), + ) + } catch (error) { + throw new TraceAssertionError(index, error) + } + } + + const expectedOrderBy = [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: scenario.direction, nulls: `first` }, + }, + { + expression: new PropRef([`id`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ] + for (const load of loads) expect(load.orderBy).toEqual(expectedOrderBy) + } finally { + live.cleanup() + source.cleanup() + } +} + +async function expectOnDemandWindowsAreCompletionOrderIndependent( + deliveryOrder: `forward` | `reverse`, +): Promise { + const authoritativeRows: Array = [ + { id: 1, rank: 0 }, + { id: 2, rank: 1 }, + { id: 3, rank: 2 }, + { id: 4, rank: 3 }, + ] + const pending: Array = [] + const deliveredIds = new Set([1]) + let begin!: () => void + let write!: (message: { type: `insert`; value: PageRow }) => void + let commit!: () => void + const apply = (options: LoadSubsetOptions) => { + begin() + for (const row of rowsForLoadSubset(authoritativeRows, options)) { + if (deliveredIds.has(row.id)) continue + deliveredIds.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + commit() + } + const source = createCollection({ + id: `pagination-completion-order-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: { ...authoritativeRows[0]! } }) + commit() + params.markReady() + return { + loadSubset: (options: LoadSubsetOptions) => { + const deferred = createDeferred() + pending.push({ options, deferred }) + return deferred.promise + }, + } + }, + }, + }) + const createLive = (limit: number) => + createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank, `asc`) + .orderBy(({ row }) => row.id, `asc`) + .limit(limit), + ) + const firstLive = createLive(2) + const secondLive = createLive(3) + + try { + const first = firstLive.preload() + const second = secondLive.preload() + expect(pending).toHaveLength(2) + + const indices = deliveryOrder === `forward` ? [0, 1] : [1, 0] + for (const index of indices) { + const request = pending[index]! + apply(request.options) + request.deferred.resolve() + await Promise.resolve() + } + await first + await second + + expect(Array.from(firstLive.values(), ({ id }) => id)).toEqual([1, 2]) + expect(Array.from(secondLive.values(), ({ id }) => id)).toEqual([1, 2, 3]) + } finally { + for (const request of pending) request.deferred.resolve() + firstLive.cleanup() + secondLive.cleanup() + source.cleanup() + } +} + +describe(`pagination recomputation oracle`, () => { + 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], + direction: `desc`, + 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) + }) + + it(`retains authoritative rows when a later window admits a prior insert`, async () => { + const scenario: PaginationStateScenario = { + ranks: [0, 0], + direction: `asc`, + initialWindow: { offset: 0, limit: 1 }, + actions: [ + { type: `put`, id: 3, rank: 1 }, + { 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) + }) + + it(`restores an out-of-window insert when a later offset selects it`, async () => { + const scenario: PaginationStateScenario = { + ranks: [0, 0], + direction: `asc`, + initialWindow: { offset: 0, limit: 1 }, + actions: [ + { type: `put`, id: 3, rank: 1 }, + { 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) + }) + + it(`restores an out-of-window rank update when a later offset selects it`, async () => { + const scenario: PaginationStateScenario = { + ranks: [0, 0, 0], + direction: `asc`, + initialWindow: { offset: 0, limit: 1 }, + actions: [ + { type: `put`, id: 2, rank: 1 }, + { 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) + }) + + it(`discovered trace: inserting at an empty offset boundary refills the window`, async () => { + const scenario: PaginationStateScenario = { + ranks: [0, 0], + direction: `asc`, + initialWindow: { offset: 0, limit: 1 }, + actions: [ + { type: `put`, id: 3, rank: 1 }, + { type: `window`, offset: 3, limit: 1 }, + { 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) + }) + + it(`discovered trace: an insert before a later offset does not skip its new boundary`, async () => { + const scenario: PaginationStateScenario = { + ranks: [-1, -1, 0, -1, 0, -1, 0, 1, 1], + direction: `asc`, + initialWindow: { offset: 0, limit: 3 }, + actions: [ + { type: `put`, id: 10, rank: 0 }, + { type: `window`, offset: 8, limit: 1 }, + ], + } + + 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) + }) + + it(`discovered trace: an async cursor loads the full offset window`, async () => { + const scenario: PaginationScenario = { + ranks: [0, 0, 0, 0, 0, 0, 1], + direction: `asc`, + windows: [ + { offset: 0, limit: 1 }, + { 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) + }) + + it(`discovered trace: an async cursor crosses an offset before filling one row`, async () => { + const scenario: PaginationScenario = { + ranks: [0, 0, -1], + direction: `asc`, + windows: [ + { offset: 0, limit: 1 }, + { offset: 2, limit: 1 }, + ], + } + await expectAssertionFailure(runOnDemandPaginationScenario, { + checkpoint: 1, + classify: ({ actual, expected }) => + isNumberArray(actual) && + actual.length === 0 && + isNumberArray(expected) && + expected.join(`,`) === `2`, + })(scenario) + }) + + fcTest.prop([scenarioArbitrary], { numRuns: runs, seed: 1657 })( + `matches full recomputation across ordered windows for a fixed seed`, + runPaginationScenario, + ) + + fcTest.prop([scenarioArbitrary], randomParameters)( + `matches full recomputation across ordered windows for a random or replayed seed`, + runPaginationScenario, + ) + + fcTest.prop([stateScenarioArbitrary], { + numRuns: 8 * multiplier, + seed: 1658, + })( + `matches full recomputation across source and window transitions for a fixed seed`, + runPaginationStateScenarioWithKnownFailures, + ) + + fcTest.prop( + [stateScenarioArbitrary], + replaySeed === undefined + ? { numRuns: 8 * multiplier } + : { numRuns: 8 * multiplier, seed: replaySeed }, + )( + `matches full recomputation across source and window transitions for a random or replayed seed`, + runPaginationStateScenarioWithKnownFailures, + ) + + it(`discovered trace: a rank update must refill a top-1 window`, async () => { + const scenario: PaginationStateScenario = { + ranks: [0, 0], + direction: `asc`, + 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) + }) + + it(`ignores an out-of-window insert when refilling after a delete`, async () => { + const scenario: PaginationStateScenario = { + ranks: [100, 90, 80, 70], + direction: `desc`, + initialWindow: { offset: 0, limit: 3 }, + actions: [ + { type: `put`, id: 5, rank: 10 }, + { 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) + }) + + it(`ignores an out-of-window rank update when refilling after a delete`, async () => { + const scenario: PaginationStateScenario = { + ranks: [0, 0, 0], + direction: `asc`, + initialWindow: { offset: 0, limit: 1 }, + actions: [ + { type: `put`, id: 2, rank: 1 }, + { type: `delete`, id: 1 }, + ], + } + + 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) + }) + + it(`ignores an out-of-window rank update when the visible row leaves`, async () => { + const scenario: PaginationStateScenario = { + ranks: [0, 0, 0], + direction: `asc`, + initialWindow: { offset: 0, limit: 1 }, + actions: [ + { type: `put`, id: 2, rank: 1 }, + { type: `put`, id: 1, rank: 2 }, + ], + } + + 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) + }) + + it(`refills untouched rows when widening after an out-of-window rank update`, async () => { + const scenario: PaginationStateScenario = { + ranks: [0, 0, 0], + direction: `desc`, + initialWindow: { offset: 0, limit: 1 }, + actions: [ + { type: `put`, id: 2, rank: -1 }, + { type: `window`, offset: 0, limit: 3 }, + ], + } + + 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) + }) + + it(`rebuilds the full boundary when widening after an out-of-window rank update`, async () => { + const scenario: PaginationStateScenario = { + ranks: [1, 0, 1, 0, 1], + direction: `desc`, + initialWindow: { offset: 0, limit: 1 }, + actions: [ + { type: `put`, id: 3, rank: 0 }, + { type: `window`, offset: 0, limit: 4 }, + ], + } + + 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) + }) + + it(`ignores an out-of-window insert when widening a tied window`, async () => { + const scenario: PaginationStateScenario = { + ranks: [0, 0], + direction: `asc`, + initialWindow: { offset: 0, limit: 1 }, + actions: [ + { type: `put`, id: 3, rank: 0 }, + { type: `window`, offset: 0, limit: 2 }, + ], + } + const defective = [ + { id: 1, rank: 0 }, + { id: 3, rank: 0 }, + ] + const expected = [ + { id: 1, rank: 0 }, + { id: 2, rank: 0 }, + ] + + await expectAssertionFailure(runPaginationStateScenario, { + checkpoint: 2, + classify: (difference) => + isPageRowArray(difference.actual) && + isPageRowArray(difference.expected) && + sameRows(difference.actual, defective) && + sameRows(difference.expected, expected), + })(scenario) + }) + + 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`, + })() + }) + + fcTest.prop([scenarioArbitrary], { + numRuns: 8 * multiplier, + seed: 1659, + })( + `matches full recomputation when exact async cursor loads widen ordered coverage for a fixed seed`, + runOnDemandPaginationScenarioWithKnownFailures, + ) + + fcTest.prop( + [scenarioArbitrary], + replaySeed === undefined + ? { numRuns: 8 * multiplier } + : { numRuns: 8 * multiplier, seed: replaySeed }, + )( + `matches full recomputation when exact async cursor loads widen ordered coverage for a random or replayed seed`, + runOnDemandPaginationScenarioWithKnownFailures, + ) + + it.each([`forward`, `reverse`] as const)( + `keeps concurrent on-demand windows correct under %s completion`, + expectOnDemandWindowsAreCompletionOrderIndependent, + ) +}) diff --git a/packages/electric-db-collection/tests/electric.test.ts b/packages/electric-db-collection/tests/electric.test.ts index 6478075213..95f279a63e 100644 --- a/packages/electric-db-collection/tests/electric.test.ts +++ b/packages/electric-db-collection/tests/electric.test.ts @@ -7,7 +7,9 @@ import { } from '@tanstack/db' import { persistedCollectionOptions } from '../../db-sqlite-persistence-core/src' import { electricCollectionOptions, isChangeMessage } from '../src/electric' +import { expectAssertionFailure } from '../../db/tests/expected-failure' import { stripVirtualProps } from '../../db/tests/utils' +import { TraceAssertionError } from '../../db/tests/trace-runner' import type { ElectricCollectionUtils } from '../src/electric' import type { Collection, @@ -2625,6 +2627,45 @@ describe(`Electric Integration`, () => { ) }) + it(`reloads Electric coverage after its final owner unloads`, async () => { + const testCollection = createCollection( + electricCollectionOptions({ + id: `on-demand-unload-coverage-test`, + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table` }, + }, + syncMode: `on-demand`, + getKey: (item: Row) => item.id as number, + startSync: true, + }), + ) + const options = { limit: 10 } + + try { + await testCollection._sync.loadSubset(options) + testCollection._sync.unloadSubset(options) + await testCollection._sync.loadSubset(options) + + await expectAssertionFailure( + () => + Promise.resolve().then(() => { + try { + expect(mockRequestSnapshot).toHaveBeenCalledTimes(2) + } catch (error) { + throw new TraceAssertionError(0, error) + } + }), + { + checkpoint: 0, + classify: ({ actual, expected }) => actual === 1 && expected === 2, + }, + )() + } finally { + await testCollection.cleanup() + } + }) + it(`should refresh the stream before requesting on-demand snapshots when already up-to-date`, async () => { vi.clearAllMocks() diff --git a/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts b/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts new file mode 100644 index 0000000000..a29fb32706 --- /dev/null +++ b/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts @@ -0,0 +1,249 @@ +import { QueryClient } from '@tanstack/query-core' +import { IR, createCollection, createLiveQueryCollection } from '@tanstack/db' +import { describe, expect, it, vi } from 'vitest' +import { expectAssertionFailure } from '../../db/tests/expected-failure.js' +import { TraceAssertionError } from '../../db/tests/trace-runner.js' +import { queryCollectionOptions } from '../src/query.js' +import type { QueryFunctionContext } from '@tanstack/query-core' + +type Row = { + id: string +} + +let collectionSequence = 0 + +function createQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { + gcTime: Number.POSITIVE_INFINITY, + retry: false, + }, + }, + }) +} + +async function expectInitialQueryFailureStatus(): Promise { + const error = new Error(`initial query failed`) + const queryClient = createQueryClient() + const id = `load-subset-error-status-${collectionSequence++}` + const loggedError = vi.spyOn(console, `error`).mockImplementation(() => {}) + const collection = createCollection( + queryCollectionOptions({ + id, + queryClient, + queryKey: [id], + queryFn: () => Promise.reject(error), + getKey: (row) => row.id, + startSync: true, + retry: false, + }), + ) + + try { + await vi.waitFor(() => { + expect(collection.utils.lastError).toBe(error) + expect(collection.utils.isError).toBe(true) + }) + expect(loggedError).toHaveBeenCalled() + try { + expect(collection.status).toBe(`error`) + } catch (caught) { + throw new TraceAssertionError(0, caught) + } + } finally { + await collection.cleanup() + queryClient.clear() + loggedError.mockRestore() + } +} + +async function expectEquivalentPredicatesShareOneLoad( + form: `commutative-and` | `reversed-equality`, +): Promise { + const queryClient = createQueryClient() + const id = `load-subset-canonical-predicate-${collectionSequence++}` + const queryFn = vi.fn().mockResolvedValue([]) + const collection = createCollection( + queryCollectionOptions({ + id, + queryClient, + queryKey: [id], + queryFn, + getKey: (row) => row.id, + startSync: true, + syncMode: `on-demand`, + retry: false, + }), + ) + const firstComparison = new IR.Func(`eq`, [ + new IR.PropRef([`id`]), + new IR.Value(`a`), + ]) + const secondComparison = new IR.Func(`eq`, [ + new IR.PropRef([`id`]), + new IR.Value(`b`), + ]) + const first = + form === `commutative-and` + ? new IR.Func(`and`, [firstComparison, secondComparison]) + : firstComparison + const second = + form === `commutative-and` + ? new IR.Func(`and`, [secondComparison, firstComparison]) + : new IR.Func(`eq`, [new IR.Value(`a`), new IR.PropRef([`id`])]) + + try { + await collection._sync.loadSubset({ where: first }) + await collection._sync.loadSubset({ where: second }) + try { + expect(queryFn.mock.calls.length).toBe(1) + } catch (error) { + throw new TraceAssertionError(0, error) + } + } finally { + await collection.cleanup() + queryClient.clear() + } +} + +async function expectFinalOwnerCleanupAbortsQuery(): Promise { + const queryClient = createQueryClient() + const id = `load-subset-cancel-final-owner-${collectionSequence++}` + let capturedSignal: AbortSignal | undefined + let resolveStarted!: () => void + const started = new Promise((resolve) => { + resolveStarted = resolve + }) + const queryFn = vi.fn((context: QueryFunctionContext) => { + capturedSignal = context.signal + resolveStarted() + return new Promise>((_resolve, reject) => { + context.signal.addEventListener(`abort`, () => { + const error = new Error(`query aborted`) + error.name = `AbortError` + reject(error) + }) + }) + }) + const source = createCollection( + queryCollectionOptions({ + id, + queryClient, + queryKey: [id], + queryFn, + getKey: (row) => row.id, + startSync: true, + syncMode: `on-demand`, + retry: false, + }), + ) + const live = createLiveQueryCollection((query) => + query.from({ row: source }).select(({ row }) => ({ id: row.id })), + ) + const preloadOutcome = live.preload().catch((error: unknown) => error) + + try { + await started + expect(queryFn).toHaveBeenCalledOnce() + expect(capturedSignal?.aborted).toBe(false) + + await live.cleanup() + await Promise.resolve() + expect(capturedSignal?.aborted).toBe(true) + } finally { + await live.cleanup() + await source.cleanup() + queryClient.clear() + await preloadOutcome + } +} + +async function expectRemountAfterAbortStartsFreshQuery(): Promise { + const queryClient = createQueryClient() + const id = `load-subset-remount-after-abort-${collectionSequence++}` + let resolveFirstStarted!: () => void + const firstStarted = new Promise((resolve) => { + resolveFirstStarted = resolve + }) + const queryFn = vi + .fn<(context: QueryFunctionContext) => Promise>>() + .mockImplementationOnce((context) => { + resolveFirstStarted() + return new Promise>((_resolve, reject) => { + context.signal.addEventListener(`abort`, () => { + const error = new Error(`first query aborted`) + error.name = `AbortError` + reject(error) + }) + }) + }) + .mockResolvedValueOnce([{ id: `fresh` }]) + const source = createCollection( + queryCollectionOptions({ + id, + queryClient, + queryKey: [id], + queryFn, + getKey: (row) => row.id, + startSync: true, + syncMode: `on-demand`, + retry: false, + }), + ) + const buildLive = () => + createLiveQueryCollection((query) => + query.from({ row: source }).select(({ row }) => ({ id: row.id })), + ) + const first = buildLive() + const firstOutcome = first.preload().catch((error: unknown) => error) + let second: ReturnType | undefined + + try { + await firstStarted + await first.cleanup() + await firstOutcome + + second = buildLive() + const rows = await second.toArrayWhenReady() + expect(queryFn).toHaveBeenCalledTimes(2) + expect(rows.map(({ id: rowId }) => rowId)).toEqual([`fresh`]) + } finally { + await first.cleanup() + await second?.cleanup() + await source.cleanup() + queryClient.clear() + } +} + +describe(`loadSubset lifecycle oracle`, () => { + it(`reports an initial query failure through collection status`, async () => { + await expectAssertionFailure(expectInitialQueryFailureStatus, { + checkpoint: 0, + classify: ({ actual, expected }) => + actual === `ready` && expected === `error`, + })() + }) + + it(`commutative predicate forms share one query-db transport load`, async () => { + await expectAssertionFailure(expectEquivalentPredicatesShareOneLoad, { + checkpoint: 0, + classify: ({ actual, expected }) => actual === 2 && expected === 1, + })(`commutative-and`) + }) + + it(`reversed equality operands share one query-db transport load`, async () => { + await expectAssertionFailure(expectEquivalentPredicatesShareOneLoad, { + checkpoint: 0, + classify: ({ actual, expected }) => actual === 2 && expected === 1, + })(`reversed-equality`) + }) + + it(`aborts an in-flight query when its final live-query owner cleans up`, async () => { + await expectFinalOwnerCleanupAbortsQuery() + }) + + it(`starts a fresh query after an aborted owner immediately remounts`, async () => { + await expectRemountAfterAbortStartsFreshQuery() + }) +}) diff --git a/packages/trailbase-db-collection/tests/trailbase.test.ts b/packages/trailbase-db-collection/tests/trailbase.test.ts index 24a0586aeb..d105a416a8 100644 --- a/packages/trailbase-db-collection/tests/trailbase.test.ts +++ b/packages/trailbase-db-collection/tests/trailbase.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it, vi } from 'vitest' import { createCollection } from '@tanstack/db' import { trailBaseCollectionOptions } from '../src/trailbase' -import { stripVirtualProps } from '../../db/tests/utils' +import { + flushPromises, + stripVirtualProps, + withExpectedRejection, +} from '../../db/tests/utils' +import { expectAssertionFailure } from '../../db/tests/expected-failure' +import { TraceAssertionError } from '../../db/tests/trace-runner' import type { CreateOperation, DeleteOperation, @@ -120,7 +126,45 @@ function setUp(recordApi: MockRecordApi) { return options } +async function expectWildcardFailureSettlesPreload(): Promise { + const failure = new Error(`wildcard subscription denied`) + const recordApi = new MockRecordApi() + recordApi.subscribe.mockRejectedValue(failure) + + await withExpectedRejection(failure.message, async () => { + const collection = createCollection(setUp(recordApi)) + let settled = false + const preload = collection.preload().then( + () => { + settled = true + }, + () => { + settled = true + }, + ) + + try { + await flushPromises() + try { + expect(settled).toBe(true) + } catch (error) { + throw new TraceAssertionError(0, error) + } + } finally { + await collection.cleanup() + await preload + } + }) +} + describe(`TrailBase Integration`, () => { + it(`settles preload when wildcard subscription startup fails`, async () => { + await expectAssertionFailure(expectWildcardFailureSettlesPreload, { + checkpoint: 0, + classify: ({ actual, expected }) => actual === false && expected === true, + })() + }) + it(`cancels its event subscription when the collection is cleaned up`, async () => { const recordApi = new MockRecordApi() const cancel = vi.fn() From f6aa71350692e37c725a5684ea056ac007fac6c9 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 19 Aug 2026 10:42:32 +0100 Subject: [PATCH 02/18] test(db): tighten loadSubset oracle boundaries --- .../query/load-subset-oracle.property.test.ts | 282 +++++++++-- .../query/pagination-oracle.property.test.ts | 461 +++++++++++++++++- 2 files changed, 682 insertions(+), 61 deletions(-) diff --git a/packages/db/tests/query/load-subset-oracle.property.test.ts b/packages/db/tests/query/load-subset-oracle.property.test.ts index d70ac2f2bf..f78cab3406 100644 --- a/packages/db/tests/query/load-subset-oracle.property.test.ts +++ b/packages/db/tests/query/load-subset-oracle.property.test.ts @@ -1,5 +1,5 @@ import { fc, test as fcTest } from '@fast-check/vitest' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { createCollection } from '../../src/collection/index.js' import { createDeferred } from '../../src/deferred.js' import { createOptimisticAction } from '../../src/optimistic-action.js' @@ -49,6 +49,24 @@ type OptimisticDerivedRow = { value: string } +type CoverageSubject = { + loadSubset: (options: LoadSubsetOptions) => true | Promise +} + +type CoverageSubjectFactory = ( + recordLoad: (options: LoadSubsetOptions) => true, +) => CoverageSubject + +class CoveredDemandRefetchedError extends Error { + constructor( + readonly checkpoint: number, + readonly requested: ReadonlySet, + readonly loadedRegions: ReadonlyArray>, + ) { + super(`Covered demand refetched at checkpoint ${checkpoint}`) + } +} + // The generated predicates only compare against integers from -3 through 3. // These points cover every distinct truth partition: both unbounded tails, // every equality point, and every open interval between adjacent thresholds. @@ -70,7 +88,7 @@ const predicateSpecArbitrary: fc.Arbitrary = fc.oneof( weight: 3, arbitrary: fc .uniqueArray(fc.integer({ min: -3, max: 3 }), { - minLength: 1, + minLength: 0, maxLength: 7, }) .map((values) => ({ kind: `in` as const, values })), @@ -91,7 +109,7 @@ const requestTraceArbitrary = fc.array(predicateSpecArbitrary, { }) const inValuesArbitrary = fc.uniqueArray(fc.integer({ min: -3, max: 3 }), { - minLength: 1, + minLength: 0, maxLength: 7, }) @@ -126,7 +144,7 @@ const asyncScenarioArbitrary: fc.Arbitrary = fc const windowRequestArbitrary: fc.Arbitrary = fc.record({ direction: fc.constantFrom(`asc`, `desc`), offset: fc.integer({ min: 0, max: 6 }), - limit: fc.integer({ min: 1, max: 6 }), + limit: fc.integer({ min: 0, max: 6 }), }) const windowTraceArbitrary = fc.array(windowRequestArbitrary, { @@ -223,23 +241,33 @@ function expectSetEqual( expect([...actual].sort()).toEqual([...expected].sort()) } -function runCoverageTrace(trace: ReadonlyArray): void { +const createDeduplicatedCoverageSubject: CoverageSubjectFactory = ( + recordLoad, +) => new DeduplicatedLoadSubset({ loadSubset: recordLoad }) + +const createAlwaysLoadingCoverageSubject: CoverageSubjectFactory = ( + recordLoad, +) => ({ loadSubset: recordLoad }) + +function runCoverageTrace( + trace: ReadonlyArray, + createSubject = createDeduplicatedCoverageSubject, +): void { const covered = new Set() + const loadedRegions: Array> = [] const loads: Array = [] - const dedupe = new DeduplicatedLoadSubset({ - loadSubset: (options) => { - loads.push(options) - return true - }, + const subject = createSubject((options) => { + loads.push(options) + return true }) - for (const predicate of trace) { + for (const [checkpoint, predicate] of trace.entries()) { const where = toWhere(predicate) const requested = matchingValues(where) const missing = difference(requested, covered) const loadCountBefore = loads.length - const result = dedupe.loadSubset({ where }) + const result = subject.loadSubset({ where }) expect(result).toBe(true) expect(loads.length - loadCountBefore).toBeLessThanOrEqual(1) @@ -248,15 +276,39 @@ function runCoverageTrace(trace: ReadonlyArray): void { } else { expect(loads).toHaveLength(loadCountBefore + 1) const loaded = matchingValues(loads.at(-1)?.where) - expectSetEqual(difference(missing, loaded), new Set()) expectSetEqual(difference(loaded, requested), new Set()) + if (missing.size === 0) { + throw new CoveredDemandRefetchedError( + checkpoint, + requested, + loadedRegions.map((region) => new Set(region)), + ) + } + expectSetEqual(difference(missing, loaded), new Set()) for (const value of loaded) covered.add(value) + loadedRegions.push(loaded) } expectSetEqual(difference(requested, covered), new Set()) } } +function runCoverageTraceWithKnownFailures( + trace: ReadonlyArray, +): void { + try { + runCoverageTrace(trace) + } catch (error) { + if ( + error instanceof CoveredDemandRefetchedError && + (error.requested.size === 0 || error.loadedRegions.length > 1) + ) { + return + } + throw error + } +} + function countLoads(trace: ReadonlyArray): number { let loads = 0 const dedupe = new DeduplicatedLoadSubset({ @@ -294,26 +346,32 @@ function windowPositions(request: WindowRequest): Set { ) } -function runWindowCoverageTrace(trace: ReadonlyArray): void { +function runWindowCoverageTrace( + trace: ReadonlyArray, + createSubject = createDeduplicatedCoverageSubject, +): void { const coveredByOrder = new Map<`asc` | `desc`, Set>([ [`asc`, new Set()], [`desc`, new Set()], ]) + const loadedRegionsByOrder = new Map<`asc` | `desc`, Array>>([ + [`asc`, []], + [`desc`, []], + ]) const loads: Array = [] - const dedupe = new DeduplicatedLoadSubset({ - loadSubset: (options) => { - loads.push(options) - return true - }, + const subject = createSubject((options) => { + loads.push(options) + return true }) - for (const request of trace) { + for (const [checkpoint, request] of trace.entries()) { const requested = windowPositions(request) const covered = coveredByOrder.get(request.direction)! + const loadedRegions = loadedRegionsByOrder.get(request.direction)! const missing = difference(requested, covered) const callsBefore = loads.length - dedupe.loadSubset(toWindowOptions(request)) + subject.loadSubset(toWindowOptions(request)) expect(loads.length - callsBefore).toBeLessThanOrEqual(1) if (loads.length === callsBefore) { @@ -325,12 +383,36 @@ function runWindowCoverageTrace(trace: ReadonlyArray): void { expect(loaded.orderBy?.[0]?.compareOptions.direction).toBe( request.direction, ) + if (missing.size === 0) { + throw new CoveredDemandRefetchedError( + checkpoint, + requested, + loadedRegions.map((region) => new Set(region)), + ) + } for (const position of requested) covered.add(position) + loadedRegions.push(requested) } expectSetEqual(difference(requested, covered), new Set()) } } +function runWindowCoverageTraceWithKnownFailures( + trace: ReadonlyArray, +): void { + try { + runWindowCoverageTrace(trace) + } catch (error) { + if ( + error instanceof CoveredDemandRefetchedError && + (error.requested.size === 0 || error.loadedRegions.length > 1) + ) { + return + } + throw error + } +} + function countWindowLoads(trace: ReadonlyArray): number { let loads = 0 const dedupe = new DeduplicatedLoadSubset({ @@ -583,41 +665,148 @@ async function expectDerivedSyncDuringOptimisticMutation(): Promise { } } -async function expectDeduplicatedWaiterInstallsRejectionHandler(): Promise { +async function captureUnhandledRejections( + run: () => Promise, +): Promise> { + const vitestHandler = process + .listeners(`unhandledRejection`) + .find((listener) => listener.name === `vitestUnhandledRejectionHandler`) + const reasons: Array = [] + const capture = (reason: unknown) => reasons.push(reason) + + if (vitestHandler) process.removeListener(`unhandledRejection`, vitestHandler) + process.on(`unhandledRejection`, capture) + try { + await run() + await new Promise((resolve) => setTimeout(resolve, 0)) + return reasons + } finally { + process.removeListener(`unhandledRejection`, capture) + if (vitestHandler) process.on(`unhandledRejection`, vitestHandler) + } +} + +async function expectDeduplicatedWaiterHandlesRejection(): Promise { const deferred = createDeferred() - const catchSpy = vi.spyOn(Promise.prototype, `catch`) const dedupe = new DeduplicatedLoadSubset({ loadSubset: () => deferred.promise, }) - const first = dedupe.loadSubset({ - where: toWhere({ kind: `in`, values: [1, 2] }), + const unhandled = await captureUnhandledRejections(async () => { + const first = dedupe.loadSubset({ + where: toWhere({ kind: `in`, values: [1, 2] }), + }) + const second = dedupe.loadSubset({ + where: toWhere({ kind: `eq`, value: 1 }), + }) + if (!(first instanceof Promise) || !(second instanceof Promise)) { + throw new Error(`Both callers must wait for the in-flight request`) + } + + const callerOutcomes = Promise.allSettled([first, second]) + deferred.reject(new Error(`transport failed`)) + expect((await callerOutcomes).map(({ status }) => status)).toEqual([ + `rejected`, + `rejected`, + ]) }) + try { - dedupe.loadSubset({ where: toWhere({ kind: `eq`, value: 1 }) }) - try { - expect(catchSpy.mock.calls.map(([handler]) => handler)).not.toContain( - undefined, - ) - } catch (error) { - throw new TraceAssertionError(0, error) - } - } finally { - catchSpy.mockRestore() - deferred.resolve() - if (first !== true) await first + expect(unhandled).toEqual([]) + } catch (error) { + throw new TraceAssertionError(0, error) } } describe(`loadSubset coverage oracle`, () => { + it( + `discovered trace: an empty predicate issues no transport work`, + expectAssertionFailure( + () => + Promise.resolve().then(() => { + expect(countLoads([{ kind: `in`, values: [] }])).toBe(0) + }), + { message: /expected 1 to be/ }, + ), + ) + + it( + `discovered trace: an empty ordered window issues no transport work`, + expectAssertionFailure( + () => + Promise.resolve().then(() => { + expect( + countWindowLoads([{ direction: `asc`, offset: 0, limit: 0 }]), + ).toBe(0) + }), + { message: /expected 1 to be/ }, + ), + ) + + it(`rejects repeated transport work for one covered predicate`, () => { + expect(() => + runCoverageTrace( + [ + { kind: `eq`, value: 1 }, + { kind: `eq`, value: 1 }, + ], + createAlwaysLoadingCoverageSubject, + ), + ).toThrow() + }) + + it(`reuses transport work for repeated and strictly covered predicates`, () => { + runCoverageTrace([ + { kind: `range`, operator: `gte`, value: 0 }, + ...Array.from( + { length: 20 }, + (): PredicateSpec => ({ kind: `eq`, value: 1 }), + ), + ]) + }) + + it(`rejects transport work for a strict covered predicate subset`, () => { + expect(() => + runCoverageTrace( + [ + { kind: `range`, operator: `gte`, value: 0 }, + { kind: `eq`, value: 1 }, + ], + createAlwaysLoadingCoverageSubject, + ), + ).toThrow() + }) + + it(`rejects repeated transport work for one covered window`, () => { + expect(() => + runWindowCoverageTrace( + [ + { direction: `asc`, offset: 1, limit: 2 }, + { direction: `asc`, offset: 1, limit: 2 }, + ], + createAlwaysLoadingCoverageSubject, + ), + ).toThrow() + }) + + it(`reuses transport work for repeated and strictly covered windows`, () => { + runWindowCoverageTrace([ + { direction: `asc`, offset: 0, limit: 4 }, + ...Array.from( + { length: 20 }, + (): WindowRequest => ({ direction: `asc`, offset: 1, limit: 2 }), + ), + ]) + }) + fcTest.prop([requestTraceArbitrary], { numRuns: runs, seed: 1657 })( `matches finite-domain coverage for a fixed seed`, - runCoverageTrace, + runCoverageTraceWithKnownFailures, ) fcTest.prop([requestTraceArbitrary], randomParameters)( `matches finite-domain coverage for a random or replayed seed`, - runCoverageTrace, + runCoverageTraceWithKnownFailures, ) fcTest.prop([asyncScenarioArbitrary], { numRuns: runs, seed: 1658 })( @@ -632,18 +821,25 @@ describe(`loadSubset coverage oracle`, () => { fcTest.prop([windowTraceArbitrary], { numRuns: runs, seed: 1659 })( `never treats uncovered ordered windows as loaded for a fixed seed`, - runWindowCoverageTrace, + runWindowCoverageTraceWithKnownFailures, ) fcTest.prop([windowTraceArbitrary], randomParameters)( `never treats uncovered ordered windows as loaded for a random or replayed seed`, - runWindowCoverageTrace, + runWindowCoverageTraceWithKnownFailures, ) it( - `discovered trace: an in-flight deduplicated waiter installs a rejection handler`, - expectAssertionFailure(expectDeduplicatedWaiterInstallsRejectionHandler, { + `an in-flight deduplicated waiter rejects without an unhandled branch`, + expectAssertionFailure(expectDeduplicatedWaiterHandlesRejection, { checkpoint: 0, + classify: ({ actual, expected }) => + Array.isArray(actual) && + actual.length === 1 && + actual[0] instanceof Error && + actual[0].message === `transport failed` && + Array.isArray(expected) && + expected.length === 0, }), ) diff --git a/packages/db/tests/query/pagination-oracle.property.test.ts b/packages/db/tests/query/pagination-oracle.property.test.ts index 8f453469ca..300551db31 100644 --- a/packages/db/tests/query/pagination-oracle.property.test.ts +++ b/packages/db/tests/query/pagination-oracle.property.test.ts @@ -7,7 +7,7 @@ import { createLiveQueryCollection } from '../../src/query/live-query-collection import { PropRef } from '../../src/query/ir.js' import { expectAssertionFailure } from '../expected-failure.js' import { TraceAssertionError } from '../trace-runner.js' -import { mockSyncCollectionOptions } from '../utils.js' +import { flushPromises, mockSyncCollectionOptions } from '../utils.js' import type { BasicExpression } from '../../src/query/ir.js' import type { LoadSubsetOptions } from '../../src/types.js' @@ -18,8 +18,20 @@ type PageRow = { type MultiOrderRow = { id: number - primary: number - secondary: number + primary: number | null + secondary: number | null +} + +type MultiOrderTerm = { + direction: `asc` | `desc` + nulls: `first` | `last` +} + +type MultiOrderScenario = { + rows: ReadonlyArray + primary: MultiOrderTerm + secondary: MultiOrderTerm + limit: number } type Window = { @@ -48,8 +60,14 @@ type PaginationStateScenario = { type PendingCursorLoad = { options: LoadSubsetOptions deferred: ReturnType> + settled?: boolean } +type PendingMutation = + | { type: `insert`; row: PageRow } + | { type: `delete`; id: number } + | { type: `update`; row: PageRow } + const scenarioArbitrary: fc.Arbitrary = fc.record({ ranks: fc.array(fc.integer({ min: -2, max: 2 }), { minLength: 1, @@ -59,7 +77,7 @@ const scenarioArbitrary: fc.Arbitrary = fc.record({ windows: fc.array( fc.record({ offset: fc.integer({ min: 0, max: 12 }), - limit: fc.integer({ min: 1, max: 8 }), + limit: fc.integer({ min: 0, max: 8 }), }), { minLength: 1, maxLength: 12 }, ), @@ -67,7 +85,7 @@ const scenarioArbitrary: fc.Arbitrary = fc.record({ const windowArbitrary: fc.Arbitrary = fc.record({ offset: fc.integer({ min: 0, max: 12 }), - limit: fc.integer({ min: 1, max: 8 }), + limit: fc.integer({ min: 0, max: 8 }), }) const paginationActionArbitrary: fc.Arbitrary = fc.oneof( @@ -271,18 +289,63 @@ async function runPaginationScenario( } async function expectMultiOrderBoundaryMatches(): Promise { - const rows: Array = [ - { id: 1, primary: 0, secondary: 2 }, - { id: 2, primary: 0, secondary: 0 }, - { id: 3, primary: 0, secondary: 1 }, - { id: 4, primary: 1, secondary: 1 }, - { id: 5, primary: 1, secondary: 0 }, - { id: 6, primary: 2, secondary: 0 }, - ] + await runMultiOrderScenario({ + rows: [ + { id: 1, primary: 0, secondary: 2 }, + { id: 2, primary: 0, secondary: 0 }, + { id: 3, primary: 0, secondary: 1 }, + { id: 4, primary: 1, secondary: 1 }, + { id: 5, primary: 1, secondary: 0 }, + { id: 6, primary: 2, secondary: 0 }, + ], + primary: { direction: `asc`, nulls: `first` }, + secondary: { direction: `asc`, nulls: `first` }, + limit: 4, + }) +} + +function compareNullableNumber( + left: number | null, + right: number | null, + term: MultiOrderTerm, +): number { + if (left === null || right === null) { + if (left === right) return 0 + return left === null + ? term.nulls === `first` + ? -1 + : 1 + : term.nulls === `first` + ? 1 + : -1 + } + const compared = left === right ? 0 : left < right ? -1 : 1 + return term.direction === `asc` ? compared : -compared +} + +function referenceMultiOrder(scenario: MultiOrderScenario): Array { + return [...scenario.rows] + .sort( + (left, right) => + compareNullableNumber(left.primary, right.primary, scenario.primary) || + compareNullableNumber( + left.secondary, + right.secondary, + scenario.secondary, + ) || + left.id - right.id, + ) + .slice(0, scenario.limit) + .map(({ id }) => id) +} + +async function runMultiOrderScenario( + scenario: MultiOrderScenario, +): Promise { const source = createCollection( mockSyncCollectionOptions({ id: `pagination-multi-order-oracle-source-${collectionSequence++}`, - initialData: rows.map((row) => ({ ...row })), + initialData: scenario.rows.map((row) => ({ ...row })), getKey: (row: MultiOrderRow) => row.id, autoIndex: `eager`, }), @@ -290,17 +353,19 @@ async function expectMultiOrderBoundaryMatches(): Promise { const live = createLiveQueryCollection((query) => query .from({ row: source }) - .orderBy(({ row }) => row.primary, `asc`) - .orderBy(({ row }) => row.secondary, `asc`) + .orderBy(({ row }) => row.primary, scenario.primary) + .orderBy(({ row }) => row.secondary, scenario.secondary) .orderBy(({ row }) => row.id, `asc`) - .limit(4) + .limit(scenario.limit) .select(({ row }) => ({ id: row.id })), ) try { await live.preload() try { - expect(Array.from(live.values(), ({ id }) => id)).toEqual([2, 3, 1, 5]) + expect(Array.from(live.values(), ({ id }) => id)).toEqual( + referenceMultiOrder(scenario), + ) } catch (error) { throw new TraceAssertionError(0, error) } @@ -647,6 +712,14 @@ function replayOnDemandPaginationWindow( { 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()], @@ -884,7 +957,359 @@ async function expectOnDemandWindowsAreCompletionOrderIndependent( } } +async function runPendingMutationScenario( + mutation: PendingMutation, + timing: `before-response` | `after-response`, +): Promise { + const rows = new Map([ + [1, { id: 1, rank: 0 }], + [2, { id: 2, rank: 1 }], + [3, { id: 3, rank: 2 }], + [4, { id: 4, rank: 3 }], + ]) + const pending: Array = [] + const deliveredIds = new Set([1]) + let begin!: () => void + let write!: (message: { + type: `insert` | `update` | `delete` + value: PageRow + }) => void + let commit!: () => void + + const source = createCollection({ + id: `pagination-event-order-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: { ...rows.get(1)! } }) + commit() + params.markReady() + return { + loadSubset: (options: LoadSubsetOptions) => { + const deferred = createDeferred() + pending.push({ options, deferred }) + return deferred.promise + }, + } + }, + }, + }) + const live = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank, `asc`) + .orderBy(({ row }) => row.id, `asc`) + .limit(3), + ) + + const applyMutation = () => { + begin() + if (mutation.type === `delete`) { + const row = rows.get(mutation.id) + if (!row) throw new Error(`Cannot delete missing authoritative row`) + rows.delete(mutation.id) + deliveredIds.delete(mutation.id) + write({ type: `delete`, value: { ...row } }) + } else { + rows.set(mutation.row.id, { ...mutation.row }) + if (mutation.type === `insert`) deliveredIds.add(mutation.row.id) + write({ type: mutation.type, value: { ...mutation.row } }) + } + commit() + } + + const settlePending = async () => { + for (const request of pending) { + if (request.settled) continue + request.settled = true + const orderedRows = referenceWindowRows([...rows.values()], `asc`, { + offset: 0, + limit: rows.size, + }) + begin() + for (const row of rowsForLoadSubset(orderedRows, request.options)) { + if (deliveredIds.has(row.id)) continue + deliveredIds.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + commit() + request.deferred.resolve() + await Promise.resolve() + } + } + + try { + const preload = live.preload() + expect(pending).toHaveLength(1) + + if (timing === `before-response`) applyMutation() + await settlePending() + await preload + if (timing === `after-response`) { + applyMutation() + await Promise.resolve() + await settlePending() + } + + try { + expect( + Array.from(live.values(), ({ id, rank }) => ({ id, rank })), + ).toEqual( + referenceWindowRows([...rows.values()], `asc`, { + offset: 0, + limit: 3, + }), + ) + } catch (error) { + throw new TraceAssertionError(0, error) + } + } finally { + for (const request of pending) request.deferred.resolve() + live.cleanup() + source.cleanup() + } +} + +async function expectInflightRequestFillsNewWindow(): Promise { + const rows: Array = [ + { id: 1, rank: 0 }, + { id: 2, rank: 1 }, + { id: 3, rank: 2 }, + { id: 4, rank: 3 }, + ] + const pending: Array = [] + const deliveredIds = new Set([1]) + let begin!: () => void + let write!: (message: { type: `insert`; value: PageRow }) => void + let commit!: () => void + const source = createCollection({ + id: `pagination-late-window-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: { ...rows[0]! } }) + commit() + params.markReady() + return { + loadSubset: (options: LoadSubsetOptions) => { + const deferred = createDeferred() + pending.push({ options, deferred }) + return 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) => { + begin() + for (const row of rowsForLoadSubset(rows, request.options)) { + if (deliveredIds.has(row.id)) continue + deliveredIds.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + commit() + request.deferred.resolve() + await Promise.resolve() + } + + try { + const preload = live.preload() + expect(pending).toHaveLength(1) + const setWindow = live.utils.setWindow({ offset: 2, limit: 2 }) + expect(setWindow).toBeInstanceOf(Promise) + await flushPromises() + expect(pending).toHaveLength(1) + + await settle(pending[0]!) + await preload + if (setWindow instanceof Promise) await setWindow + expect(pending).toHaveLength(1) + + try { + expect(Array.from(live.values(), ({ id }) => id)).toEqual([3, 4]) + } catch (error) { + throw new TraceAssertionError(0, error) + } + } finally { + for (const request of pending) request.deferred.resolve() + live.cleanup() + source.cleanup() + } +} + describe(`pagination recomputation oracle`, () => { + it(`materializes an initially empty zero-limit window`, async () => { + await runPaginationScenario({ + ranks: [0, 1, 2], + direction: `asc`, + windows: [{ offset: 0, limit: 0 }], + }) + }) + + it(`clears and restores a nonempty window across a zero limit`, async () => { + await runPaginationScenario({ + ranks: [0, 1, 2], + direction: `asc`, + windows: [ + { offset: 0, limit: 2 }, + { offset: 0, limit: 0 }, + { offset: 1, limit: 1 }, + ], + }) + }) + + 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`, + })({ + ranks: [0, 0], + direction: `asc`, + windows: [ + { offset: 1, limit: 0 }, + { offset: 0, limit: 2 }, + ], + }) + }) + + const nullableBoundaryRows: ReadonlyArray = [ + { id: 1, primary: null, secondary: 2 }, + { id: 2, primary: null, secondary: 0 }, + { id: 3, primary: null, secondary: 1 }, + { id: 4, primary: 1, secondary: null }, + { id: 5, primary: 1, secondary: 0 }, + { id: 6, primary: 2, secondary: 0 }, + ] + + it.each([ + [ + `discovered trace: orders an ascending nullable boundary by its second term`, + { + rows: nullableBoundaryRows, + primary: { direction: `asc`, nulls: `first` }, + secondary: { direction: `asc`, nulls: `first` }, + limit: 1, + }, + true, + ], + [ + `orders a descending nullable boundary by its second term`, + { + rows: nullableBoundaryRows, + primary: { direction: `desc`, nulls: `first` }, + secondary: { direction: `desc`, nulls: `first` }, + limit: 1, + }, + false, + ], + [ + `orders an ascending and descending mixed nullable boundary`, + { + rows: nullableBoundaryRows, + primary: { direction: `asc`, nulls: `first` }, + secondary: { direction: `desc`, nulls: `first` }, + limit: 1, + }, + false, + ], + [ + `discovered trace: orders a descending and ascending mixed nullable boundary`, + { + rows: nullableBoundaryRows, + primary: { direction: `desc`, nulls: `first` }, + secondary: { direction: `asc`, nulls: `first` }, + limit: 1, + }, + true, + ], + [ + `uses the public key to break a complete tuple tie`, + { + rows: [ + { id: 2, primary: 0, secondary: 0 }, + { id: 1, primary: 0, secondary: 0 }, + ], + primary: { direction: `asc`, nulls: `last` }, + secondary: { direction: `asc`, nulls: `last` }, + limit: 1, + }, + false, + ], + ] satisfies ReadonlyArray)( + `%s`, + async (_name, scenario, expectsFailure) => { + if (!expectsFailure) { + await runMultiOrderScenario(scenario) + return + } + await expectAssertionFailure(runMultiOrderScenario, { + checkpoint: 0, + classify: ({ actual, expected }) => + isNumberArray(actual) && + actual.length === 1 && + actual[0] === 1 && + isNumberArray(expected) && + expected.length === 1 && + expected[0] === 2, + })(scenario) + }, + ) + + it.each([ + [`boundary insert`, { type: `insert`, row: { id: 5, rank: 0.5 } }], + [`visible delete`, { type: `delete`, id: 1 }], + [ + `boundary-crossing rank update`, + { type: `update`, row: { id: 4, rank: 0.5 } }, + ], + ] satisfies ReadonlyArray)( + `%s converges before and after a pending response`, + async (_name, mutation) => { + await runPendingMutationScenario(mutation, `before-response`) + await runPendingMutationScenario(mutation, `after-response`) + }, + ) + + 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`, + }), + ) + it(`rejects collateral loss from the ordered-subscription classifier`, () => { const scenario: PaginationStateScenario = { ranks: [0, 1, 2], From fc2f4f0d88437c25b6b2eb9dc5064dc6173a62fb Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 19 Aug 2026 12:46:15 +0100 Subject: [PATCH 03/18] test(db): close loadSubset oracle gaps --- .../query/load-subset-join-dedupe.test.ts | 36 +- .../query/load-subset-oracle.property.test.ts | 919 +++++++++++++++--- .../query/pagination-oracle.property.test.ts | 881 ++++++++++++++++- .../tests/electric.test.ts | 19 +- .../load-subset-lifecycle-oracle.test.ts | 7 +- 5 files changed, 1672 insertions(+), 190 deletions(-) diff --git a/packages/db/tests/query/load-subset-join-dedupe.test.ts b/packages/db/tests/query/load-subset-join-dedupe.test.ts index 5bebdf0b47..97e3b96011 100644 --- a/packages/db/tests/query/load-subset-join-dedupe.test.ts +++ b/packages/db/tests/query/load-subset-join-dedupe.test.ts @@ -3,6 +3,8 @@ import { createCollection } from '../../src/collection/index.js' import { BasicIndex } from '../../src/indexes/basic-index.js' import { extractSimpleComparisons } from '../../src/query/expression-helpers.js' import { createLiveQueryCollection, eq } from '../../src/query/index.js' +import { expectAssertionFailure } from '../expected-failure.js' +import { TraceAssertionError } from '../trace-runner.js' import { flushPromises } from '../utils.js' import type { ChangeMessageOrDeleteKeyMessage, @@ -103,18 +105,32 @@ describe(`loadSubset join-key deduplication`, () => { for (const cleanup of cleanups.splice(0).reverse()) cleanup() }) - it(`does not reload the same join predicate on repeated preload`, async () => { - const { collection: parentCollection } = createParents() - const { collection: childCollection, loads } = createChildren() - const live = createJoinedQuery(parentCollection, childCollection) + it( + `discovered trace: a second live query reuses its loaded join predicate`, + expectAssertionFailure( + async () => { + const { collection: parentCollection } = createParents() + const { collection: childCollection, loads } = createChildren() + const firstLive = createJoinedQuery(parentCollection, childCollection) - await live.preload() - const loadCount = loads.length - expect(loadCount).toBeGreaterThan(0) + await firstLive.preload() + const loadCount = loads.length + expect(loadCount).toBeGreaterThan(0) - await live.preload() - expect(loads).toHaveLength(loadCount) - }) + const secondLive = createJoinedQuery(parentCollection, childCollection) + await secondLive.preload() + try { + expect(loads).toHaveLength(loadCount) + } catch (error) { + throw new TraceAssertionError(0, error) + } + }, + { + checkpoint: 0, + classify: ({ actual, expected }) => actual === 2 && expected === 1, + }, + ), + ) it(`requests only a newly inserted join key`, async () => { const { collection: parentCollection, insert } = createParents() diff --git a/packages/db/tests/query/load-subset-oracle.property.test.ts b/packages/db/tests/query/load-subset-oracle.property.test.ts index f78cab3406..ffe64daedc 100644 --- a/packages/db/tests/query/load-subset-oracle.property.test.ts +++ b/packages/db/tests/query/load-subset-oracle.property.test.ts @@ -21,6 +21,8 @@ type PredicateSpec = operator: `gt` | `gte` | `lt` | `lte` value: number } + | { kind: `and` | `or`; operands: readonly [PredicateSpec, PredicateSpec] } + | { kind: `not`; operand: PredicateSpec } type AsyncScenario = { first: ReadonlyArray @@ -31,12 +33,21 @@ type AsyncScenario = { resetBeforeSettlement: boolean } +type ConcurrentAsyncScenario = { + requestedValues: ReadonlyArray> + deliveryOrder: `forward` | `reverse` +} + type RangeOperator = Extract[`operator`] type WindowRequest = { + where?: PredicateSpec + orderField?: `none` | `rank` | `score` direction: `asc` | `desc` + nulls?: `first` | `last` + stringSort?: `lexical` | `locale` offset: number - limit: number + limit?: number } type PersistedLoadRow = { @@ -51,10 +62,11 @@ type OptimisticDerivedRow = { type CoverageSubject = { loadSubset: (options: LoadSubsetOptions) => true | Promise + reset?: () => void } type CoverageSubjectFactory = ( - recordLoad: (options: LoadSubsetOptions) => true, + recordLoad: (options: LoadSubsetOptions) => true | Promise, ) => CoverageSubject class CoveredDemandRefetchedError extends Error { @@ -62,11 +74,40 @@ class CoveredDemandRefetchedError extends Error { readonly checkpoint: number, readonly requested: ReadonlySet, readonly loadedRegions: ReadonlyArray>, + readonly requestedFingerprint: string, + readonly loadedRegionFingerprints: ReadonlyArray, ) { super(`Covered demand refetched at checkpoint ${checkpoint}`) } } +class UncoveredWindowDeduplicatedError extends Error { + constructor( + readonly checkpoint: number, + readonly requested: WindowRequest, + readonly loadedRegions: ReadonlyArray<{ + request: WindowRequest + positions: ReadonlySet + }>, + ) { + super(`Uncovered window deduplicated at checkpoint ${checkpoint}`) + } +} + +class CoveredWindowRefetchedError extends Error { + constructor( + readonly checkpoint: number, + readonly requested: WindowRequest, + readonly requestedPositions: ReadonlySet, + readonly loadedRegions: ReadonlyArray<{ + request: WindowRequest + positions: ReadonlySet + }>, + ) { + super(`Covered window refetched at checkpoint ${checkpoint}`) + } +} + // The generated predicates only compare against integers from -3 through 3. // These points cover every distinct truth partition: both unbounded tails, // every equality point, and every open interval between adjacent thresholds. @@ -76,7 +117,7 @@ const valueDomain = [ const scoreRef = new PropRef([`score`]) const rankRef = new PropRef([`rank`]) -const predicateSpecArbitrary: fc.Arbitrary = fc.oneof( +const atomicPredicateSpecArbitrary: fc.Arbitrary = fc.oneof( { weight: 1, arbitrary: fc.constant({ kind: `all` as const }) }, { weight: 3, @@ -103,23 +144,44 @@ const predicateSpecArbitrary: fc.Arbitrary = fc.oneof( }, ) +const predicateSpecArbitrary: fc.Arbitrary = fc.oneof( + { weight: 8, arbitrary: atomicPredicateSpecArbitrary }, + { + weight: 2, + arbitrary: fc.record({ + kind: fc.constantFrom(`and` as const, `or` as const), + operands: fc.tuple( + atomicPredicateSpecArbitrary, + atomicPredicateSpecArbitrary, + ), + }), + }, + { + weight: 1, + arbitrary: atomicPredicateSpecArbitrary.map((operand) => ({ + kind: `not` as const, + operand, + })), + }, +) + const requestTraceArbitrary = fc.array(predicateSpecArbitrary, { minLength: 1, maxLength: 20, }) -const inValuesArbitrary = fc.uniqueArray(fc.integer({ min: -3, max: 3 }), { - minLength: 0, - maxLength: 7, -}) +const nonEmptyInValuesArbitrary = fc.uniqueArray( + fc.integer({ min: -3, max: 3 }), + { minLength: 1, maxLength: 7 }, +) // A rejected request with an in-flight deduplicated waiter currently creates a // detached rejected promise inside DeduplicatedLoadSubset. Keep that discovered // defect out of this green settlement corpus; it is pinned separately below. const asyncScenarioArbitrary: fc.Arbitrary = fc .record({ - first: inValuesArbitrary, - second: inValuesArbitrary, + first: nonEmptyInValuesArbitrary, + second: nonEmptyInValuesArbitrary, firstOutcome: fc.constantFrom( `resolve`, `reject`, @@ -141,16 +203,50 @@ const asyncScenarioArbitrary: fc.Arbitrary = fc : scenario, ) -const windowRequestArbitrary: fc.Arbitrary = fc.record({ - direction: fc.constantFrom(`asc`, `desc`), - offset: fc.integer({ min: 0, max: 6 }), - limit: fc.integer({ min: 0, max: 6 }), -}) +const concurrentAsyncScenarioArbitrary: fc.Arbitrary = + fc.record({ + requestedValues: fc.array(nonEmptyInValuesArbitrary, { + minLength: 3, + maxLength: 5, + }), + deliveryOrder: fc.constantFrom(`forward`, `reverse`), + }) -const windowTraceArbitrary = fc.array(windowRequestArbitrary, { - minLength: 1, - maxLength: 20, -}) +const windowRequestArbitrary: fc.Arbitrary> = + fc.record({ + orderField: fc.constantFrom(`none`, `rank`, `score`), + direction: fc.constantFrom(`asc`, `desc`), + nulls: fc.constantFrom(`first`, `last`), + stringSort: fc.constantFrom(`lexical`, `locale`), + offset: fc.integer({ min: 0, max: 6 }), + limit: fc.option(fc.integer({ min: 0, max: 6 }), { nil: undefined }), + }) + +const windowTraceArbitrary = fc + .record({ + where: fc.option(predicateSpecArbitrary, { nil: undefined }), + requests: fc.array(windowRequestArbitrary, { + minLength: 1, + maxLength: 20, + }), + }) + .map(({ where, requests }) => + requests.map((request) => ({ ...request, where })), + ) + +const distinctWindowWherePairArbitrary = fc + .tuple(predicateSpecArbitrary, predicateSpecArbitrary) + .filter( + ([first, second]) => + !isSubset( + matchingValues(toWhere(first)), + matchingValues(toWhere(second)), + ) || + !isSubset( + matchingValues(toWhere(second)), + matchingValues(toWhere(first)), + ), + ) function toWhere( predicate: PredicateSpec, @@ -167,9 +263,18 @@ function toWhere( scoreRef, new Value(predicate.value), ]) + case `and`: + case `or`: + return new Func(predicate.kind, predicate.operands.map(toRequiredWhere)) + case `not`: + return new Func(`not`, [toRequiredWhere(predicate.operand)]) } } +function toRequiredWhere(predicate: PredicateSpec): BasicExpression { + return toWhere(predicate) ?? new Value(true) +} + function evaluateExpression( expression: BasicExpression, score: number, @@ -249,12 +354,38 @@ const createAlwaysLoadingCoverageSubject: CoverageSubjectFactory = ( recordLoad, ) => ({ loadSubset: recordLoad }) +const createRefetchAfterSettlementSubject: CoverageSubjectFactory = ( + recordLoad, +) => { + let hasSettled = false + const dedupe = new DeduplicatedLoadSubset({ loadSubset: recordLoad }) + return { + loadSubset: (options) => { + if (hasSettled) return recordLoad(options) + const result = dedupe.loadSubset(options) + if (result instanceof Promise) { + void result.then( + () => { + hasSettled = true + }, + () => { + hasSettled = true + }, + ) + } + return result + }, + reset: () => dedupe.reset(), + } +} + function runCoverageTrace( trace: ReadonlyArray, createSubject = createDeduplicatedCoverageSubject, ): void { const covered = new Set() const loadedRegions: Array> = [] + const loadedRegionFingerprints: Array = [] const loads: Array = [] const subject = createSubject((options) => { loads.push(options) @@ -282,11 +413,14 @@ function runCoverageTrace( checkpoint, requested, loadedRegions.map((region) => new Set(region)), + JSON.stringify(predicate), + [...loadedRegionFingerprints], ) } expectSetEqual(difference(missing, loaded), new Set()) for (const value of loaded) covered.add(value) loadedRegions.push(loaded) + loadedRegionFingerprints.push(JSON.stringify(predicate)) } expectSetEqual(difference(requested, covered), new Set()) @@ -295,13 +429,14 @@ function runCoverageTrace( function runCoverageTraceWithKnownFailures( trace: ReadonlyArray, + createSubject = createDeduplicatedCoverageSubject, ): void { try { - runCoverageTrace(trace) + runCoverageTrace(trace, createSubject) } catch (error) { if ( error instanceof CoveredDemandRefetchedError && - (error.requested.size === 0 || error.loadedRegions.length > 1) + isKnownUnionCompositionRefetch(error) ) { return } @@ -309,6 +444,27 @@ function runCoverageTraceWithKnownFailures( } } +function isKnownUnionCompositionRefetch( + error: CoveredDemandRefetchedError, +): boolean { + if (error.requested.size === 0) return true + // The error can only be built after the independent model proves the demand + // is already covered. Once two unlimited regions have been composed, the + // current implementation can refetch any later covered demand, including a + // strict subset of one original region. Fixed traces below make this waiver + // expire when that product defect is repaired. + if (error.loadedRegions.length > 1) return true + + const usesCompoundPredicate = [ + error.requestedFingerprint, + ...error.loadedRegionFingerprints, + ].some((fingerprint) => /"kind":"(?:and|or|not)"/.test(fingerprint)) + return ( + usesCompoundPredicate && + error.loadedRegionFingerprints[0] !== error.requestedFingerprint + ) +} + function countLoads(trace: ReadonlyArray): number { let loads = 0 const dedupe = new DeduplicatedLoadSubset({ @@ -324,40 +480,159 @@ function countLoads(trace: ReadonlyArray): number { } function toWindowOptions(request: WindowRequest): LoadSubsetOptions { + const orderField = request.orderField ?? `rank` return { + where: request.where ? toWhere(request.where) : undefined, offset: request.offset, limit: request.limit, - orderBy: [ - { - expression: rankRef, - compareOptions: { - direction: request.direction, - nulls: `last`, - stringSort: `lexical`, - }, - }, - ], + orderBy: + orderField === `none` + ? undefined + : [ + { + expression: orderField === `rank` ? rankRef : scoreRef, + compareOptions: { + direction: request.direction, + nulls: request.nulls ?? `last`, + stringSort: request.stringSort ?? `lexical`, + }, + }, + ], } } function windowPositions(request: WindowRequest): Set { - return new Set( - Array.from({ length: request.limit }, (_, index) => request.offset + index), + if (request.where?.kind === `in` && request.where.values.length === 0) { + return new Set() + } + // The coverage oracle needs a finite universe. Generated finite windows end + // at position 11, so 16 positions preserve every generated subset relation + // while giving an omitted limit an authoritative "through the end" region. + const length = request.limit ?? 16 - request.offset + return new Set(Array.from({ length }, (_, index) => request.offset + index)) +} + +function loadedWindowCovers( + requested: WindowRequest, + loaded: WindowRequest, +): boolean { + const requestedOptions = toWindowOptions(requested) + const loadedOptions = toWindowOptions(loaded) + // An unlimited load has every row in its predicate region. It can therefore + // cover any narrower predicate and let local query processing impose the + // requested order and window. + if ( + loaded.limit === undefined && + isSubset( + matchingValues(requestedOptions.where), + matchingValues(loadedOptions.where), + ) + ) { + return true + } + if ( + JSON.stringify(requestedOptions.where) !== + JSON.stringify(loadedOptions.where) + ) { + return false + } + if (!requestedOptions.orderBy?.length) return true + if (!loadedOptions.orderBy?.length) return false + return ( + JSON.stringify(requestedOptions.orderBy) === + JSON.stringify(loadedOptions.orderBy) ) } +function isKnownCompareOptionsDeduplication( + error: UncoveredWindowDeduplicatedError, +): boolean { + const requestedOptions = toWindowOptions(error.requested) + const requestedOrder = requestedOptions.orderBy?.[0] + if (!requestedOrder) return false + const requestedPositions = windowPositions(error.requested) + + return error.loadedRegions.some(({ request: loaded, positions }) => { + const loadedOptions = toWindowOptions(loaded) + const loadedOrder = loadedOptions.orderBy?.[0] + return ( + loadedOrder !== undefined && + JSON.stringify(requestedOptions.where) === + JSON.stringify(loadedOptions.where) && + JSON.stringify(requestedOrder.expression) === + JSON.stringify(loadedOrder.expression) && + requestedOrder.compareOptions.direction === + loadedOrder.compareOptions.direction && + (requestedOrder.compareOptions.nulls !== + loadedOrder.compareOptions.nulls || + requestedOrder.compareOptions.stringSort !== + loadedOrder.compareOptions.stringSort) && + isSubset(requestedPositions, positions) + ) + }) +} + +function isKnownUnlimitedOffsetDeduplication( + error: UncoveredWindowDeduplicatedError, +): boolean { + const requestedOptions = toWindowOptions(error.requested) + + return error.loadedRegions.some(({ request: loaded }) => { + if (loaded.limit !== undefined || loaded.offset <= error.requested.offset) { + return false + } + const loadedOptions = toWindowOptions(loaded) + return isSubset( + matchingValues(requestedOptions.where), + matchingValues(loadedOptions.where), + ) + }) +} + +function isKnownCoveredWindowRefetch( + error: CoveredWindowRefetchedError, +): boolean { + if ( + error.requestedPositions.size === 0 && + (error.requested.limit === 0 || + (error.requested.where?.kind === `in` && + error.requested.where.values.length === 0)) + ) { + return true + } + if (error.loadedRegions.length > 1) return true + if (error.requested.where === undefined) return false + + return error.loadedRegions.some( + ({ request: loaded, positions }) => + loadedWindowCovers(error.requested, loaded) && + isSubset(error.requestedPositions, positions), + ) +} + +const createWindowKeyBlindSubject: CoverageSubjectFactory = (recordLoad) => { + const coveredWindows = new Set() + return { + loadSubset: (options) => { + const key = JSON.stringify({ + offset: options.offset ?? 0, + limit: options.limit, + }) + if (coveredWindows.has(key)) return true + coveredWindows.add(key) + return recordLoad(options) + }, + } +} + function runWindowCoverageTrace( trace: ReadonlyArray, createSubject = createDeduplicatedCoverageSubject, ): void { - const coveredByOrder = new Map<`asc` | `desc`, Set>([ - [`asc`, new Set()], - [`desc`, new Set()], - ]) - const loadedRegionsByOrder = new Map<`asc` | `desc`, Array>>([ - [`asc`, []], - [`desc`, []], - ]) + const loadedRegions: Array<{ + request: WindowRequest + positions: Set + }> = [] const loads: Array = [] const subject = createSubject((options) => { loads.push(options) @@ -366,8 +641,12 @@ function runWindowCoverageTrace( for (const [checkpoint, request] of trace.entries()) { const requested = windowPositions(request) - const covered = coveredByOrder.get(request.direction)! - const loadedRegions = loadedRegionsByOrder.get(request.direction)! + const compatibleRegions = loadedRegions.filter(({ request: loaded }) => + loadedWindowCovers(request, loaded), + ) + const covered = new Set( + compatibleRegions.flatMap(({ positions }) => [...positions]), + ) const missing = difference(requested, covered) const callsBefore = loads.length @@ -375,23 +654,32 @@ function runWindowCoverageTrace( expect(loads.length - callsBefore).toBeLessThanOrEqual(1) if (loads.length === callsBefore) { - expectSetEqual(missing, new Set()) + if (missing.size > 0) { + throw new UncoveredWindowDeduplicatedError( + checkpoint, + request, + loadedRegions.map(({ request: loaded, positions }) => ({ + request: { ...loaded }, + positions: new Set(positions), + })), + ) + } } else { const loaded = loads.at(-1)! - expect(loaded.offset ?? 0).toBe(request.offset) - expect(loaded.limit).toBe(request.limit) - expect(loaded.orderBy?.[0]?.compareOptions.direction).toBe( - request.direction, - ) + expect(loaded).toEqual(toWindowOptions(request)) if (missing.size === 0) { - throw new CoveredDemandRefetchedError( + throw new CoveredWindowRefetchedError( checkpoint, - requested, - loadedRegions.map((region) => new Set(region)), + { ...request }, + new Set(requested), + compatibleRegions.map(({ request: previous, positions }) => ({ + request: { ...previous }, + positions: new Set(positions), + })), ) } for (const position of requested) covered.add(position) - loadedRegions.push(requested) + loadedRegions.push({ request: { ...request }, positions: requested }) } expectSetEqual(difference(requested, covered), new Set()) } @@ -399,13 +687,21 @@ function runWindowCoverageTrace( function runWindowCoverageTraceWithKnownFailures( trace: ReadonlyArray, + createSubject = createDeduplicatedCoverageSubject, ): void { try { - runWindowCoverageTrace(trace) + runWindowCoverageTrace(trace, createSubject) } catch (error) { if ( - error instanceof CoveredDemandRefetchedError && - (error.requested.size === 0 || error.loadedRegions.length > 1) + error instanceof UncoveredWindowDeduplicatedError && + (isKnownCompareOptionsDeduplication(error) || + isKnownUnlimitedOffsetDeduplication(error)) + ) { + return + } + if ( + error instanceof CoveredWindowRefetchedError && + isKnownCoveredWindowRefetch(error) ) { return } @@ -425,27 +721,43 @@ function countWindowLoads(trace: ReadonlyArray): number { return loads } -async function runAsyncScenario(scenario: AsyncScenario): Promise { +function expectDistinctWhereStartsDistinctLimitedWindowLoads( + predicates: readonly [PredicateSpec, PredicateSpec], +): void { + const createRequest = (where: PredicateSpec): WindowRequest => ({ + where, + orderField: `rank`, + direction: `asc`, + nulls: `last`, + stringSort: `lexical`, + offset: 0, + limit: 2, + }) + expect(countWindowLoads(predicates.map(createRequest))).toBe(2) +} + +async function runAsyncScenario( + scenario: AsyncScenario, + createSubject: CoverageSubjectFactory = createDeduplicatedCoverageSubject, +): Promise { const requests: Array<{ options: LoadSubsetOptions deferred: ReturnType> }> = [] - const dedupe = new DeduplicatedLoadSubset({ - loadSubset: (options) => { - const deferred = createDeferred() - // The source promise is intentionally rejectable. Observe it directly as - // well as through the dedupe wrapper so Vitest never mistakes a generated - // transport rejection for an unhandled test error. - void deferred.promise.catch(() => undefined) - requests.push({ options, deferred }) - return deferred.promise - }, + const subject = createSubject((options) => { + const deferred = createDeferred() + // The source promise is intentionally rejectable. Observe it directly as + // well as through the dedupe wrapper so Vitest never mistakes a generated + // transport rejection for an unhandled test error. + void deferred.promise.catch(() => undefined) + requests.push({ options, deferred }) + return deferred.promise }) - const firstResult = dedupe.loadSubset({ + const firstResult = subject.loadSubset({ where: toWhere({ kind: `in`, values: scenario.first }), }) - const secondResult = dedupe.loadSubset({ + const secondResult = subject.loadSubset({ where: toWhere({ kind: `in`, values: scenario.second }), }) expect(firstResult).toBeInstanceOf(Promise) @@ -460,7 +772,7 @@ async function runAsyncScenario(scenario: AsyncScenario): Promise { expect(requests).toHaveLength(secondCoveredByFirst ? 1 : 2) expect(firstResult === secondResult).toBe(secondCoveredByFirst) - if (scenario.resetBeforeSettlement) dedupe.reset() + if (scenario.resetBeforeSettlement) subject.reset?.() const outcomes = [scenario.firstOutcome, scenario.secondOutcome] as const const deliveryIndices = @@ -499,7 +811,7 @@ async function runAsyncScenario(scenario: AsyncScenario): Promise { } const callsBeforeRetry = requests.length - const retry = dedupe.loadSubset({ + const retry = subject.loadSubset({ where: toWhere({ kind: `in`, values: scenario.second }), }) const retryWasCovered = isSubset(secondSet, successfullyCovered) @@ -508,6 +820,11 @@ async function runAsyncScenario(scenario: AsyncScenario): Promise { expect(retry).toBe(true) expect(requests).toHaveLength(callsBeforeRetry) } else { + try { + expect(retryWasCovered).toBe(false) + } catch (error) { + throw new TraceAssertionError(2, error) + } expect(retry).toBeInstanceOf(Promise) expect(requests).toHaveLength(callsBeforeRetry + 1) const retriedValues = matchingValues(requests.at(-1)?.options.where) @@ -519,6 +836,73 @@ async function runAsyncScenario(scenario: AsyncScenario): Promise { } } +async function runConcurrentAsyncScenario( + scenario: ConcurrentAsyncScenario, +): Promise { + const transports: Array<{ + values: Set + deferred: ReturnType> + result?: Promise + }> = [] + const subject = createDeduplicatedCoverageSubject((options) => { + const deferred = createDeferred() + transports.push({ values: matchingValues(options.where), deferred }) + return deferred.promise + }) + const callerResults: Array> = [] + + for (const values of scenario.requestedValues) { + const requested = new Set(values) + const coveringIndex = transports.findIndex(({ values: loaded }) => + isSubset(requested, loaded), + ) + const transportCount = transports.length + const result = subject.loadSubset({ + where: toWhere({ kind: `in`, values }), + }) + expect(result).toBeInstanceOf(Promise) + if (!(result instanceof Promise)) { + throw new Error(`Concurrent async requests must remain pending`) + } + callerResults.push(result) + + if (coveringIndex === -1) { + expect(transports).toHaveLength(transportCount + 1) + transports.at(-1)!.result = result + } else { + expect(transports).toHaveLength(transportCount) + expect(result).toBe(transports[coveringIndex]!.result) + } + } + + const delivery = + scenario.deliveryOrder === `forward` + ? transports + : [...transports].reverse() + for (const { deferred } of delivery) deferred.resolve() + await Promise.all(callerResults) +} + +async function runAsyncScenarioWithKnownFailures( + scenario: AsyncScenario, +): Promise { + try { + await runAsyncScenario(scenario) + } catch (error) { + if ( + error instanceof TraceAssertionError && + error.checkpoint === 2 && + !scenario.resetBeforeSettlement && + scenario.firstOutcome === `resolve` && + scenario.secondOutcome === `resolve` && + !isSubset(new Set(scenario.second), new Set(scenario.first)) + ) { + return + } + throw error + } +} + function readPositiveInteger(name: string, fallback: number): number { const raw = process.env[name] if (raw === undefined) return fallback @@ -665,54 +1049,49 @@ async function expectDerivedSyncDuringOptimisticMutation(): Promise { } } -async function captureUnhandledRejections( - run: () => Promise, -): Promise> { - const vitestHandler = process - .listeners(`unhandledRejection`) - .find((listener) => listener.name === `vitestUnhandledRejectionHandler`) - const reasons: Array = [] - const capture = (reason: unknown) => reasons.push(reason) - - if (vitestHandler) process.removeListener(`unhandledRejection`, vitestHandler) - process.on(`unhandledRejection`, capture) - try { - await run() - await new Promise((resolve) => setTimeout(resolve, 0)) - return reasons - } finally { - process.removeListener(`unhandledRejection`, capture) - if (vitestHandler) process.on(`unhandledRejection`, vitestHandler) +async function expectDeduplicatedWaiterHandlesRejection(): Promise { + const detachedBranches: Array> = [] + class LocallyTrackedPromise extends Promise { + catch( + onRejected?: ((reason: unknown) => TResult | PromiseLike) | null, + ): Promise { + const branch = super.catch(onRejected) + detachedBranches.push(branch) + return branch + } } -} -async function expectDeduplicatedWaiterHandlesRejection(): Promise { - const deferred = createDeferred() + let rejectSource!: (reason?: unknown) => void + const sourcePromise = new LocallyTrackedPromise((_resolve, reject) => { + rejectSource = reject + }) const dedupe = new DeduplicatedLoadSubset({ - loadSubset: () => deferred.promise, + loadSubset: () => sourcePromise, }) - const unhandled = await captureUnhandledRejections(async () => { - const first = dedupe.loadSubset({ - where: toWhere({ kind: `in`, values: [1, 2] }), - }) - const second = dedupe.loadSubset({ - where: toWhere({ kind: `eq`, value: 1 }), - }) - if (!(first instanceof Promise) || !(second instanceof Promise)) { - throw new Error(`Both callers must wait for the in-flight request`) - } - - const callerOutcomes = Promise.allSettled([first, second]) - deferred.reject(new Error(`transport failed`)) - expect((await callerOutcomes).map(({ status }) => status)).toEqual([ - `rejected`, - `rejected`, - ]) + const first = dedupe.loadSubset({ + where: toWhere({ kind: `in`, values: [1, 2] }), + }) + const second = dedupe.loadSubset({ + where: toWhere({ kind: `eq`, value: 1 }), }) + if (!(first instanceof Promise) || !(second instanceof Promise)) { + throw new Error(`Both callers must wait for the in-flight request`) + } + + const callerOutcomes = Promise.allSettled([first, second]) + const detachedOutcomes = Promise.allSettled(detachedBranches) + rejectSource(new Error(`transport failed`)) + expect((await callerOutcomes).map(({ status }) => status)).toEqual([ + `rejected`, + `rejected`, + ]) try { - expect(unhandled).toEqual([]) + expect({ + branchCount: detachedBranches.length, + statuses: (await detachedOutcomes).map(({ status }) => status), + }).toEqual({ branchCount: 1, statuses: [`fulfilled`] }) } catch (error) { throw new TraceAssertionError(0, error) } @@ -743,6 +1122,60 @@ describe(`loadSubset coverage oracle`, () => { ), ) + it( + `discovered trace: an empty filtered window issues no transport work`, + expectAssertionFailure( + () => + Promise.resolve().then(() => { + expect( + countWindowLoads([ + { + where: { kind: `in`, values: [] }, + direction: `asc`, + offset: 0, + limit: 1, + }, + ]), + ).toBe(0) + }), + { message: /expected 1 to be/ }, + ), + ) + + it( + `discovered trace: widening an unlimited offset starts another load`, + expectAssertionFailure( + () => + Promise.resolve().then(() => { + expect( + countWindowLoads([ + { direction: `asc`, offset: 1, limit: undefined }, + { direction: `asc`, offset: 0, limit: undefined }, + ]), + ).toBe(2) + }), + { message: /expected 1 to be/ }, + ), + ) + + it( + `discovered trace: an identical filtered window reuses its load`, + expectAssertionFailure( + () => + Promise.resolve().then(() => { + const request: WindowRequest = { + where: { kind: `in`, values: [0] }, + orderField: `none`, + direction: `asc`, + offset: 0, + limit: 1, + } + expect(countWindowLoads([request, request])).toBe(1) + }), + { message: /expected 2 to be/ }, + ), + ) + it(`rejects repeated transport work for one covered predicate`, () => { expect(() => runCoverageTrace( @@ -777,6 +1210,57 @@ describe(`loadSubset coverage oracle`, () => { ).toThrow() }) + it( + `discovered trace: a covered compound predicate issues no second load`, + expectAssertionFailure( + () => + Promise.resolve().then(() => { + try { + expect( + countLoads([ + { + kind: `and`, + operands: [ + { kind: `range`, operator: `gte`, value: 0 }, + { kind: `not`, operand: { kind: `eq`, value: 2 } }, + ], + }, + { + kind: `or`, + operands: [ + { kind: `eq`, value: 1 }, + { kind: `eq`, value: 3 }, + ], + }, + ]), + ).toBe(1) + } catch (error) { + throw new TraceAssertionError(0, error) + } + }), + { + checkpoint: 0, + classify: ({ actual, expected }) => actual === 2 && expected === 1, + }, + ), + ) + + it(`rejects repeated transport work for one identical compound predicate`, () => { + const predicate: PredicateSpec = { + kind: `and`, + operands: [ + { kind: `range`, operator: `gte`, value: 0 }, + { kind: `not`, operand: { kind: `eq`, value: 2 } }, + ], + } + expect(() => + runCoverageTraceWithKnownFailures( + [predicate, predicate], + createAlwaysLoadingCoverageSubject, + ), + ).toThrow() + }) + it(`rejects repeated transport work for one covered window`, () => { expect(() => runWindowCoverageTrace( @@ -799,6 +1283,171 @@ describe(`loadSubset coverage oracle`, () => { ]) }) + it(`reuses an unlimited load across local orderings`, () => { + runWindowCoverageTrace([ + { + orderField: `none`, + direction: `asc`, + offset: 0, + limit: undefined, + }, + { + where: { kind: `range`, operator: `gt`, value: 0 }, + orderField: `score`, + direction: `desc`, + nulls: `first`, + stringSort: `locale`, + offset: 0, + limit: undefined, + }, + ]) + }) + + it.each([ + [ + `where`, + { + where: { kind: `eq`, value: 2 }, + orderField: `rank`, + direction: `asc`, + nulls: `last`, + stringSort: `lexical`, + offset: 0, + limit: 2, + }, + ], + [ + `order expression`, + { + where: { kind: `eq`, value: 1 }, + orderField: `score`, + direction: `asc`, + nulls: `last`, + stringSort: `lexical`, + offset: 0, + limit: 2, + }, + ], + [ + `null placement`, + { + where: { kind: `eq`, value: 1 }, + orderField: `rank`, + direction: `asc`, + nulls: `first`, + stringSort: `lexical`, + offset: 0, + limit: 2, + }, + ], + [ + `string ordering`, + { + where: { kind: `eq`, value: 1 }, + orderField: `rank`, + direction: `asc`, + nulls: `last`, + stringSort: `locale`, + offset: 0, + limit: 2, + }, + ], + ] satisfies ReadonlyArray)( + `does not reuse window coverage across a different %s`, + (_name, changedRequest) => { + const baseRequest: WindowRequest = { + where: { kind: `eq`, value: 1 }, + orderField: `rank`, + direction: `asc`, + nulls: `last`, + stringSort: `lexical`, + offset: 0, + limit: 2, + } + expect(() => + runWindowCoverageTrace( + [baseRequest, changedRequest], + createWindowKeyBlindSubject, + ), + ).toThrow() + }, + ) + + it.each([ + [ + `null placement`, + { nulls: `first`, stringSort: `lexical` }, + { nulls: `last`, stringSort: `lexical` }, + ], + [ + `string ordering`, + { nulls: `first`, stringSort: `lexical` }, + { nulls: `first`, stringSort: `locale` }, + ], + ] as const)( + `discovered trace: a different %s starts a distinct window load`, + async (_name, firstOptions, secondOptions) => { + const createRequest = ( + compareOptions: typeof firstOptions | typeof secondOptions, + ): WindowRequest => ({ + direction: `asc`, + orderField: `rank`, + offset: 0, + limit: 1, + ...compareOptions, + }) + await expectAssertionFailure( + () => + Promise.resolve().then(() => { + try { + expect( + countWindowLoads([ + createRequest(firstOptions), + createRequest(secondOptions), + ]), + ).toBe(2) + } catch (error) { + throw new TraceAssertionError(0, error) + } + }), + { + checkpoint: 0, + classify: ({ actual, expected }) => actual === 1 && expected === 2, + }, + )() + }, + ) + + it(`rejects async transport work after coverage settles`, async () => { + await expect( + runAsyncScenario( + { + first: [1], + second: [1], + firstOutcome: `resolve`, + secondOutcome: `resolve`, + deliveryOrder: `forward`, + resetBeforeSettlement: false, + }, + createRefetchAfterSettlementSubject, + ), + ).rejects.toThrow() + }) + + it(`discovered trace: settled predicate regions cover their union`, async () => { + await expectAssertionFailure(runAsyncScenario, { + checkpoint: 2, + classify: ({ actual, expected }) => actual === true && expected === false, + })({ + first: [0], + second: [1], + firstOutcome: `resolve`, + secondOutcome: `resolve`, + deliveryOrder: `forward`, + resetBeforeSettlement: false, + }) + }) + fcTest.prop([requestTraceArbitrary], { numRuns: runs, seed: 1657 })( `matches finite-domain coverage for a fixed seed`, runCoverageTraceWithKnownFailures, @@ -811,12 +1460,25 @@ describe(`loadSubset coverage oracle`, () => { fcTest.prop([asyncScenarioArbitrary], { numRuns: runs, seed: 1658 })( `settles, retries, and resets in-flight set requests for a fixed seed`, - runAsyncScenario, + runAsyncScenarioWithKnownFailures, ) fcTest.prop([asyncScenarioArbitrary], randomParameters)( `settles, retries, and resets in-flight set requests for a random or replayed seed`, - runAsyncScenario, + runAsyncScenarioWithKnownFailures, + ) + + fcTest.prop([concurrentAsyncScenarioArbitrary], { + numRuns: runs, + seed: 1661, + })( + `deduplicates three or more concurrent requests for a fixed seed`, + runConcurrentAsyncScenario, + ) + + fcTest.prop([concurrentAsyncScenarioArbitrary], randomParameters)( + `deduplicates three or more concurrent requests for a random or replayed seed`, + runConcurrentAsyncScenario, ) fcTest.prop([windowTraceArbitrary], { numRuns: runs, seed: 1659 })( @@ -829,17 +1491,38 @@ describe(`loadSubset coverage oracle`, () => { runWindowCoverageTraceWithKnownFailures, ) + fcTest.prop([distinctWindowWherePairArbitrary], { + numRuns: runs, + seed: 1662, + })( + `keeps distinct limited-window predicates separate for a fixed seed`, + expectDistinctWhereStartsDistinctLimitedWindowLoads, + ) + + fcTest.prop([distinctWindowWherePairArbitrary], randomParameters)( + `keeps distinct limited-window predicates separate for a random or replayed seed`, + expectDistinctWhereStartsDistinctLimitedWindowLoads, + ) + it( `an in-flight deduplicated waiter rejects without an unhandled branch`, expectAssertionFailure(expectDeduplicatedWaiterHandlesRejection, { checkpoint: 0, classify: ({ actual, expected }) => - Array.isArray(actual) && - actual.length === 1 && - actual[0] instanceof Error && - actual[0].message === `transport failed` && - Array.isArray(expected) && - expected.length === 0, + typeof actual === `object` && + actual !== null && + `branchCount` in actual && + actual.branchCount === 1 && + `statuses` in actual && + Array.isArray(actual.statuses) && + actual.statuses.join(`,`) === `rejected` && + typeof expected === `object` && + expected !== null && + `branchCount` in expected && + expected.branchCount === 1 && + `statuses` in expected && + Array.isArray(expected.statuses) && + expected.statuses.join(`,`) === `fulfilled`, }), ) diff --git a/packages/db/tests/query/pagination-oracle.property.test.ts b/packages/db/tests/query/pagination-oracle.property.test.ts index 300551db31..12a514e115 100644 --- a/packages/db/tests/query/pagination-oracle.property.test.ts +++ b/packages/db/tests/query/pagination-oracle.property.test.ts @@ -68,6 +68,28 @@ type PendingMutation = | { type: `delete`; id: number } | { type: `update`; row: PageRow } +type PendingMutationScenario = { + ranks: ReadonlyArray + direction: `asc` | `desc` + limit: number + mutation: PendingMutation +} + +type PendingHistoryScenario = { + ranks: ReadonlyArray + direction: `asc` | `desc` + initialLimit: number + narrowLimit: number + wideLimit: number + firstRank: number + secondRank: number +} + +type PendingHistoryObservation = { + rows: Array + modeledDeliveredRows: Array +} + const scenarioArbitrary: fc.Arbitrary = fc.record({ ranks: fc.array(fc.integer({ min: -2, max: 2 }), { minLength: 1, @@ -128,6 +150,134 @@ const stateScenarioArbitrary: fc.Arbitrary = fc.record( }, ) +const pendingMutationScenarioArbitrary: fc.Arbitrary = + fc + .record({ + ranks: fc.array(fc.integer({ min: -2, max: 2 }), { + minLength: 3, + maxLength: 8, + }), + direction: fc.constantFrom(`asc` as const, `desc` as const), + requestedLimit: fc.integer({ min: 1, max: 8 }), + mutationKind: fc.constantFrom( + `insert` as const, + `update` as const, + `delete` as const, + ), + targetIndex: fc.nat({ max: 7 }), + rank: fc.integer({ min: -2, max: 2 }), + }) + .map( + ({ + ranks, + direction, + requestedLimit, + mutationKind, + targetIndex, + rank, + }) => { + const id = (targetIndex % ranks.length) + 1 + const previousRank = ranks[id - 1]! + const changedRank = + rank === previousRank ? (rank === 2 ? -2 : rank + 1) : rank + const mutation: PendingMutation = + mutationKind === `insert` + ? { type: `insert`, row: { id: ranks.length + 1, rank } } + : mutationKind === `update` + ? { type: `update`, row: { id, rank: changedRank } } + : { type: `delete`, id } + return { + ranks, + direction, + limit: Math.min(requestedLimit, ranks.length), + mutation, + } + }, + ) + +const pendingHistoryScenarioArbitrary: fc.Arbitrary = fc + .record({ + ranks: fc.array(fc.integer({ min: -2, max: 2 }), { + minLength: 4, + maxLength: 8, + }), + direction: fc.constantFrom(`asc` as const, `desc` as const), + requestedInitialLimit: fc.integer({ min: 2, max: 7 }), + requestedNarrowLimit: fc.integer({ min: 1, max: 6 }), + requestedWideLimit: fc.integer({ min: 3, max: 8 }), + firstRank: fc.integer({ min: -2, max: 2 }), + secondRank: fc.integer({ min: -2, max: 2 }), + }) + .map( + ({ + ranks, + direction, + requestedInitialLimit, + requestedNarrowLimit, + requestedWideLimit, + firstRank, + secondRank, + }) => { + const initialLimit = Math.min(requestedInitialLimit, ranks.length - 1) + return { + ranks, + direction, + initialLimit, + narrowLimit: Math.min(requestedNarrowLimit, initialLimit - 1), + wideLimit: Math.max( + initialLimit + 1, + Math.min(requestedWideLimit, ranks.length), + ), + firstRank, + secondRank, + } + }, + ) + +const responseTimingArbitrary = fc.constantFrom( + `before-response` as const, + `after-response` as const, +) + +const nullableNumberArbitrary = fc.option(fc.integer({ min: -2, max: 2 }), { + nil: null, +}) + +const multiOrderTermArbitrary: fc.Arbitrary = fc.record({ + direction: fc.constantFrom(`asc` as const, `desc` as const), + nulls: fc.constantFrom(`first` as const, `last` as const), +}) + +const multiOrderScenarioArbitrary: fc.Arbitrary = fc + .record({ + rows: fc.uniqueArray( + fc.record({ + id: fc.integer({ min: 1, max: 12 }), + primary: nullableNumberArbitrary, + secondary: nullableNumberArbitrary, + }), + { + minLength: 2, + maxLength: 10, + selector: ({ id }) => id, + }, + ), + primary: multiOrderTermArbitrary, + secondary: multiOrderTermArbitrary, + requestedLimit: fc.integer({ min: 1, max: 10 }), + }) + .filter(({ rows }) => + rows.some( + ({ primary, secondary }) => primary === null || secondary === null, + ), + ) + .map(({ rows, primary, secondary, requestedLimit }) => ({ + rows, + primary, + secondary, + limit: Math.min(requestedLimit, rows.length), + })) + function readPositiveInteger(name: string, fallback: number): number { const raw = process.env[name] if (raw === undefined) return fallback @@ -339,6 +489,39 @@ 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 { @@ -375,6 +558,43 @@ 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 runPaginationStateScenario( scenario: PaginationStateScenario, ): Promise { @@ -518,6 +738,30 @@ function readPageRowDifference(error: unknown): PageRowDifference | undefined { } } +function readPageRowDifferenceAtCheckpoint( + error: unknown, + checkpoint: number, +): PageRowDifference | undefined { + if ( + !(error instanceof TraceAssertionError) || + error.checkpoint !== 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, + actual: error.cause.actual, + expected: error.cause.expected, + } +} + function sameRows( left: ReadonlyArray, right: ReadonlyArray, @@ -958,17 +1202,19 @@ async function expectOnDemandWindowsAreCompletionOrderIndependent( } async function runPendingMutationScenario( - mutation: PendingMutation, + scenario: PendingMutationScenario, timing: `before-response` | `after-response`, ): Promise { - const rows = new Map([ - [1, { id: 1, rank: 0 }], - [2, { id: 2, rank: 1 }], - [3, { id: 3, rank: 2 }], - [4, { id: 4, rank: 3 }], - ]) + const rows = new Map( + scenario.ranks.map((rank, index) => [index + 1, { id: index + 1, rank }]), + ) + const firstDelivered = referenceWindowRows( + [...rows.values()], + scenario.direction, + { offset: 0, limit: 1 }, + )[0]! const pending: Array = [] - const deliveredIds = new Set([1]) + const deliveredIds = new Set([firstDelivered.id]) let begin!: () => void let write!: (message: { type: `insert` | `update` | `delete` @@ -989,7 +1235,7 @@ async function runPendingMutationScenario( write = params.write commit = params.commit begin() - write({ type: `insert`, value: { ...rows.get(1)! } }) + write({ type: `insert`, value: { ...firstDelivered } }) commit() params.markReady() return { @@ -1005,12 +1251,13 @@ async function runPendingMutationScenario( const live = createLiveQueryCollection((query) => query .from({ row: source }) - .orderBy(({ row }) => row.rank, `asc`) + .orderBy(({ row }) => row.rank, scenario.direction) .orderBy(({ row }) => row.id, `asc`) - .limit(3), + .limit(scenario.limit), ) const applyMutation = () => { + const { mutation } = scenario begin() if (mutation.type === `delete`) { const row = rows.get(mutation.id) @@ -1030,10 +1277,14 @@ async function runPendingMutationScenario( for (const request of pending) { if (request.settled) continue request.settled = true - const orderedRows = referenceWindowRows([...rows.values()], `asc`, { - offset: 0, - limit: rows.size, - }) + const orderedRows = referenceWindowRows( + [...rows.values()], + scenario.direction, + { + offset: 0, + limit: rows.size, + }, + ) begin() for (const row of rowsForLoadSubset(orderedRows, request.options)) { if (deliveredIds.has(row.id)) continue @@ -1063,9 +1314,9 @@ async function runPendingMutationScenario( expect( Array.from(live.values(), ({ id, rank }) => ({ id, rank })), ).toEqual( - referenceWindowRows([...rows.values()], `asc`, { + referenceWindowRows([...rows.values()], scenario.direction, { offset: 0, - limit: 3, + limit: scenario.limit, }), ) } catch (error) { @@ -1078,6 +1329,373 @@ 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 (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) + ) +} + +async function runPendingMutationScenarioWithKnownFailures( + scenario: PendingMutationScenario, + timing: `before-response` | `after-response`, +): Promise { + try { + await runPendingMutationScenario(scenario, timing) + } catch (error) { + if (isKnownSettledTopKMembershipFailure(scenario, timing, error)) return + throw error + } +} + +async function runRejectedCursorRetryAfterMutation(): Promise { + const rows = new Map([ + [1, { id: 1, rank: 0 }], + [2, { id: 2, rank: 1 }], + [3, { id: 3, rank: 2 }], + [4, { id: 4, rank: 3 }], + ]) + const pending: Array = [] + const deliveredIds = new Set([1]) + let begin!: () => void + let write!: (message: { type: `insert` | `update`; value: PageRow }) => void + let commit!: () => void + const source = createCollection({ + id: `pagination-rejected-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 + begin() + write({ type: `insert`, value: { ...rows.get(1)! } }) + commit() + params.markReady() + return { + loadSubset: (options: LoadSubsetOptions) => { + const deferred = createDeferred() + pending.push({ options, deferred }) + return 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): Promise => { + request.settled = true + begin() + const orderedRows = referenceWindowRows([...rows.values()], `asc`, { + offset: 0, + limit: rows.size, + }) + for (const row of rowsForLoadSubset(orderedRows, request.options)) { + if (deliveredIds.has(row.id)) continue + deliveredIds.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + commit() + request.deferred.resolve() + await Promise.resolve() + } + + try { + const preload = live.preload() + expect(pending).toHaveLength(1) + + rows.set(1, { id: 1, rank: 3 }) + begin() + write({ type: `update`, value: { id: 1, rank: 3 } }) + commit() + + pending[0]!.settled = true + pending[0]!.deferred.reject(new Error(`cursor failed`)) + await preload + await Promise.resolve() + + const retry = live.utils.setWindow({ offset: 0, limit: 3 }) + expect(pending).toHaveLength(2) + await settle(pending[1]!) + if (retry instanceof Promise) await retry + + try { + expect(Array.from(live.values(), ({ id }) => id)).toEqual([2, 3, 1]) + } catch (error) { + throw new TraceAssertionError(0, error) + } + } finally { + for (const request of pending) request.deferred.resolve() + live.cleanup() + source.cleanup() + } +} + +async function runPendingHistoryScenario( + scenario: PendingHistoryScenario, +): Promise { + const rows = new Map( + scenario.ranks.map((rank, index) => [index + 1, { id: index + 1, rank }]), + ) + const firstDelivered = referenceWindowRows( + [...rows.values()], + scenario.direction, + { offset: 0, limit: 1 }, + )[0]! + const pending: Array = [] + const deliveredIds = new Set([firstDelivered.id]) + const outstanding: Array> = [] + let begin!: () => void + let write!: (message: { type: `insert` | `update`; value: PageRow }) => void + let commit!: () => void + const source = createCollection({ + id: `pagination-pending-history-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: { ...firstDelivered } }) + commit() + params.markReady() + return { + loadSubset: (options: LoadSubsetOptions) => { + const deferred = createDeferred() + pending.push({ options, deferred }) + return deferred.promise + }, + } + }, + }, + }) + const live = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank, scenario.direction) + .orderBy(({ row }) => row.id, `asc`) + .limit(scenario.initialLimit), + ) + + const updateFirstDelivered = (rank: number): void => { + const previous = rows.get(firstDelivered.id)! + const changedRank = changedRankValue(previous.rank, rank) + const next = { ...previous, rank: changedRank } + rows.set(next.id, next) + begin() + write({ type: `update`, value: { ...next } }) + commit() + } + + const settle = async (request: PendingCursorLoad): Promise => { + request.settled = true + const orderedRows = referenceWindowRows( + [...rows.values()], + scenario.direction, + { offset: 0, limit: rows.size }, + ) + begin() + for (const row of rowsForLoadSubset(orderedRows, request.options)) { + if (deliveredIds.has(row.id)) continue + deliveredIds.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + commit() + request.deferred.resolve() + await Promise.resolve() + } + + const track = (result: true | Promise): void => { + if (result instanceof Promise) outstanding.push(result) + } + + try { + outstanding.push(live.preload()) + expect(pending).toHaveLength(1) + + updateFirstDelivered(scenario.firstRank) + track(live.utils.setWindow({ offset: 0, limit: scenario.narrowLimit })) + track(live.utils.setWindow({ offset: 0, limit: scenario.wideLimit })) + expect(pending).toHaveLength(1) + updateFirstDelivered(scenario.secondRank) + + await settle(pending[0]!) + for (let index = 1; index < pending.length; index++) { + await settle(pending[index]!) + } + await Promise.all(outstanding) + + try { + const actual = Array.from(live.values(), ({ id, rank }) => ({ id, rank })) + const expected = referenceWindowRows( + [...rows.values()], + scenario.direction, + { offset: 0, limit: scenario.wideLimit }, + ) + const modeledDeliveredRows = referenceWindowRows( + [...rows.values()].filter(({ id }) => deliveredIds.has(id)), + scenario.direction, + { offset: 0, limit: scenario.wideLimit }, + ) + expect({ + rows: actual, + modeledDeliveredRows, + } satisfies PendingHistoryObservation).toEqual({ + rows: expected, + modeledDeliveredRows, + } satisfies PendingHistoryObservation) + } catch (error) { + throw new TraceAssertionError(0, error) + } + } finally { + for (const request of pending) request.deferred.resolve() + await Promise.allSettled(outstanding) + live.cleanup() + source.cleanup() + } +} + +function changedRankValue(previous: number, requested: number): number { + return requested === previous + ? requested === 2 + ? -2 + : requested + 1 + : 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 isPendingHistoryObservation( + value: unknown, +): value is PendingHistoryObservation { + return ( + typeof value === `object` && + value !== null && + `rows` in value && + isPageRowArray(value.rows) && + `modeledDeliveredRows` in value && + isPageRowArray(value.modeledDeliveredRows) + ) +} + +function isKnownLatePendingHistoryUnderfill( + scenario: PendingHistoryScenario, + 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) || + !isPendingHistoryObservation(error.cause.actual) || + !isPendingHistoryObservation(error.cause.expected) + ) { + return false + } + + const actual = error.cause.actual + const expected = error.cause.expected + const authoritative = referenceWindowRows( + [...pendingHistoryRows(scenario).values()], + scenario.direction, + { offset: 0, limit: scenario.wideLimit }, + ) + return ( + sameRows(expected.rows, authoritative) && + sameRows(actual.modeledDeliveredRows, expected.modeledDeliveredRows) && + !sameRows(actual.modeledDeliveredRows, authoritative) && + sameRows(actual.rows, actual.modeledDeliveredRows) + ) +} + +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 }, @@ -1218,7 +1836,7 @@ describe(`pagination recomputation oracle`, () => { secondary: { direction: `asc`, nulls: `first` }, limit: 1, }, - true, + { actual: [1], expected: [2] }, ], [ `orders a descending nullable boundary by its second term`, @@ -1228,7 +1846,7 @@ describe(`pagination recomputation oracle`, () => { secondary: { direction: `desc`, nulls: `first` }, limit: 1, }, - false, + undefined, ], [ `orders an ascending and descending mixed nullable boundary`, @@ -1238,7 +1856,7 @@ describe(`pagination recomputation oracle`, () => { secondary: { direction: `desc`, nulls: `first` }, limit: 1, }, - false, + undefined, ], [ `discovered trace: orders a descending and ascending mixed nullable boundary`, @@ -1248,7 +1866,7 @@ describe(`pagination recomputation oracle`, () => { secondary: { direction: `asc`, nulls: `first` }, limit: 1, }, - true, + { actual: [1], expected: [2] }, ], [ `uses the public key to break a complete tuple tie`, @@ -1261,28 +1879,86 @@ describe(`pagination recomputation oracle`, () => { secondary: { direction: `asc`, nulls: `last` }, limit: 1, }, - false, + undefined, ], - ] satisfies ReadonlyArray)( - `%s`, - async (_name, scenario, expectsFailure) => { - if (!expectsFailure) { - await runMultiOrderScenario(scenario) - return - } - await expectAssertionFailure(runMultiOrderScenario, { - checkpoint: 0, - classify: ({ actual, expected }) => - isNumberArray(actual) && - actual.length === 1 && - actual[0] === 1 && - isNumberArray(expected) && - expected.length === 1 && - expected[0] === 2, - })(scenario) - }, + [ + `discovered trace: places nulls last in an ascending nullable boundary`, + { + rows: nullableBoundaryRows, + primary: { direction: `asc`, nulls: `last` }, + secondary: { direction: `asc`, nulls: `last` }, + limit: 1, + }, + { actual: [4], expected: [5] }, + ], + [ + `places nulls last in a descending nullable boundary`, + { + rows: nullableBoundaryRows, + primary: { direction: `desc`, nulls: `last` }, + 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) + }) + + fcTest.prop([multiOrderScenarioArbitrary], { + numRuns: 12 * multiplier, + seed: 1663, + })( + `matches multi-column nullable ordering for a fixed seed`, + runMultiOrderScenarioWithKnownFailures, ) + fcTest.prop( + [multiOrderScenarioArbitrary], + replaySeed === undefined + ? { numRuns: 12 * multiplier } + : { numRuns: 12 * multiplier, seed: replaySeed }, + )( + `matches multi-column nullable ordering for a random or replayed seed`, + runMultiOrderScenarioWithKnownFailures, + ) + + 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 }], @@ -1293,11 +1969,132 @@ describe(`pagination recomputation oracle`, () => { ] satisfies ReadonlyArray)( `%s converges before and after a pending response`, async (_name, mutation) => { - await runPendingMutationScenario(mutation, `before-response`) - await runPendingMutationScenario(mutation, `after-response`) + const scenario: PendingMutationScenario = { + ranks: [0, 1, 2, 3], + direction: `asc`, + limit: 3, + mutation, + } + await runPendingMutationScenario(scenario, `before-response`) + await runPendingMutationScenario(scenario, `after-response`) }, ) + 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 } }, + } + await expectAssertionFailure( + () => runPendingMutationScenario(scenario, `after-response`), + { + checkpoint: 0, + classify: ({ actual, expected }) => + isPageRowArray(actual) && + sameRows(actual, [{ id: 3, rank: 0 }]) && + isPageRowArray(expected) && + sameRows(expected, [{ id: 1, rank: 0 }]), + }, + )() + }) + + it(`rejects collateral output from the settled top-k classifier`, () => { + const scenario: PendingMutationScenario = { + ranks: [0, 0, 1], + direction: `desc`, + limit: 1, + mutation: { type: `update`, row: { id: 3, rank: 0 } }, + } + + expect( + isKnownSettledTopKMembershipFailure( + scenario, + `after-response`, + assertionDifference(0, [{ id: 2, rank: 0 }], [{ id: 1, rank: 0 }]), + ), + ).toBe(false) + }) + + fcTest.prop([pendingMutationScenarioArbitrary, responseTimingArbitrary], { + numRuns: 8 * multiplier, + seed: 1660, + })( + `matches recomputation when source mutations cross a pending cursor response for a fixed seed`, + runPendingMutationScenarioWithKnownFailures, + ) + + fcTest.prop( + [pendingMutationScenarioArbitrary, responseTimingArbitrary], + replaySeed === undefined + ? { numRuns: 8 * multiplier } + : { numRuns: 8 * multiplier, seed: replaySeed }, + )( + `matches recomputation when source mutations cross a pending cursor response for a random or replayed seed`, + runPendingMutationScenarioWithKnownFailures, + ) + + 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`, + }), + ) + + fcTest.prop([pendingHistoryScenarioArbitrary], { + numRuns: 8 * multiplier, + seed: 1664, + })( + `matches recomputation across multi-action pending histories for a fixed seed`, + runPendingHistoryScenarioWithKnownFailures, + ) + + fcTest.prop( + [pendingHistoryScenarioArbitrary], + replaySeed === undefined + ? { numRuns: 8 * multiplier } + : { numRuns: 8 * multiplier, seed: replaySeed }, + )( + `matches recomputation across multi-action pending histories for a random or replayed seed`, + runPendingHistoryScenarioWithKnownFailures, + ) + + 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, { diff --git a/packages/electric-db-collection/tests/electric.test.ts b/packages/electric-db-collection/tests/electric.test.ts index 95f279a63e..83b129e02c 100644 --- a/packages/electric-db-collection/tests/electric.test.ts +++ b/packages/electric-db-collection/tests/electric.test.ts @@ -7,9 +7,7 @@ import { } from '@tanstack/db' import { persistedCollectionOptions } from '../../db-sqlite-persistence-core/src' import { electricCollectionOptions, isChangeMessage } from '../src/electric' -import { expectAssertionFailure } from '../../db/tests/expected-failure' import { stripVirtualProps } from '../../db/tests/utils' -import { TraceAssertionError } from '../../db/tests/trace-runner' import type { ElectricCollectionUtils } from '../src/electric' import type { Collection, @@ -2627,7 +2625,7 @@ describe(`Electric Integration`, () => { ) }) - it(`reloads Electric coverage after its final owner unloads`, async () => { + it(`retains Electric coverage when the adapter cannot unload it`, async () => { const testCollection = createCollection( electricCollectionOptions({ id: `on-demand-unload-coverage-test`, @@ -2647,20 +2645,7 @@ describe(`Electric Integration`, () => { testCollection._sync.unloadSubset(options) await testCollection._sync.loadSubset(options) - await expectAssertionFailure( - () => - Promise.resolve().then(() => { - try { - expect(mockRequestSnapshot).toHaveBeenCalledTimes(2) - } catch (error) { - throw new TraceAssertionError(0, error) - } - }), - { - checkpoint: 0, - classify: ({ actual, expected }) => actual === 1 && expected === 2, - }, - )() + expect(mockRequestSnapshot).toHaveBeenCalledTimes(1) } finally { await testCollection.cleanup() } diff --git a/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts b/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts index a29fb32706..af2263d17c 100644 --- a/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts +++ b/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts @@ -8,6 +8,7 @@ import type { QueryFunctionContext } from '@tanstack/query-core' type Row = { id: string + group?: string } let collectionSequence = 0 @@ -63,7 +64,7 @@ async function expectEquivalentPredicatesShareOneLoad( ): Promise { const queryClient = createQueryClient() const id = `load-subset-canonical-predicate-${collectionSequence++}` - const queryFn = vi.fn().mockResolvedValue([]) + const queryFn = vi.fn().mockResolvedValue([{ id: `a`, group: `x` }]) const collection = createCollection( queryCollectionOptions({ id, @@ -81,8 +82,8 @@ async function expectEquivalentPredicatesShareOneLoad( new IR.Value(`a`), ]) const secondComparison = new IR.Func(`eq`, [ - new IR.PropRef([`id`]), - new IR.Value(`b`), + new IR.PropRef([`group`]), + new IR.Value(`x`), ]) const first = form === `commutative-and` From 7c9734c9cd56c13d6c6c2a0fd02a6f10b0096df6 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 19 Aug 2026 13:01:18 +0100 Subject: [PATCH 04/18] test(db): finish loadSubset review coverage --- .../query/load-subset-oracle.property.test.ts | 146 ++++++++++++------ .../tests/query/load-subset-subquery.test.ts | 8 +- .../query/pagination-oracle.property.test.ts | 57 ++----- packages/db/tests/utils.test.ts | 38 +++++ packages/db/tests/utils.ts | 30 ++++ 5 files changed, 182 insertions(+), 97 deletions(-) diff --git a/packages/db/tests/query/load-subset-oracle.property.test.ts b/packages/db/tests/query/load-subset-oracle.property.test.ts index ffe64daedc..1364c587e1 100644 --- a/packages/db/tests/query/load-subset-oracle.property.test.ts +++ b/packages/db/tests/query/load-subset-oracle.property.test.ts @@ -9,6 +9,7 @@ import { Func, PropRef, Value } from '../../src/query/ir.js' import { createTransaction } from '../../src/transactions.js' import { expectAssertionFailure } from '../expected-failure.js' import { TraceAssertionError } from '../trace-runner.js' +import { oracleRandomParameters, readOracleRunConfig } from '../utils.js' import type { BasicExpression } from '../../src/query/ir.js' import type { LoadSubsetOptions } from '../../src/types.js' @@ -38,6 +39,11 @@ type ConcurrentAsyncScenario = { deliveryOrder: `forward` | `reverse` } +type RejectedWaiterScenario = { + covering: ReadonlyArray + covered: ReadonlyArray +} + type RangeOperator = Extract[`operator`] type WindowRequest = { @@ -177,7 +183,8 @@ const nonEmptyInValuesArbitrary = fc.uniqueArray( // A rejected request with an in-flight deduplicated waiter currently creates a // detached rejected promise inside DeduplicatedLoadSubset. Keep that discovered -// defect out of this green settlement corpus; it is pinned separately below. +// defect in its own generated corpus so this broader settlement property does +// not create process-level unhandled rejection noise. const asyncScenarioArbitrary: fc.Arbitrary = fc .record({ first: nonEmptyInValuesArbitrary, @@ -212,6 +219,13 @@ const concurrentAsyncScenarioArbitrary: fc.Arbitrary = deliveryOrder: fc.constantFrom(`forward`, `reverse`), }) +const rejectedWaiterScenarioArbitrary: fc.Arbitrary = + nonEmptyInValuesArbitrary.chain((covering) => + fc + .subarray(covering, { minLength: 1 }) + .map((covered) => ({ covering, covered })), + ) + const windowRequestArbitrary: fc.Arbitrary> = fc.record({ orderField: fc.constantFrom(`none`, `rank`, `score`), @@ -903,34 +917,9 @@ async function runAsyncScenarioWithKnownFailures( } } -function readPositiveInteger(name: string, fallback: number): number { - const raw = process.env[name] - if (raw === undefined) return fallback - - const value = Number(raw) - if (!Number.isSafeInteger(value) || value < 1) { - throw new Error(`${name} must be a positive integer`) - } - return value -} - -function readSeed(): number | undefined { - const raw = process.env.TANSTACK_DB_ORACLE_SEED - if (raw === undefined) return undefined - - const seed = Number(raw) - if (!Number.isSafeInteger(seed)) { - throw new Error(`TANSTACK_DB_ORACLE_SEED must be an integer`) - } - return seed -} - -const runs = 40 * readPositiveInteger(`TANSTACK_DB_ORACLE_RUNS_MULTIPLIER`, 1) -const replaySeed = readSeed() -const randomParameters = - replaySeed === undefined - ? { numRuns: runs } - : { numRuns: runs, seed: replaySeed } +const { multiplier, replaySeed } = readOracleRunConfig() +const runs = 40 * multiplier +const randomParameters = oracleRandomParameters(runs, replaySeed) let collectionSequence = 0 @@ -1049,7 +1038,9 @@ async function expectDerivedSyncDuringOptimisticMutation(): Promise { } } -async function expectDeduplicatedWaiterHandlesRejection(): Promise { +async function expectDeduplicatedWaiterHandlesRejection( + scenario: RejectedWaiterScenario, +): Promise { const detachedBranches: Array> = [] class LocallyTrackedPromise extends Promise { catch( @@ -1070,10 +1061,10 @@ async function expectDeduplicatedWaiterHandlesRejection(): Promise { }) const first = dedupe.loadSubset({ - where: toWhere({ kind: `in`, values: [1, 2] }), + where: toWhere({ kind: `in`, values: scenario.covering }), }) const second = dedupe.loadSubset({ - where: toWhere({ kind: `eq`, value: 1 }), + where: toWhere({ kind: `in`, values: scenario.covered }), }) if (!(first instanceof Promise) || !(second instanceof Promise)) { throw new Error(`Both callers must wait for the in-flight request`) @@ -1097,6 +1088,54 @@ async function expectDeduplicatedWaiterHandlesRejection(): Promise { } } +function isDetachedWaiterRejectionDifference( + actual: unknown, + expected: unknown, +): boolean { + return ( + typeof actual === `object` && + actual !== null && + `branchCount` in actual && + actual.branchCount === 1 && + `statuses` in actual && + Array.isArray(actual.statuses) && + actual.statuses.join(`,`) === `rejected` && + typeof expected === `object` && + expected !== null && + `branchCount` in expected && + expected.branchCount === 1 && + `statuses` in expected && + Array.isArray(expected.statuses) && + expected.statuses.join(`,`) === `fulfilled` + ) +} + +function isKnownDetachedWaiterRejection(error: unknown): boolean { + return ( + error instanceof TraceAssertionError && + error.checkpoint === 0 && + typeof error.cause === `object` && + error.cause !== null && + `actual` in error.cause && + `expected` in error.cause && + isDetachedWaiterRejectionDifference( + error.cause.actual, + error.cause.expected, + ) + ) +} + +async function runRejectedWaiterScenarioWithKnownFailure( + scenario: RejectedWaiterScenario, +): Promise { + try { + await expectDeduplicatedWaiterHandlesRejection(scenario) + } catch (error) { + if (isKnownDetachedWaiterRejection(error)) return + throw error + } +} + describe(`loadSubset coverage oracle`, () => { it( `discovered trace: an empty predicate issues no transport work`, @@ -1481,6 +1520,19 @@ describe(`loadSubset coverage oracle`, () => { runConcurrentAsyncScenario, ) + fcTest.prop([rejectedWaiterScenarioArbitrary], { + numRuns: runs, + seed: 1665, + })( + `checks rejected requests observed by an in-flight waiter for a fixed seed`, + runRejectedWaiterScenarioWithKnownFailure, + ) + + fcTest.prop([rejectedWaiterScenarioArbitrary], randomParameters)( + `checks rejected requests observed by an in-flight waiter for a random or replayed seed`, + runRejectedWaiterScenarioWithKnownFailure, + ) + fcTest.prop([windowTraceArbitrary], { numRuns: runs, seed: 1659 })( `never treats uncovered ordered windows as loaded for a fixed seed`, runWindowCoverageTraceWithKnownFailures, @@ -1506,24 +1558,18 @@ describe(`loadSubset coverage oracle`, () => { it( `an in-flight deduplicated waiter rejects without an unhandled branch`, - expectAssertionFailure(expectDeduplicatedWaiterHandlesRejection, { - checkpoint: 0, - classify: ({ actual, expected }) => - typeof actual === `object` && - actual !== null && - `branchCount` in actual && - actual.branchCount === 1 && - `statuses` in actual && - Array.isArray(actual.statuses) && - actual.statuses.join(`,`) === `rejected` && - typeof expected === `object` && - expected !== null && - `branchCount` in expected && - expected.branchCount === 1 && - `statuses` in expected && - Array.isArray(expected.statuses) && - expected.statuses.join(`,`) === `fulfilled`, - }), + expectAssertionFailure( + () => + expectDeduplicatedWaiterHandlesRejection({ + covering: [1, 2], + covered: [1], + }), + { + checkpoint: 0, + classify: ({ actual, expected }) => + isDetachedWaiterRejectionDifference(actual, expected), + }, + ), ) it(`applies loaded rows when no mutation is persisting`, async () => { diff --git a/packages/db/tests/query/load-subset-subquery.test.ts b/packages/db/tests/query/load-subset-subquery.test.ts index 8dcc29ce42..3f6eee13b3 100644 --- a/packages/db/tests/query/load-subset-subquery.test.ts +++ b/packages/db/tests/query/load-subset-subquery.test.ts @@ -410,8 +410,10 @@ describe(`loadSubset with subqueries`, () => { await query.preload() expect(loadSubsetCalls).not.toHaveLength(0) - const lastCall = loadSubsetCalls.at(-1) - expect(lastCall?.orderBy).toBeUndefined() - expect(lastCall?.limit).toBeUndefined() + expect( + loadSubsetCalls.every( + ({ orderBy, limit }) => orderBy === undefined && limit === undefined, + ), + ).toBe(true) }) }) diff --git a/packages/db/tests/query/pagination-oracle.property.test.ts b/packages/db/tests/query/pagination-oracle.property.test.ts index 12a514e115..0035c8033d 100644 --- a/packages/db/tests/query/pagination-oracle.property.test.ts +++ b/packages/db/tests/query/pagination-oracle.property.test.ts @@ -7,7 +7,12 @@ import { createLiveQueryCollection } from '../../src/query/live-query-collection import { PropRef } from '../../src/query/ir.js' import { expectAssertionFailure } from '../expected-failure.js' import { TraceAssertionError } from '../trace-runner.js' -import { flushPromises, mockSyncCollectionOptions } from '../utils.js' +import { + flushPromises, + mockSyncCollectionOptions, + oracleRandomParameters, + readOracleRunConfig, +} from '../utils.js' import type { BasicExpression } from '../../src/query/ir.js' import type { LoadSubsetOptions } from '../../src/types.js' @@ -278,35 +283,9 @@ const multiOrderScenarioArbitrary: fc.Arbitrary = fc limit: Math.min(requestedLimit, rows.length), })) -function readPositiveInteger(name: string, fallback: number): number { - const raw = process.env[name] - if (raw === undefined) return fallback - - const value = Number(raw) - if (!Number.isSafeInteger(value) || value < 1) { - throw new Error(`${name} must be a positive integer`) - } - return value -} - -function readSeed(): number | undefined { - const raw = process.env.TANSTACK_DB_ORACLE_SEED - if (raw === undefined) return undefined - - const seed = Number(raw) - if (!Number.isSafeInteger(seed)) { - throw new Error(`TANSTACK_DB_ORACLE_SEED must be an integer`) - } - return seed -} - -const multiplier = readPositiveInteger(`TANSTACK_DB_ORACLE_RUNS_MULTIPLIER`, 1) +const { multiplier, replaySeed } = readOracleRunConfig() const runs = 12 * multiplier -const replaySeed = readSeed() -const randomParameters = - replaySeed === undefined - ? { numRuns: runs } - : { numRuns: runs, seed: replaySeed } +const randomParameters = oracleRandomParameters(runs, replaySeed) let collectionSequence = 0 @@ -1932,9 +1911,7 @@ describe(`pagination recomputation oracle`, () => { fcTest.prop( [multiOrderScenarioArbitrary], - replaySeed === undefined - ? { numRuns: 12 * multiplier } - : { numRuns: 12 * multiplier, seed: replaySeed }, + oracleRandomParameters(12 * multiplier, replaySeed), )( `matches multi-column nullable ordering for a random or replayed seed`, runMultiOrderScenarioWithKnownFailures, @@ -2027,9 +2004,7 @@ describe(`pagination recomputation oracle`, () => { fcTest.prop( [pendingMutationScenarioArbitrary, responseTimingArbitrary], - replaySeed === undefined - ? { numRuns: 8 * multiplier } - : { numRuns: 8 * multiplier, seed: replaySeed }, + oracleRandomParameters(8 * multiplier, replaySeed), )( `matches recomputation when source mutations cross a pending cursor response for a random or replayed seed`, runPendingMutationScenarioWithKnownFailures, @@ -2057,9 +2032,7 @@ describe(`pagination recomputation oracle`, () => { fcTest.prop( [pendingHistoryScenarioArbitrary], - replaySeed === undefined - ? { numRuns: 8 * multiplier } - : { numRuns: 8 * multiplier, seed: replaySeed }, + oracleRandomParameters(8 * multiplier, replaySeed), )( `matches recomputation across multi-action pending histories for a random or replayed seed`, runPendingHistoryScenarioWithKnownFailures, @@ -2372,9 +2345,7 @@ describe(`pagination recomputation oracle`, () => { fcTest.prop( [stateScenarioArbitrary], - replaySeed === undefined - ? { numRuns: 8 * multiplier } - : { numRuns: 8 * multiplier, seed: replaySeed }, + oracleRandomParameters(8 * multiplier, replaySeed), )( `matches full recomputation across source and window transitions for a random or replayed seed`, runPaginationStateScenarioWithKnownFailures, @@ -2584,9 +2555,7 @@ describe(`pagination recomputation oracle`, () => { fcTest.prop( [scenarioArbitrary], - replaySeed === undefined - ? { numRuns: 8 * multiplier } - : { numRuns: 8 * multiplier, seed: replaySeed }, + oracleRandomParameters(8 * multiplier, replaySeed), )( `matches full recomputation when exact async cursor loads widen ordered coverage for a random or replayed seed`, runOnDemandPaginationScenarioWithKnownFailures, diff --git a/packages/db/tests/utils.test.ts b/packages/db/tests/utils.test.ts index d6bb5e368b..2a65471c8b 100644 --- a/packages/db/tests/utils.test.ts +++ b/packages/db/tests/utils.test.ts @@ -2,6 +2,44 @@ import { describe, expect, it } from 'vitest' import { Temporal } from 'temporal-polyfill' import { deepEquals } from '../src/utils' import { isPromiseLike } from '../src/utils/type-guards' +import { oracleRandomParameters, readOracleRunConfig } from './utils' + +describe(`oracle run configuration`, () => { + it(`reads the multiplier and replay seed from an explicit environment`, () => { + expect( + readOracleRunConfig({ + TANSTACK_DB_ORACLE_RUNS_MULTIPLIER: `100`, + TANSTACK_DB_ORACLE_SEED: `-42`, + }), + ).toEqual({ multiplier: 100, replaySeed: -42 }) + }) + + it(`uses one run multiplier and no replay seed by default`, () => { + expect(readOracleRunConfig({})).toEqual({ + multiplier: 1, + replaySeed: undefined, + }) + }) + + it.each([ + [{ TANSTACK_DB_ORACLE_RUNS_MULTIPLIER: `0` }, `positive integer`], + [{ TANSTACK_DB_ORACLE_RUNS_MULTIPLIER: `1.5` }, `positive integer`], + [{ TANSTACK_DB_ORACLE_SEED: `1.5` }, `must be an integer`], + ] satisfies ReadonlyArray, string]>)( + `rejects invalid environment values`, + (environment, message) => { + expect(() => readOracleRunConfig(environment)).toThrow(message) + }, + ) + + it(`adds a seed only for replay runs`, () => { + expect(oracleRandomParameters(40, undefined)).toEqual({ numRuns: 40 }) + expect(oracleRandomParameters(40, -42)).toEqual({ + numRuns: 40, + seed: -42, + }) + }) +}) describe(`deepEquals`, () => { describe(`primitives`, () => { diff --git a/packages/db/tests/utils.ts b/packages/db/tests/utils.ts index d025634a51..b31408d0b4 100644 --- a/packages/db/tests/utils.ts +++ b/packages/db/tests/utils.ts @@ -10,6 +10,36 @@ import type { import type { IndexConstructor } from '../src/indexes/base-index' import type { WithVirtualProps } from '../src/virtual-props.js' +type OracleEnvironment = Record + +export function readOracleRunConfig( + environment: OracleEnvironment = process.env, +): { multiplier: number; replaySeed: number | undefined } { + const multiplierValue = environment.TANSTACK_DB_ORACLE_RUNS_MULTIPLIER ?? `1` + const multiplier = Number(multiplierValue) + if (!Number.isSafeInteger(multiplier) || multiplier < 1) { + throw new Error( + `TANSTACK_DB_ORACLE_RUNS_MULTIPLIER must be a positive integer`, + ) + } + + const seedValue = environment.TANSTACK_DB_ORACLE_SEED + if (seedValue === undefined) return { multiplier, replaySeed: undefined } + + const replaySeed = Number(seedValue) + if (!Number.isSafeInteger(replaySeed)) { + throw new Error(`TANSTACK_DB_ORACLE_SEED must be an integer`) + } + return { multiplier, replaySeed } +} + +export function oracleRandomParameters( + numRuns: number, + replaySeed: number | undefined, +): { numRuns: number; seed?: number } { + return replaySeed === undefined ? { numRuns } : { numRuns, seed: replaySeed } +} + export type OutputWithVirtual< T extends object, TKey extends string | number = string | number, From a1696dd22e0cc141a32579d68a81efa40cc9efe7 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 19 Aug 2026 16:27:57 +0100 Subject: [PATCH 05/18] test(db): tighten loadSubset coverage oracle --- .../query/load-subset-oracle.property.test.ts | 140 +++++++++++++++++- 1 file changed, 134 insertions(+), 6 deletions(-) diff --git a/packages/db/tests/query/load-subset-oracle.property.test.ts b/packages/db/tests/query/load-subset-oracle.property.test.ts index 1364c587e1..ef123fcfe2 100644 --- a/packages/db/tests/query/load-subset-oracle.property.test.ts +++ b/packages/db/tests/query/load-subset-oracle.property.test.ts @@ -532,11 +532,12 @@ function loadedWindowCovers( ): boolean { const requestedOptions = toWindowOptions(requested) const loadedOptions = toWindowOptions(loaded) - // An unlimited load has every row in its predicate region. It can therefore - // cover any narrower predicate and let local query processing impose the - // requested order and window. + // An unlimited load that starts at zero has every row in its predicate + // region. It can therefore cover any narrower predicate and let local query + // processing impose the requested order and window. if ( loaded.limit === undefined && + loaded.offset === 0 && isSubset( matchingValues(requestedOptions.where), matchingValues(loadedOptions.where), @@ -603,6 +604,28 @@ function isKnownUnlimitedOffsetDeduplication( }) } +function isKnownOffsetTruncatedUnlimitedDeduplication( + error: UncoveredWindowDeduplicatedError, +): boolean { + const requestedOptions = toWindowOptions(error.requested) + + return error.loadedRegions.some(({ request: loaded }) => { + if (loaded.limit !== undefined || loaded.offset === 0) return false + const loadedOptions = toWindowOptions(loaded) + const priorLoads = error.loadedRegions.map(({ request }) => request) + return ( + JSON.stringify(requestedOptions.orderBy) !== + JSON.stringify(loadedOptions.orderBy) && + isSubset( + matchingValues(requestedOptions.where), + matchingValues(loadedOptions.where), + ) && + countWindowLoads(priorLoads) === priorLoads.length && + countWindowLoads([...priorLoads, error.requested]) === priorLoads.length + ) + }) +} + function isKnownCoveredWindowRefetch( error: CoveredWindowRefetchedError, ): boolean { @@ -614,7 +637,12 @@ function isKnownCoveredWindowRefetch( ) { return true } - if (error.loadedRegions.length > 1) return true + if (error.loadedRegions.length > 1) { + const coveredByOneRegion = error.loadedRegions.some(({ positions }) => + isSubset(error.requestedPositions, positions), + ) + return !coveredByOneRegion + } if (error.requested.where === undefined) return false return error.loadedRegions.some( @@ -624,6 +652,24 @@ function isKnownCoveredWindowRefetch( ) } +function isKnownIndividuallyCoveredWindowRefetch( + error: CoveredWindowRefetchedError, +): boolean { + if (error.loadedRegions.length <= 1) return false + const coveredByOneRegion = error.loadedRegions.some( + ({ request: loaded, positions }) => + loadedWindowCovers(error.requested, loaded) && + isSubset(error.requestedPositions, positions), + ) + if (!coveredByOneRegion) return false + + const replay = [ + ...error.loadedRegions.map(({ request }) => request), + error.requested, + ] + return countWindowLoads(replay) === replay.length +} + const createWindowKeyBlindSubject: CoverageSubjectFactory = (recordLoad) => { const coveredWindows = new Set() return { @@ -706,16 +752,19 @@ function runWindowCoverageTraceWithKnownFailures( try { runWindowCoverageTrace(trace, createSubject) } catch (error) { + if (createSubject !== createDeduplicatedCoverageSubject) throw error if ( error instanceof UncoveredWindowDeduplicatedError && (isKnownCompareOptionsDeduplication(error) || - isKnownUnlimitedOffsetDeduplication(error)) + isKnownUnlimitedOffsetDeduplication(error) || + isKnownOffsetTruncatedUnlimitedDeduplication(error)) ) { return } if ( error instanceof CoveredWindowRefetchedError && - isKnownCoveredWindowRefetch(error) + (isKnownCoveredWindowRefetch(error) || + isKnownIndividuallyCoveredWindowRefetch(error)) ) { return } @@ -1197,6 +1246,32 @@ describe(`loadSubset coverage oracle`, () => { ), ) + it( + `discovered trace: an offset-truncated unlimited load does not cover another ordering`, + expectAssertionFailure( + () => + Promise.resolve().then(() => { + expect( + countWindowLoads([ + { + orderField: `rank`, + direction: `asc`, + offset: 1, + limit: undefined, + }, + { + orderField: `score`, + direction: `asc`, + offset: 1, + limit: 1, + }, + ]), + ).toBe(2) + }), + { message: /expected 1 to be 2/ }, + ), + ) + it( `discovered trace: an identical filtered window reuses its load`, expectAssertionFailure( @@ -1342,6 +1417,39 @@ describe(`loadSubset coverage oracle`, () => { ]) }) + it(`does not treat an offset-truncated unlimited load as complete under another ordering`, () => { + expect( + loadedWindowCovers( + { + orderField: `score`, + direction: `asc`, + offset: 1, + limit: 1, + }, + { + orderField: `rank`, + direction: `asc`, + offset: 1, + limit: undefined, + }, + ), + ).toBe(false) + }) + + it(`rejects redundant work for a window covered by one loaded region`, () => { + const first: WindowRequest = { + direction: `asc`, + offset: 0, + limit: 2, + } + expect(() => + runWindowCoverageTraceWithKnownFailures( + [first, { direction: `asc`, offset: 2, limit: 2 }, first], + createAlwaysLoadingCoverageSubject, + ), + ).toThrow() + }) + it.each([ [ `where`, @@ -1615,6 +1723,26 @@ describe(`loadSubset coverage oracle`, () => { ), ) + it( + `discovered trace: widening a window forgets an earlier covered window`, + expectAssertionFailure( + () => + Promise.resolve().then(() => { + const first: WindowRequest = { + orderField: `none`, + direction: `asc`, + offset: 0, + limit: 1, + where: { kind: `in`, values: [0] }, + } + expect(countWindowLoads([first, { ...first, limit: 2 }, first])).toBe( + 2, + ) + }), + { message: /expected 3 to be 2/ }, + ), + ) + it( `discovered trace: complementary ranges redundantly reload an all-data request`, expectAssertionFailure( From 360d821a27905bd2106c12b44986666c7d55dbb1 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 19 Aug 2026 17:38:37 +0100 Subject: [PATCH 06/18] fix(db): propagate initial query errors --- docs/guides/collection-options-creator.md | 19 ++--- docs/guides/error-handling.md | 8 +- .../db/skills/db-core/custom-adapter/SKILL.md | 48 ++++++----- packages/db/src/collection/lifecycle.ts | 11 ++- packages/db/src/collection/sync.ts | 35 ++++++-- packages/db/src/query/subset-dedupe.ts | 7 +- packages/db/src/types.ts | 6 +- packages/db/tests/collection-errors.test.ts | 3 + .../query/load-subset-oracle.property.test.ts | 77 ++---------------- packages/query-db-collection/src/query.ts | 11 ++- .../load-subset-lifecycle-oracle.test.ts | 80 +++++++++++++++++-- .../query-db-collection/tests/query.test.ts | 10 +-- 12 files changed, 190 insertions(+), 125 deletions(-) diff --git a/docs/guides/collection-options-creator.md b/docs/guides/collection-options-creator.md index 000585ccab..de8cd2c120 100644 --- a/docs/guides/collection-options-creator.md +++ b/docs/guides/collection-options-creator.md @@ -73,7 +73,7 @@ The sync function must return a cleanup function for proper garbage collection: ```typescript const sync: SyncConfig['sync'] = (params) => { - const { begin, write, commit, markReady, collection } = params + const { begin, write, commit, markReady, markError, collection } = params // 1. Initialize connection to your sync engine const connection = initializeConnection(config) @@ -134,13 +134,13 @@ const sync: SyncConfig['sync'] = (params) => { commit() eventBuffer.splice(0) } - + + // A complete initial snapshot is now available. + markReady() } catch (error) { console.error('Initial sync failed:', error) - throw error - } finally { - // ALWAYS call markReady, even on error - markReady() + // No usable initial snapshot exists. + markError() } } @@ -163,7 +163,8 @@ The sync process follows this lifecycle: 1. **begin()** - Start collecting changes 2. **write()** - Add changes to the pending transaction (buffered until commit) 3. **commit()** - Apply all changes atomically to the collection state -4. **markReady()** - Signal that initial sync is complete +4. **markReady()** - Signal that a usable initial or recovered snapshot exists +5. **markError()** - Signal that initial sync failed before producing a usable snapshot **Race Condition Prevention:** Many sync engines start real-time subscriptions before the initial sync completes. Your implementation MUST deduplicate events that arrive via subscription that represent the same data as the initial sync. Consider: @@ -900,8 +901,8 @@ const wrappedOnInsert = async (params) => { ## Best Practices -1. **Always call markReady()** - This signals that the collection has initial data and is ready for use -2. **Handle errors gracefully** - Call markReady() even on error to avoid blocking the app +1. **Report initial sync status** - Call `markReady()` after a usable snapshot, or `markError()` if initial sync fails +2. **Recover explicitly** - After an error, call `markReady()` only when a later sync has produced a usable snapshot 3. **Clean up resources** - Return a cleanup function from sync to prevent memory leaks 4. **Batch operations** - Use begin/commit to batch multiple changes for better performance 5. **Race Conditions** - Start listeners before initial fetch and buffer events diff --git a/docs/guides/error-handling.md b/docs/guides/error-handling.md index 40bb758beb..ac8e3a7d9c 100644 --- a/docs/guides/error-handling.md +++ b/docs/guides/error-handling.md @@ -420,7 +420,7 @@ try { ### Query Collection Sync Errors -Query collections handle sync errors gracefully and mark the collection as ready even on error to avoid blocking applications: +Query collections distinguish an initial load failure from a later refetch failure: ```ts import { queryCollectionOptions } from "@tanstack/query-db-collection" @@ -447,9 +447,11 @@ const todoCollection = createCollection( When sync errors occur: - Error is logged to console: `[QueryCollection] Error observing query...` -- Collection is marked as ready to prevent blocking the application -- Cached data remains available +- An initial failure marks the collection as `error` because no usable snapshot exists +- Readiness waits such as `preload()` and `toArrayWhenReady()` reject while the collection is in that initial error state +- A later refetch failure keeps the collection `ready` and preserves its cached data - Error tracking counters are updated (`lastError`, `errorCount`) +- A later successful refetch recovers an initial `error` collection to `ready`; a new readiness wait then resolves normally ### Sync Write Errors diff --git a/packages/db/skills/db-core/custom-adapter/SKILL.md b/packages/db/skills/db-core/custom-adapter/SKILL.md index 1d6bb38d38..a7af91e17d 100644 --- a/packages/db/skills/db-core/custom-adapter/SKILL.md +++ b/packages/db/skills/db-core/custom-adapter/SKILL.md @@ -2,7 +2,7 @@ name: db-core/custom-adapter description: > Building custom collection adapters for new backends. SyncConfig interface: - sync function receiving begin, write, commit, markReady, truncate, metadata + sync function receiving begin, write, commit, markReady, markError, truncate, metadata primitives and returning cleanup, loadSubset, and optional unloadSubset handlers. ChangeMessage format (insert, update, delete). On-demand LoadSubsetOptions @@ -48,7 +48,7 @@ function myBackendCollectionOptions(config: { return { getKey: config.getKey, sync: { - sync: ({ begin, write, commit, markReady }) => { + sync: ({ begin, write, commit, markReady, markError }) => { let isInitialSyncComplete = false const bufferedEvents: Array> = [] @@ -64,25 +64,30 @@ function myBackendCollectionOptions(config: { }) // 2. Fetch initial data - fetch(config.endpoint).then(async (res) => { - const items = await res.json() - begin() - for (const item of items) { - write({ type: 'insert', value: item }) - } - commit() - - // 3. Process buffered events - isInitialSyncComplete = true - for (const event of bufferedEvents) { + void fetch(config.endpoint) + .then(async (res) => { + const items = await res.json() begin() - write({ type: event.type, key: event.id, value: event.data }) + for (const item of items) { + write({ type: 'insert', value: item }) + } commit() - } - // 4. Signal readiness - markReady() - }) + // 3. Process buffered events + isInitialSyncComplete = true + for (const event of bufferedEvents) { + begin() + write({ type: event.type, key: event.id, value: event.data }) + commit() + } + + // 4. Signal that a usable snapshot exists + markReady() + }) + .catch((error) => { + console.error('Initial sync failed:', error) + markError() + }) // 5. Return cleanup function return () => { @@ -210,7 +215,7 @@ Without persistence the metadata is in-memory only and does not survive reloads. With persistence, it is durable across sessions. ```ts -sync: ({ begin, write, commit, markReady, metadata }) => { +sync: ({ begin, write, commit, markReady, markError, metadata }) => { if (!metadata) throw new Error('Sync metadata API is unavailable') // Row metadata: store per-row state (e.g. server version, ETag) @@ -254,6 +259,7 @@ sync: ({ begin, write, commit, markReady, metadata }) => { }) stream.on('ready', () => markReady()) + stream.on('initial-error', () => markError()) return () => stream.close() } ``` @@ -322,6 +328,10 @@ sync: ({ begin, write, commit, markReady }) => { `markReady()` transitions the collection to "ready" status. Without it, live queries never resolve and `useLiveSuspenseQuery` hangs forever in Suspense. +If initial sync fails before it produces a usable snapshot, call `markError()` +instead. This rejects readiness waits and moves dependent live queries to the +error state. A later successful sync can call `markReady()` to recover. + Source: docs/guides/collection-options-creator.md ### HIGH Race condition: subscribing after initial fetch diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index 8f8cced21f..b0e407690c 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -77,7 +77,7 @@ export class CollectionLifecycleManager< idle: [`loading`, `error`, `cleaned-up`], loading: [`ready`, `error`, `cleaned-up`], ready: [`cleaned-up`, `error`], - error: [`cleaned-up`, `idle`], + error: [`ready`, `cleaned-up`, `idle`], 'cleaned-up': [`loading`, `error`], } @@ -133,8 +133,8 @@ export class CollectionLifecycleManager< */ public markReady(): void { this.validateStatusTransition(this.status, `ready`) - // Can transition to ready from loading state - if (this.status === `loading`) { + // A successful initial sync or recovery establishes a ready snapshot. + if (this.status === `loading` || this.status === `error`) { this.setStatus(`ready`, true) // Call any registered first ready callbacks (only on first time becoming ready) @@ -158,6 +158,11 @@ export class CollectionLifecycleManager< } } + /** Mark an asynchronous sync failure after sync has started. */ + public markError(): void { + this.setStatus(`error`) + } + /** * Start the garbage collection timer * Called when the collection becomes inactive (no subscribers) diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts index c47de23a92..a354bcc73c 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -233,6 +233,9 @@ export class CollectionSyncManager< markReady: () => { this.lifecycle.markReady() }, + markError: () => { + this.lifecycle.markError() + }, truncate: () => { const pendingTransaction = this.state.pendingSyncedTransactions[ @@ -472,7 +475,7 @@ export class CollectionSyncManager< ) } - this.preloadPromise = new Promise((resolve, reject) => { + const attempt = new Promise((resolve, reject) => { if (this.lifecycle.status === `ready`) { resolve() return @@ -483,9 +486,25 @@ export class CollectionSyncManager< return } - // Register callback BEFORE starting sync to avoid race condition - this.lifecycle.onFirstReady(() => { + let settled = false + let unsubscribeError = () => {} + const resolveReady = () => { + if (settled) return + settled = true + unsubscribeError() resolve() + } + const rejectError = (error: unknown) => { + if (settled) return + settled = true + unsubscribeError() + reject(error) + } + + // Register callback BEFORE starting sync to avoid race condition + this.lifecycle.onFirstReady(resolveReady) + unsubscribeError = this.collection.on(`status:error`, () => { + rejectError(new CollectionIsInErrorStateError()) }) // Start sync if collection hasn't started yet or was cleaned up @@ -496,13 +515,19 @@ export class CollectionSyncManager< try { this.startSync() } catch (error) { - reject(error) + rejectError(error) return } } }) - return this.preloadPromise + this.preloadPromise = attempt + void attempt.then(undefined, () => { + if (this.preloadPromise === attempt) { + this.preloadPromise = null + } + }) + return attempt } /** diff --git a/packages/db/src/query/subset-dedupe.ts b/packages/db/src/query/subset-dedupe.ts index 3dc5cc2bc8..8b248e9250 100644 --- a/packages/db/src/query/subset-dedupe.ts +++ b/packages/db/src/query/subset-dedupe.ts @@ -122,7 +122,12 @@ export class DeduplicatedLoadSubset { // The in-flight promise already handles tracking updates when it completes const prom = matchingInflight.promise // Call `onDeduplicate` when the inflight request has loaded the data - prom.then(() => this.onDeduplicate?.(options)).catch() // ignore errors + void prom + .then(() => this.onDeduplicate?.(options)) + .catch(() => { + // The original caller owns the transport failure. This observer only + // waits to publish successful deduplication. + }) return prom } diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index bae05a943f..d9eb67750d 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -338,7 +338,10 @@ export interface SyncConfig< begin: (options?: { immediate?: boolean }) => void write: (message: ChangeMessageOrDeleteKeyMessage) => void commit: () => void + /** Signal that a usable initial or recovered snapshot is available. */ markReady: () => void + /** Signal that initial sync failed before producing a usable snapshot. */ + markError: () => void truncate: () => void metadata?: SyncMetadataApi }) => void | CleanupFn | SyncConfigRes @@ -518,7 +521,8 @@ export type DeleteMutationFn< * @example * // Status transitions * // idle → loading → ready (when markReady() is called) - * // Any status can transition to → error or cleaned-up + * // Any active status can transition to → error or cleaned-up + * // error → ready after a successful sync recovery */ export type CollectionStatus = /** Collection is created but sync hasn't started yet (when startSync config is false) */ diff --git a/packages/db/tests/collection-errors.test.ts b/packages/db/tests/collection-errors.test.ts index f4bdae52d8..db95427750 100644 --- a/packages/db/tests/collection-errors.test.ts +++ b/packages/db/tests/collection-errors.test.ts @@ -446,6 +446,9 @@ describe(`Collection Error Handling`, () => { expect(() => collectionImpl._lifecycle.validateStatusTransition(`error`, `idle`), ).not.toThrow() + expect(() => + collectionImpl._lifecycle.validateStatusTransition(`error`, `ready`), + ).not.toThrow() // Valid transitions from cleaned-up (allow restart) expect(() => diff --git a/packages/db/tests/query/load-subset-oracle.property.test.ts b/packages/db/tests/query/load-subset-oracle.property.test.ts index ef123fcfe2..a31e9bc131 100644 --- a/packages/db/tests/query/load-subset-oracle.property.test.ts +++ b/packages/db/tests/query/load-subset-oracle.property.test.ts @@ -181,10 +181,6 @@ const nonEmptyInValuesArbitrary = fc.uniqueArray( { minLength: 1, maxLength: 7 }, ) -// A rejected request with an in-flight deduplicated waiter currently creates a -// detached rejected promise inside DeduplicatedLoadSubset. Keep that discovered -// defect in its own generated corpus so this broader settlement property does -// not create process-level unhandled rejection noise. const asyncScenarioArbitrary: fc.Arbitrary = fc .record({ first: nonEmptyInValuesArbitrary, @@ -1137,54 +1133,6 @@ async function expectDeduplicatedWaiterHandlesRejection( } } -function isDetachedWaiterRejectionDifference( - actual: unknown, - expected: unknown, -): boolean { - return ( - typeof actual === `object` && - actual !== null && - `branchCount` in actual && - actual.branchCount === 1 && - `statuses` in actual && - Array.isArray(actual.statuses) && - actual.statuses.join(`,`) === `rejected` && - typeof expected === `object` && - expected !== null && - `branchCount` in expected && - expected.branchCount === 1 && - `statuses` in expected && - Array.isArray(expected.statuses) && - expected.statuses.join(`,`) === `fulfilled` - ) -} - -function isKnownDetachedWaiterRejection(error: unknown): boolean { - return ( - error instanceof TraceAssertionError && - error.checkpoint === 0 && - typeof error.cause === `object` && - error.cause !== null && - `actual` in error.cause && - `expected` in error.cause && - isDetachedWaiterRejectionDifference( - error.cause.actual, - error.cause.expected, - ) - ) -} - -async function runRejectedWaiterScenarioWithKnownFailure( - scenario: RejectedWaiterScenario, -): Promise { - try { - await expectDeduplicatedWaiterHandlesRejection(scenario) - } catch (error) { - if (isKnownDetachedWaiterRejection(error)) return - throw error - } -} - describe(`loadSubset coverage oracle`, () => { it( `discovered trace: an empty predicate issues no transport work`, @@ -1633,12 +1581,12 @@ describe(`loadSubset coverage oracle`, () => { seed: 1665, })( `checks rejected requests observed by an in-flight waiter for a fixed seed`, - runRejectedWaiterScenarioWithKnownFailure, + expectDeduplicatedWaiterHandlesRejection, ) fcTest.prop([rejectedWaiterScenarioArbitrary], randomParameters)( `checks rejected requests observed by an in-flight waiter for a random or replayed seed`, - runRejectedWaiterScenarioWithKnownFailure, + expectDeduplicatedWaiterHandlesRejection, ) fcTest.prop([windowTraceArbitrary], { numRuns: runs, seed: 1659 })( @@ -1664,21 +1612,12 @@ describe(`loadSubset coverage oracle`, () => { expectDistinctWhereStartsDistinctLimitedWindowLoads, ) - it( - `an in-flight deduplicated waiter rejects without an unhandled branch`, - expectAssertionFailure( - () => - expectDeduplicatedWaiterHandlesRejection({ - covering: [1, 2], - covered: [1], - }), - { - checkpoint: 0, - classify: ({ actual, expected }) => - isDetachedWaiterRejectionDifference(actual, expected), - }, - ), - ) + it(`an in-flight deduplicated waiter rejects without an unhandled branch`, async () => { + await expectDeduplicatedWaiterHandlesRejection({ + covering: [1, 2], + covered: [1], + }) + }) it(`applies loaded rows when no mutation is persisting`, async () => { await expectPersistingLoadIsApplied(false) diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 7632e537e4..c286dfb95b 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -877,7 +877,8 @@ export function queryCollectionOptions( } const internalSync: SyncConfig[`sync`] = (params) => { - const { begin, write, commit, markReady, collection, metadata } = params + const { begin, write, commit, markReady, markError, collection, metadata } = + params const persistedMetadata = metadata as | QuerySyncMetadataWithPersistedScan | undefined @@ -1588,8 +1589,12 @@ export function queryCollectionOptions( result.error, ) - // Mark collection as ready even on error to avoid blocking apps - markReady() + // A failure before the first successful snapshot leaves no usable + // collection state. Later refetch failures keep the last ready + // snapshot available while utils expose the error. + if (collection.status === `loading`) { + markError() + } } } return handleQueryResult diff --git a/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts b/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts index af2263d17c..e0f69436f7 100644 --- a/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts +++ b/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts @@ -29,17 +29,28 @@ async function expectInitialQueryFailureStatus(): Promise { const queryClient = createQueryClient() const id = `load-subset-error-status-${collectionSequence++}` const loggedError = vi.spyOn(console, `error`).mockImplementation(() => {}) + const queryFn = vi + .fn() + .mockRejectedValueOnce(error) + .mockResolvedValueOnce([{ id: `recovered` }]) const collection = createCollection( queryCollectionOptions({ id, queryClient, queryKey: [id], - queryFn: () => Promise.reject(error), + queryFn, getKey: (row) => row.id, startSync: true, retry: false, }), ) + const live = createLiveQueryCollection((query) => + query.from({ row: collection }).select(({ row }) => ({ id: row.id })), + ) + const preloadOutcomes = Promise.allSettled([ + collection.preload(), + live.preload(), + ]) try { await vi.waitFor(() => { @@ -49,10 +60,65 @@ async function expectInitialQueryFailureStatus(): Promise { expect(loggedError).toHaveBeenCalled() try { expect(collection.status).toBe(`error`) + expect(live.status).toBe(`error`) + expect((await preloadOutcomes).map(({ status }) => status)).toEqual([ + `rejected`, + `rejected`, + ]) } catch (caught) { throw new TraceAssertionError(0, caught) } + + await collection.utils.clearError() + await vi.waitFor(() => { + expect(collection.status).toBe(`ready`) + expect(collection.get(`recovered`)).toBeDefined() + }) + await expect(collection.preload()).resolves.toBeUndefined() } finally { + await live.cleanup() + await collection.cleanup() + queryClient.clear() + loggedError.mockRestore() + } +} + +async function expectRefetchFailureKeepsReadySnapshot(): Promise { + const error = new Error(`refetch failed`) + const queryClient = createQueryClient() + const id = `load-subset-refetch-status-${collectionSequence++}` + const loggedError = vi.spyOn(console, `error`).mockImplementation(() => {}) + const queryFn = vi + .fn() + .mockResolvedValueOnce([{ id: `cached` }]) + .mockRejectedValueOnce(error) + const collection = createCollection( + queryCollectionOptions({ + id, + queryClient, + queryKey: [id], + queryFn, + getKey: (row) => row.id, + startSync: true, + retry: false, + }), + ) + const live = createLiveQueryCollection((query) => + query.from({ row: collection }).select(({ row }) => ({ id: row.id })), + ) + + try { + await live.preload() + await collection.utils.refetch() + await vi.waitFor(() => { + expect(collection.utils.lastError).toBe(error) + }) + expect(collection.status).toBe(`ready`) + expect(live.status).toBe(`ready`) + expect(collection.get(`cached`)).toBeDefined() + expect(live.get(`cached`)).toBeDefined() + } finally { + await live.cleanup() await collection.cleanup() queryClient.clear() loggedError.mockRestore() @@ -218,12 +284,12 @@ async function expectRemountAfterAbortStartsFreshQuery(): Promise { } describe(`loadSubset lifecycle oracle`, () => { - it(`reports an initial query failure through collection status`, async () => { - await expectAssertionFailure(expectInitialQueryFailureStatus, { - checkpoint: 0, - classify: ({ actual, expected }) => - actual === `ready` && expected === `error`, - })() + it(`reports an initial query failure and recovers after a successful refetch`, async () => { + await expectInitialQueryFailureStatus() + }) + + it(`keeps the last ready snapshot after a refetch failure`, async () => { + await expectRefetchFailureKeepsReadySnapshot() }) it(`commutative predicate forms share one query-db transport load`, async () => { diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 8e23f31131..92243a0c1e 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -4631,9 +4631,9 @@ describe(`QueryCollection`, () => { const options = queryCollectionOptions(config) const collection = createCollection(options) - // Wait for collection to be ready (even with error) + // No initial snapshot exists, so the collection reports an error. await vi.waitFor(() => { - expect(collection.status).toBe(`ready`) + expect(collection.status).toBe(`error`) expect(collection.utils.isError).toBe(true) }) @@ -4657,9 +4657,9 @@ describe(`QueryCollection`, () => { queryFn, ) - // Wait for collection to be ready (even with error) + // No initial snapshot exists, so the collection reports an error. await vi.waitFor(() => { - expect(collection.status).toBe(`ready`) + expect(collection.status).toBe(`error`) expect(collection.utils.isError).toBe(true) }) @@ -4703,7 +4703,7 @@ describe(`QueryCollection`, () => { // Wait for all retry attempts to complete and final failure await vi.waitFor( () => { - expect(collection.status).toBe(`ready`) // Should be ready even with error + expect(collection.status).toBe(`error`) expect(queryFn).toHaveBeenCalledTimes(totalAttempts) expect(collection.utils.isError).toBe(true) }, From 58ceb98ce1970a0a3e5366000186f50cacc2420d Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 19 Aug 2026 17:40:16 +0100 Subject: [PATCH 07/18] chore: add error propagation changeset --- .changeset/propagate-initial-query-errors.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/propagate-initial-query-errors.md diff --git a/.changeset/propagate-initial-query-errors.md b/.changeset/propagate-initial-query-errors.md new file mode 100644 index 0000000000..4bb081376d --- /dev/null +++ b/.changeset/propagate-initial-query-errors.md @@ -0,0 +1,6 @@ +--- +'@tanstack/db': patch +'@tanstack/query-db-collection': patch +--- + +Propagate initial query sync failures to collection status and readiness promises while preserving a ready cached snapshot on later refetch failures. Prevent rejected deduplicated subset requests from creating detached promise rejections. From 3ab8fb7c4035ab78f6e4de379fb8ff8fd7eee36a Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 20 Aug 2026 19:22:39 +0100 Subject: [PATCH 08/18] test(db): exclude empty distinct windows --- .../query/load-subset-oracle.property.test.ts | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/packages/db/tests/query/load-subset-oracle.property.test.ts b/packages/db/tests/query/load-subset-oracle.property.test.ts index ef123fcfe2..146f680544 100644 --- a/packages/db/tests/query/load-subset-oracle.property.test.ts +++ b/packages/db/tests/query/load-subset-oracle.property.test.ts @@ -250,17 +250,21 @@ const windowTraceArbitrary = fc const distinctWindowWherePairArbitrary = fc .tuple(predicateSpecArbitrary, predicateSpecArbitrary) - .filter( - ([first, second]) => - !isSubset( - matchingValues(toWhere(first)), - matchingValues(toWhere(second)), - ) || - !isSubset( - matchingValues(toWhere(second)), - matchingValues(toWhere(first)), - ), + .filter(isDistinctNonEmptyWindowWherePair) + +function isDistinctNonEmptyWindowWherePair([first, second]: readonly [ + PredicateSpec, + PredicateSpec, +]): boolean { + const firstValues = matchingValues(toWhere(first)) + const secondValues = matchingValues(toWhere(second)) + return ( + firstValues.size > 0 && + secondValues.size > 0 && + (!isSubset(firstValues, secondValues) || + !isSubset(secondValues, firstValues)) ) +} function toWhere( predicate: PredicateSpec, @@ -1186,6 +1190,15 @@ async function runRejectedWaiterScenarioWithKnownFailure( } describe(`loadSubset coverage oracle`, () => { + it(`keeps empty predicates out of the distinct-window corpus`, () => { + expect( + isDistinctNonEmptyWindowWherePair([ + { kind: `in`, values: [] }, + { kind: `eq`, value: 0 }, + ]), + ).toBe(false) + }) + it( `discovered trace: an empty predicate issues no transport work`, expectAssertionFailure( From 7798883657e396c48e3b4ff7aa5e8c38374b54cf Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 20 Aug 2026 19:42:48 +0100 Subject: [PATCH 09/18] test(db): address oracle review findings --- .../query/load-subset-join-dedupe.test.ts | 6 +- .../query/load-subset-oracle.property.test.ts | 132 +++++++++++++----- .../query/pagination-oracle.property.test.ts | 32 +++-- .../load-subset-lifecycle-oracle.test.ts | 1 - 4 files changed, 126 insertions(+), 45 deletions(-) diff --git a/packages/db/tests/query/load-subset-join-dedupe.test.ts b/packages/db/tests/query/load-subset-join-dedupe.test.ts index 97e3b96011..898cdde334 100644 --- a/packages/db/tests/query/load-subset-join-dedupe.test.ts +++ b/packages/db/tests/query/load-subset-join-dedupe.test.ts @@ -26,7 +26,7 @@ const children = [ ] let sequence = 0 -const cleanups: Array<() => void> = [] +const cleanups: Array<() => void | Promise> = [] function createParents() { let begin!: () => void @@ -101,8 +101,8 @@ function createJoinedQuery( } describe(`loadSubset join-key deduplication`, () => { - afterEach(() => { - for (const cleanup of cleanups.splice(0).reverse()) cleanup() + afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup() }) it( diff --git a/packages/db/tests/query/load-subset-oracle.property.test.ts b/packages/db/tests/query/load-subset-oracle.property.test.ts index 146f680544..f1eac3552e 100644 --- a/packages/db/tests/query/load-subset-oracle.property.test.ts +++ b/packages/db/tests/query/load-subset-oracle.property.test.ts @@ -357,6 +357,10 @@ function isSubset(left: ReadonlySet, right: ReadonlySet) { return [...left].every((value) => right.has(value)) } +function unionSets(sets: ReadonlyArray>): Set { + return new Set(sets.flatMap((set) => [...set])) +} + function expectSetEqual( actual: ReadonlySet, expected: ReadonlySet, @@ -471,7 +475,9 @@ function isKnownUnionCompositionRefetch( // current implementation can refetch any later covered demand, including a // strict subset of one original region. Fixed traces below make this waiver // expire when that product defect is repaired. - if (error.loadedRegions.length > 1) return true + if (error.loadedRegions.length > 1) { + return isSubset(error.requested, unionSets(error.loadedRegions)) + } const usesCompoundPredicate = [ error.requestedFingerprint, @@ -642,6 +648,14 @@ function isKnownCoveredWindowRefetch( return true } if (error.loadedRegions.length > 1) { + if ( + !isSubset( + error.requestedPositions, + unionSets(error.loadedRegions.map(({ positions }) => positions)), + ) + ) { + return false + } const coveredByOneRegion = error.loadedRegions.some(({ positions }) => isSubset(error.requestedPositions, positions), ) @@ -1028,8 +1042,8 @@ async function expectPersistingLoadIsApplied(persisting: boolean) { persistence.resolve() await transaction.isPersisted.promise } - live.cleanup() - source.cleanup() + await live.cleanup() + await source.cleanup() } } @@ -1086,8 +1100,8 @@ async function expectDerivedSyncDuringOptimisticMutation(): Promise { } finally { persistence.resolve() await transaction.isPersisted.promise - derived.cleanup() - source.cleanup() + await derived.cleanup() + await source.cleanup() } } @@ -1189,7 +1203,68 @@ async function runRejectedWaiterScenarioWithKnownFailure( } } +function expectExactCountFailure( + count: () => number, + actual: number, + expected: number, +): () => Promise { + return expectAssertionFailure( + () => + Promise.resolve().then(() => { + try { + expect(count()).toBe(expected) + } catch (error) { + throw new TraceAssertionError(0, error) + } + }), + { + checkpoint: 0, + classify: ({ actual: received, expected: wanted }) => + received === actual && wanted === expected, + }, + ) +} + describe(`loadSubset coverage oracle`, () => { + it(`rejects uncovered demand from the union-composition classifier`, () => { + expect( + isKnownUnionCompositionRefetch( + new CoveredDemandRefetchedError( + 2, + new Set([3]), + [new Set([1]), new Set([2])], + JSON.stringify({ kind: `eq`, value: 3 }), + [ + JSON.stringify({ kind: `eq`, value: 1 }), + JSON.stringify({ kind: `eq`, value: 2 }), + ], + ), + ), + ).toBe(false) + }) + + it(`rejects an uncovered window from the union classifier`, () => { + const request: WindowRequest = { + direction: `asc`, + offset: 2, + limit: 1, + } + expect( + isKnownCoveredWindowRefetch( + new CoveredWindowRefetchedError(2, request, new Set([2]), [ + { + request: { direction: `asc`, offset: 0, limit: 1 }, + positions: new Set([0]), + }, + { + request: { direction: `asc`, offset: 1, limit: 1 }, + positions: new Set([1]), + }, + ]), + ), + ).toBe(false) + }) + it(`keeps empty predicates out of the distinct-window corpus`, () => { expect( isDistinctNonEmptyWindowWherePair([ @@ -1201,45 +1276,36 @@ describe(`loadSubset coverage oracle`, () => { it( `discovered trace: an empty predicate issues no transport work`, - expectAssertionFailure( - () => - Promise.resolve().then(() => { - expect(countLoads([{ kind: `in`, values: [] }])).toBe(0) - }), - { message: /expected 1 to be/ }, + expectExactCountFailure( + () => countLoads([{ kind: `in`, values: [] }]), + 1, + 0, ), ) it( `discovered trace: an empty ordered window issues no transport work`, - expectAssertionFailure( - () => - Promise.resolve().then(() => { - expect( - countWindowLoads([{ direction: `asc`, offset: 0, limit: 0 }]), - ).toBe(0) - }), - { message: /expected 1 to be/ }, + expectExactCountFailure( + () => countWindowLoads([{ direction: `asc`, offset: 0, limit: 0 }]), + 1, + 0, ), ) it( `discovered trace: an empty filtered window issues no transport work`, - expectAssertionFailure( + expectExactCountFailure( () => - Promise.resolve().then(() => { - expect( - countWindowLoads([ - { - where: { kind: `in`, values: [] }, - direction: `asc`, - offset: 0, - limit: 1, - }, - ]), - ).toBe(0) - }), - { message: /expected 1 to be/ }, + countWindowLoads([ + { + where: { kind: `in`, values: [] }, + direction: `asc`, + offset: 0, + limit: 1, + }, + ]), + 1, + 0, ), ) diff --git a/packages/db/tests/query/pagination-oracle.property.test.ts b/packages/db/tests/query/pagination-oracle.property.test.ts index 0035c8033d..7b61af1be1 100644 --- a/packages/db/tests/query/pagination-oracle.property.test.ts +++ b/packages/db/tests/query/pagination-oracle.property.test.ts @@ -39,7 +39,7 @@ type MultiOrderScenario = { limit: number } -type Window = { +type PaginationWindow = { offset: number limit: number } @@ -47,18 +47,18 @@ type Window = { type PaginationScenario = { ranks: ReadonlyArray direction: `asc` | `desc` - windows: ReadonlyArray + windows: ReadonlyArray } type PaginationAction = - | ({ type: `window` } & Window) + | ({ type: `window` } & PaginationWindow) | { type: `put`; id: number; rank: number } | { type: `delete`; id: number } type PaginationStateScenario = { ranks: ReadonlyArray direction: `asc` | `desc` - initialWindow: Window + initialWindow: PaginationWindow actions: ReadonlyArray } @@ -110,7 +110,7 @@ const scenarioArbitrary: fc.Arbitrary = fc.record({ ), }) -const windowArbitrary: fc.Arbitrary = fc.record({ +const windowArbitrary: fc.Arbitrary = fc.record({ offset: fc.integer({ min: 0, max: 12 }), limit: fc.integer({ min: 0, max: 8 }), }) @@ -292,7 +292,7 @@ let collectionSequence = 0 function referenceWindow( rows: ReadonlyArray, direction: `asc` | `desc`, - window: Window, + window: PaginationWindow, ): Array { return referenceWindowRows(rows, direction, window).map(({ id }) => id) } @@ -300,7 +300,7 @@ function referenceWindow( function referenceWindowRows( rows: ReadonlyArray, direction: `asc` | `desc`, - window: Window, + window: PaginationWindow, ): Array { const directionFactor = direction === `asc` ? 1 : -1 return [...rows] @@ -649,7 +649,7 @@ async function runPaginationStateScenario( type ReferencePaginationState = { rows: Map - window: Window + window: PaginationWindow } function replayReferenceState( @@ -1759,6 +1759,22 @@ async function expectInflightRequestFillsNewWindow(): Promise { } describe(`pagination recomputation oracle`, () => { + it(`materializes an empty source window`, async () => { + await runPaginationScenario({ + ranks: [], + direction: `asc`, + windows: [{ offset: 0, limit: 3 }], + }) + }) + + it(`materializes an offset past the final row`, async () => { + await runPaginationScenario({ + ranks: [0, 1], + direction: `asc`, + windows: [{ offset: 4, limit: 2 }], + }) + }) + it(`materializes an initially empty zero-limit window`, async () => { await runPaginationScenario({ ranks: [0, 1, 2], diff --git a/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts b/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts index af2263d17c..cd0c15a589 100644 --- a/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts +++ b/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts @@ -150,7 +150,6 @@ async function expectFinalOwnerCleanupAbortsQuery(): Promise { expect(capturedSignal?.aborted).toBe(false) await live.cleanup() - await Promise.resolve() expect(capturedSignal?.aborted).toBe(true) } finally { await live.cleanup() From 7f34c1a098002ec3ee0371cb01842688b246a703 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 20 Aug 2026 20:39:09 +0100 Subject: [PATCH 10/18] test(db): close oracle review gaps --- .../query/load-subset-oracle.property.test.ts | 222 ++++++++++++++++-- .../query/pagination-oracle.property.test.ts | 187 +++++++++++++-- 2 files changed, 361 insertions(+), 48 deletions(-) diff --git a/packages/db/tests/query/load-subset-oracle.property.test.ts b/packages/db/tests/query/load-subset-oracle.property.test.ts index f1eac3552e..700ad06210 100644 --- a/packages/db/tests/query/load-subset-oracle.property.test.ts +++ b/packages/db/tests/query/load-subset-oracle.property.test.ts @@ -236,6 +236,16 @@ const windowRequestArbitrary: fc.Arbitrary> = limit: fc.option(fc.integer({ min: 0, max: 6 }), { nil: undefined }), }) +const finiteWindowRequestArbitrary: fc.Arbitrary> = + fc.record({ + orderField: fc.constantFrom(`none`, `rank`, `score`), + direction: fc.constantFrom(`asc`, `desc`), + nulls: fc.constantFrom(`first`, `last`), + stringSort: fc.constantFrom(`lexical`, `locale`), + offset: fc.integer({ min: 0, max: 6 }), + limit: fc.integer({ min: 0, max: 6 }), + }) + const windowTraceArbitrary = fc .record({ where: fc.option(predicateSpecArbitrary, { nil: undefined }), @@ -266,6 +276,24 @@ function isDistinctNonEmptyWindowWherePair([first, second]: readonly [ ) } +const changingWhereWindowTraceArbitrary = fc + .record({ + wherePair: distinctWindowWherePairArbitrary, + first: finiteWindowRequestArbitrary, + second: finiteWindowRequestArbitrary, + rest: fc.array(fc.tuple(fc.boolean(), finiteWindowRequestArbitrary), { + maxLength: 18, + }), + }) + .map(({ wherePair, first, second, rest }) => [ + { ...first, where: wherePair[0] }, + { ...second, where: wherePair[1] }, + ...rest.map(([useSecond, request]) => ({ + ...request, + where: wherePair[useSecond ? 1 : 0], + })), + ]) + function toWhere( predicate: PredicateSpec, ): BasicExpression | undefined { @@ -458,7 +486,8 @@ function runCoverageTraceWithKnownFailures( } catch (error) { if ( error instanceof CoveredDemandRefetchedError && - isKnownUnionCompositionRefetch(error) + (isKnownUnionCompositionRefetch(error) || + isKnownComposedRegionRefetch(error)) ) { return } @@ -466,17 +495,32 @@ function runCoverageTraceWithKnownFailures( } } +function isKnownComposedRegionRefetch( + error: CoveredDemandRefetchedError, +): boolean { + if (error.requested.size === 0 || error.loadedRegions.length <= 1) { + return false + } + + return error.loadedRegions.some((region) => isSubset(error.requested, region)) +} + function isKnownUnionCompositionRefetch( error: CoveredDemandRefetchedError, ): boolean { if (error.requested.size === 0) return true // The error can only be built after the independent model proves the demand - // is already covered. Once two unlimited regions have been composed, the - // current implementation can refetch any later covered demand, including a - // strict subset of one original region. Fixed traces below make this waiver - // expire when that product defect is repaired. + // is already covered. This classifier is only for coverage formed by + // composing several regions; a request covered by one region is a different + // defect and must not enter this waiver. if (error.loadedRegions.length > 1) { - return isSubset(error.requested, unionSets(error.loadedRegions)) + const coveredByOneRegion = error.loadedRegions.some((region) => + isSubset(error.requested, region), + ) + return ( + !coveredByOneRegion && + isSubset(error.requested, unionSets(error.loadedRegions)) + ) } const usesCompoundPredicate = [ @@ -503,6 +547,18 @@ function countLoads(trace: ReadonlyArray): number { return loads } +function readDedupeTrackingState(dedupe: DeduplicatedLoadSubset): { + unlimitedWhere: BasicExpression | undefined + limitedCalls: ReadonlyArray + inflightCalls: ReadonlyArray +} { + return dedupe as unknown as { + unlimitedWhere: BasicExpression | undefined + limitedCalls: ReadonlyArray + inflightCalls: ReadonlyArray + } +} + function toWindowOptions(request: WindowRequest): LoadSubsetOptions { const orderField = request.orderField ?? `rank` return { @@ -624,8 +680,6 @@ function isKnownOffsetTruncatedUnlimitedDeduplication( const loadedOptions = toWindowOptions(loaded) const priorLoads = error.loadedRegions.map(({ request }) => request) return ( - JSON.stringify(requestedOptions.orderBy) !== - JSON.stringify(loadedOptions.orderBy) && isSubset( matchingValues(requestedOptions.where), matchingValues(loadedOptions.where), @@ -985,8 +1039,11 @@ async function runAsyncScenarioWithKnownFailures( } const { multiplier, replaySeed } = readOracleRunConfig() -const runs = 40 * multiplier -const randomParameters = oracleRandomParameters(runs, replaySeed) +const coverageScenarioRuns = 40 * multiplier +const coverageRandomParameters = oracleRandomParameters( + coverageScenarioRuns, + replaySeed, +) let collectionSequence = 0 @@ -1226,6 +1283,39 @@ function expectExactCountFailure( } describe(`loadSubset coverage oracle`, () => { + it(`rejects one-region coverage from the union-composition classifier`, () => { + expect( + isKnownUnionCompositionRefetch( + new CoveredDemandRefetchedError( + 2, + new Set([1]), + [new Set([1]), new Set([2])], + JSON.stringify({ kind: `eq`, value: 1 }), + [ + JSON.stringify({ kind: `eq`, value: 1 }), + JSON.stringify({ kind: `eq`, value: 2 }), + ], + ), + ), + ).toBe(false) + }) + + it(`classifies a composed state that forgets one loaded region separately`, () => { + const error = new CoveredDemandRefetchedError( + 2, + new Set([2]), + [new Set([0]), new Set([2])], + JSON.stringify({ kind: `eq`, value: 2 }), + [ + JSON.stringify({ kind: `in`, values: [0] }), + JSON.stringify({ kind: `in`, values: [2] }), + ], + ) + + expect(isKnownUnionCompositionRefetch(error)).toBe(false) + expect(isKnownComposedRegionRefetch(error)).toBe(true) + }) + it(`rejects uncovered demand from the union-composition classifier`, () => { expect( isKnownUnionCompositionRefetch( @@ -1265,6 +1355,20 @@ describe(`loadSubset coverage oracle`, () => { ).toBe(false) }) + it(`generates window histories that change predicates`, () => { + const traces = fc.sample(changingWhereWindowTraceArbitrary, { + seed: 1750, + numRuns: 100, + }) + + expect( + traces.some( + (trace) => + new Set(trace.map(({ where }) => JSON.stringify(where))).size > 1, + ), + ).toBe(true) + }) + it(`keeps empty predicates out of the distinct-window corpus`, () => { expect( isDistinctNonEmptyWindowWherePair([ @@ -1351,6 +1455,28 @@ describe(`loadSubset coverage oracle`, () => { ), ) + it( + `discovered trace: an offset-truncated unfiltered load does not cover a filtered request`, + expectExactCountFailure( + () => + countWindowLoads([ + { + direction: `asc`, + offset: 1, + limit: undefined, + }, + { + where: { kind: `not`, operand: { kind: `eq`, value: 0 } }, + direction: `asc`, + offset: 1, + limit: undefined, + }, + ]), + 1, + 2, + ), + ) + it( `discovered trace: an identical filtered window reuses its load`, expectAssertionFailure( @@ -1391,6 +1517,22 @@ describe(`loadSubset coverage oracle`, () => { ]) }) + it(`keeps tracking bounded across repeated covered demand`, () => { + const dedupe = new DeduplicatedLoadSubset({ loadSubset: () => true }) + const request: LoadSubsetOptions = { + where: toWhere({ kind: `range`, operator: `gte`, value: 0 }), + offset: 0, + limit: 4, + } + + dedupe.loadSubset(request) + for (let index = 0; index < 20; index++) dedupe.loadSubset(request) + + const state = readDedupeTrackingState(dedupe) + expect(state.limitedCalls).toHaveLength(1) + expect(state.inflightCalls).toHaveLength(0) + }) + it(`rejects transport work for a strict covered predicate subset`, () => { expect(() => runCoverageTrace( @@ -1438,6 +1580,20 @@ describe(`loadSubset coverage oracle`, () => { ), ) + it( + `discovered trace: a composed predicate state forgets one loaded region`, + expectExactCountFailure( + () => + countLoads([ + { kind: `in`, values: [0] }, + { kind: `in`, values: [2] }, + { kind: `eq`, value: 2 }, + ]), + 3, + 2, + ), + ) + it(`rejects repeated transport work for one identical compound predicate`, () => { const predicate: PredicateSpec = { kind: `and`, @@ -1674,71 +1830,93 @@ describe(`loadSubset coverage oracle`, () => { }) }) - fcTest.prop([requestTraceArbitrary], { numRuns: runs, seed: 1657 })( + fcTest.prop([requestTraceArbitrary], { + numRuns: coverageScenarioRuns, + seed: 1657, + })( `matches finite-domain coverage for a fixed seed`, runCoverageTraceWithKnownFailures, ) - fcTest.prop([requestTraceArbitrary], randomParameters)( + fcTest.prop([requestTraceArbitrary], coverageRandomParameters)( `matches finite-domain coverage for a random or replayed seed`, runCoverageTraceWithKnownFailures, ) - fcTest.prop([asyncScenarioArbitrary], { numRuns: runs, seed: 1658 })( + fcTest.prop([asyncScenarioArbitrary], { + numRuns: coverageScenarioRuns, + seed: 1658, + })( `settles, retries, and resets in-flight set requests for a fixed seed`, runAsyncScenarioWithKnownFailures, ) - fcTest.prop([asyncScenarioArbitrary], randomParameters)( + fcTest.prop([asyncScenarioArbitrary], coverageRandomParameters)( `settles, retries, and resets in-flight set requests for a random or replayed seed`, runAsyncScenarioWithKnownFailures, ) fcTest.prop([concurrentAsyncScenarioArbitrary], { - numRuns: runs, + numRuns: coverageScenarioRuns, seed: 1661, })( `deduplicates three or more concurrent requests for a fixed seed`, runConcurrentAsyncScenario, ) - fcTest.prop([concurrentAsyncScenarioArbitrary], randomParameters)( + fcTest.prop([concurrentAsyncScenarioArbitrary], coverageRandomParameters)( `deduplicates three or more concurrent requests for a random or replayed seed`, runConcurrentAsyncScenario, ) fcTest.prop([rejectedWaiterScenarioArbitrary], { - numRuns: runs, + numRuns: coverageScenarioRuns, seed: 1665, })( `checks rejected requests observed by an in-flight waiter for a fixed seed`, runRejectedWaiterScenarioWithKnownFailure, ) - fcTest.prop([rejectedWaiterScenarioArbitrary], randomParameters)( + fcTest.prop([rejectedWaiterScenarioArbitrary], coverageRandomParameters)( `checks rejected requests observed by an in-flight waiter for a random or replayed seed`, runRejectedWaiterScenarioWithKnownFailure, ) - fcTest.prop([windowTraceArbitrary], { numRuns: runs, seed: 1659 })( + fcTest.prop([windowTraceArbitrary], { + numRuns: coverageScenarioRuns, + seed: 1659, + })( `never treats uncovered ordered windows as loaded for a fixed seed`, runWindowCoverageTraceWithKnownFailures, ) - fcTest.prop([windowTraceArbitrary], randomParameters)( + fcTest.prop([windowTraceArbitrary], coverageRandomParameters)( `never treats uncovered ordered windows as loaded for a random or replayed seed`, runWindowCoverageTraceWithKnownFailures, ) + fcTest.prop([changingWhereWindowTraceArbitrary], { + numRuns: coverageScenarioRuns, + seed: 1666, + })( + `keeps changing predicates distinct across window histories for a fixed seed`, + runWindowCoverageTraceWithKnownFailures, + ) + + fcTest.prop([changingWhereWindowTraceArbitrary], coverageRandomParameters)( + `keeps changing predicates distinct across window histories for a random or replayed seed`, + runWindowCoverageTraceWithKnownFailures, + ) + fcTest.prop([distinctWindowWherePairArbitrary], { - numRuns: runs, + numRuns: coverageScenarioRuns, seed: 1662, })( `keeps distinct limited-window predicates separate for a fixed seed`, expectDistinctWhereStartsDistinctLimitedWindowLoads, ) - fcTest.prop([distinctWindowWherePairArbitrary], randomParameters)( + fcTest.prop([distinctWindowWherePairArbitrary], coverageRandomParameters)( `keeps distinct limited-window predicates separate for a random or replayed seed`, expectDistinctWhereStartsDistinctLimitedWindowLoads, ) diff --git a/packages/db/tests/query/pagination-oracle.property.test.ts b/packages/db/tests/query/pagination-oracle.property.test.ts index 7b61af1be1..05b3b6298a 100644 --- a/packages/db/tests/query/pagination-oracle.property.test.ts +++ b/packages/db/tests/query/pagination-oracle.property.test.ts @@ -78,6 +78,16 @@ type PendingMutationScenario = { direction: `asc` | `desc` limit: number mutation: PendingMutation + responseOutcome: `resolve` | `reject` +} + +class PendingMutationTraceAssertionError extends TraceAssertionError { + constructor( + cause: unknown, + readonly deliveredRows: ReadonlyArray, + ) { + super(0, cause) + } } type PendingHistoryScenario = { @@ -169,6 +179,7 @@ const pendingMutationScenarioArbitrary: fc.Arbitrary = `update` as const, `delete` as const, ), + responseOutcome: fc.constantFrom(`resolve` as const, `reject` as const), targetIndex: fc.nat({ max: 7 }), rank: fc.integer({ min: -2, max: 2 }), }) @@ -178,6 +189,7 @@ const pendingMutationScenarioArbitrary: fc.Arbitrary = direction, requestedLimit, mutationKind, + responseOutcome, targetIndex, rank, }) => { @@ -194,8 +206,9 @@ const pendingMutationScenarioArbitrary: fc.Arbitrary = return { ranks, direction, - limit: Math.min(requestedLimit, ranks.length), + limit: Math.min(requestedLimit, ranks.length - 2), mutation, + responseOutcome, } }, ) @@ -284,8 +297,12 @@ const multiOrderScenarioArbitrary: fc.Arbitrary = fc })) const { multiplier, replaySeed } = readOracleRunConfig() -const runs = 12 * multiplier -const randomParameters = oracleRandomParameters(runs, replaySeed) +const orderedScenarioRuns = 12 * multiplier +const transitionScenarioRuns = 8 * multiplier +const orderedScenarioRandomParameters = oracleRandomParameters( + orderedScenarioRuns, + replaySeed, +) let collectionSequence = 0 @@ -1234,6 +1251,7 @@ async function runPendingMutationScenario( .orderBy(({ row }) => row.id, `asc`) .limit(scenario.limit), ) + const outstanding: Array> = [] const applyMutation = () => { const { mutation } = scenario @@ -1246,7 +1264,7 @@ async function runPendingMutationScenario( write({ type: `delete`, value: { ...row } }) } else { rows.set(mutation.row.id, { ...mutation.row }) - if (mutation.type === `insert`) deliveredIds.add(mutation.row.id) + deliveredIds.add(mutation.row.id) write({ type: mutation.type, value: { ...mutation.row } }) } commit() @@ -1278,15 +1296,32 @@ async function runPendingMutationScenario( try { const preload = live.preload() + outstanding.push(preload) expect(pending).toHaveLength(1) if (timing === `before-response`) applyMutation() - await settlePending() - await preload - if (timing === `after-response`) { - applyMutation() - await Promise.resolve() + let finalLimit = scenario.limit + if (scenario.responseOutcome === `resolve`) { await settlePending() + await preload + if (timing === `after-response`) { + applyMutation() + await Promise.resolve() + await settlePending() + } + } else { + pending[0]!.settled = true + pending[0]!.deferred.reject(new Error(`cursor failed`)) + await Promise.resolve() + await Promise.allSettled([preload]) + if (timing === `after-response`) applyMutation() + + finalLimit += 1 + const retry = live.utils.setWindow({ offset: 0, limit: finalLimit }) + if (retry instanceof Promise) outstanding.push(retry) + expect(pending.length).toBeLessThanOrEqual(2) + if (pending.length === 2) await settlePending() + if (retry instanceof Promise) await retry } try { @@ -1295,16 +1330,24 @@ async function runPendingMutationScenario( ).toEqual( referenceWindowRows([...rows.values()], scenario.direction, { offset: 0, - limit: scenario.limit, + limit: finalLimit, }), ) } catch (error) { - throw new TraceAssertionError(0, error) + throw new PendingMutationTraceAssertionError( + error, + referenceWindowRows( + [...rows.values()].filter(({ id }) => deliveredIds.has(id)), + scenario.direction, + { offset: 0, limit: finalLimit }, + ), + ) } } finally { for (const request of pending) request.deferred.resolve() - live.cleanup() - source.cleanup() + await Promise.allSettled(outstanding) + await live.cleanup() + await source.cleanup() } } @@ -1327,7 +1370,9 @@ function isKnownSettledTopKMembershipFailure( timing: `before-response` | `after-response`, error: unknown, ): boolean { - if (timing !== `after-response`) return false + if (scenario.responseOutcome !== `resolve` || timing !== `after-response`) { + return false + } const difference = readPageRowDifferenceAtCheckpoint(error, 0) if (!difference) return false @@ -1360,6 +1405,32 @@ function isKnownSettledTopKMembershipFailure( ) } +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`, @@ -1368,6 +1439,7 @@ async function runPendingMutationScenarioWithKnownFailures( await runPendingMutationScenario(scenario, timing) } catch (error) { if (isKnownSettledTopKMembershipFailure(scenario, timing, error)) return + if (isKnownRejectedCursorRetryFailure(scenario, error)) return throw error } } @@ -1918,7 +1990,7 @@ describe(`pagination recomputation oracle`, () => { }) fcTest.prop([multiOrderScenarioArbitrary], { - numRuns: 12 * multiplier, + numRuns: orderedScenarioRuns, seed: 1663, })( `matches multi-column nullable ordering for a fixed seed`, @@ -1927,7 +1999,7 @@ describe(`pagination recomputation oracle`, () => { fcTest.prop( [multiOrderScenarioArbitrary], - oracleRandomParameters(12 * multiplier, replaySeed), + oracleRandomParameters(orderedScenarioRuns, replaySeed), )( `matches multi-column nullable ordering for a random or replayed seed`, runMultiOrderScenarioWithKnownFailures, @@ -1967,6 +2039,7 @@ describe(`pagination recomputation oracle`, () => { direction: `asc`, limit: 3, mutation, + responseOutcome: `resolve`, } await runPendingMutationScenario(scenario, `before-response`) await runPendingMutationScenario(scenario, `after-response`) @@ -1979,6 +2052,7 @@ describe(`pagination recomputation oracle`, () => { direction: `desc`, limit: 1, mutation: { type: `update`, row: { id: 3, rank: 0 } }, + responseOutcome: `resolve`, } await expectAssertionFailure( () => runPendingMutationScenario(scenario, `after-response`), @@ -1999,6 +2073,7 @@ describe(`pagination recomputation oracle`, () => { direction: `desc`, limit: 1, mutation: { type: `update`, row: { id: 3, rank: 0 } }, + responseOutcome: `resolve`, } expect( @@ -2010,8 +2085,65 @@ describe(`pagination recomputation oracle`, () => { ).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 }, + ]), + }, + )() + }) + + it(`rejects collateral output from the rejected-cursor retry classifier`, () => { + const scenario: PendingMutationScenario = { + ranks: [0, -1, 0], + direction: `asc`, + limit: 1, + mutation: { type: `insert`, row: { id: 4, rank: 0 } }, + 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) + }) + fcTest.prop([pendingMutationScenarioArbitrary, responseTimingArbitrary], { - numRuns: 8 * multiplier, + numRuns: transitionScenarioRuns, seed: 1660, })( `matches recomputation when source mutations cross a pending cursor response for a fixed seed`, @@ -2020,7 +2152,7 @@ describe(`pagination recomputation oracle`, () => { fcTest.prop( [pendingMutationScenarioArbitrary, responseTimingArbitrary], - oracleRandomParameters(8 * multiplier, replaySeed), + oracleRandomParameters(transitionScenarioRuns, replaySeed), )( `matches recomputation when source mutations cross a pending cursor response for a random or replayed seed`, runPendingMutationScenarioWithKnownFailures, @@ -2039,7 +2171,7 @@ describe(`pagination recomputation oracle`, () => { ) fcTest.prop([pendingHistoryScenarioArbitrary], { - numRuns: 8 * multiplier, + numRuns: transitionScenarioRuns, seed: 1664, })( `matches recomputation across multi-action pending histories for a fixed seed`, @@ -2048,7 +2180,7 @@ describe(`pagination recomputation oracle`, () => { fcTest.prop( [pendingHistoryScenarioArbitrary], - oracleRandomParameters(8 * multiplier, replaySeed), + oracleRandomParameters(transitionScenarioRuns, replaySeed), )( `matches recomputation across multi-action pending histories for a random or replayed seed`, runPendingHistoryScenarioWithKnownFailures, @@ -2341,18 +2473,21 @@ describe(`pagination recomputation oracle`, () => { })(scenario) }) - fcTest.prop([scenarioArbitrary], { numRuns: runs, seed: 1657 })( + fcTest.prop([scenarioArbitrary], { + numRuns: orderedScenarioRuns, + seed: 1657, + })( `matches full recomputation across ordered windows for a fixed seed`, runPaginationScenario, ) - fcTest.prop([scenarioArbitrary], randomParameters)( + fcTest.prop([scenarioArbitrary], orderedScenarioRandomParameters)( `matches full recomputation across ordered windows for a random or replayed seed`, runPaginationScenario, ) fcTest.prop([stateScenarioArbitrary], { - numRuns: 8 * multiplier, + numRuns: transitionScenarioRuns, seed: 1658, })( `matches full recomputation across source and window transitions for a fixed seed`, @@ -2361,7 +2496,7 @@ describe(`pagination recomputation oracle`, () => { fcTest.prop( [stateScenarioArbitrary], - oracleRandomParameters(8 * multiplier, replaySeed), + oracleRandomParameters(transitionScenarioRuns, replaySeed), )( `matches full recomputation across source and window transitions for a random or replayed seed`, runPaginationStateScenarioWithKnownFailures, @@ -2562,7 +2697,7 @@ describe(`pagination recomputation oracle`, () => { }) fcTest.prop([scenarioArbitrary], { - numRuns: 8 * multiplier, + numRuns: transitionScenarioRuns, seed: 1659, })( `matches full recomputation when exact async cursor loads widen ordered coverage for a fixed seed`, @@ -2571,7 +2706,7 @@ describe(`pagination recomputation oracle`, () => { fcTest.prop( [scenarioArbitrary], - oracleRandomParameters(8 * multiplier, replaySeed), + oracleRandomParameters(transitionScenarioRuns, replaySeed), )( `matches full recomputation when exact async cursor loads widen ordered coverage for a random or replayed seed`, runOnDemandPaginationScenarioWithKnownFailures, From 973cb7289bd05b80731db95ab7c058b8a8f781c9 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Thu, 20 Aug 2026 16:20:49 -0600 Subject: [PATCH 11/18] test: account for load subset abort signal --- packages/db/tests/query/load-subset-join-dedupe.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/db/tests/query/load-subset-join-dedupe.test.ts b/packages/db/tests/query/load-subset-join-dedupe.test.ts index 898cdde334..54427f5920 100644 --- a/packages/db/tests/query/load-subset-join-dedupe.test.ts +++ b/packages/db/tests/query/load-subset-join-dedupe.test.ts @@ -154,6 +154,7 @@ describe(`loadSubset join-key deduplication`, () => { where: expect.anything(), orderBy: undefined, limit: undefined, + signal: expect.any(AbortSignal), subscription: expect.anything(), }) expect(load.where).toBeDefined() From 011fe2a2a945f5273474df18a6e5826e2044e650 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 20 Aug 2026 16:58:37 -0600 Subject: [PATCH 12/18] test(db): tighten loadSubset oracle boundaries --- .../query/load-subset-oracle.property.test.ts | 80 +++++++++++++------ .../query/pagination-oracle.property.test.ts | 17 +++- 2 files changed, 71 insertions(+), 26 deletions(-) diff --git a/packages/db/tests/query/load-subset-oracle.property.test.ts b/packages/db/tests/query/load-subset-oracle.property.test.ts index 700ad06210..c59cf7ce78 100644 --- a/packages/db/tests/query/load-subset-oracle.property.test.ts +++ b/packages/db/tests/query/load-subset-oracle.property.test.ts @@ -484,6 +484,7 @@ function runCoverageTraceWithKnownFailures( try { runCoverageTrace(trace, createSubject) } catch (error) { + if (createSubject !== createDeduplicatedCoverageSubject) throw error if ( error instanceof CoveredDemandRefetchedError && (isKnownUnionCompositionRefetch(error) || @@ -581,10 +582,15 @@ function toWindowOptions(request: WindowRequest): LoadSubsetOptions { } } +function hasNoWindowDemand(request: WindowRequest): boolean { + return ( + request.limit === 0 || + matchingValues(toWindowOptions(request).where).size === 0 + ) +} + function windowPositions(request: WindowRequest): Set { - if (request.where?.kind === `in` && request.where.values.length === 0) { - return new Set() - } + if (hasNoWindowDemand(request)) return new Set() // The coverage oracle needs a finite universe. Generated finite windows end // at position 11, so 16 positions preserve every generated subset relation // while giving an omitted limit an authoritative "through the end" region. @@ -695,9 +701,7 @@ function isKnownCoveredWindowRefetch( ): boolean { if ( error.requestedPositions.size === 0 && - (error.requested.limit === 0 || - (error.requested.where?.kind === `in` && - error.requested.where.values.length === 0)) + hasNoWindowDemand(error.requested) ) { return true } @@ -1316,23 +1320,6 @@ describe(`loadSubset coverage oracle`, () => { expect(isKnownComposedRegionRefetch(error)).toBe(true) }) - it(`rejects uncovered demand from the union-composition classifier`, () => { - expect( - isKnownUnionCompositionRefetch( - new CoveredDemandRefetchedError( - 2, - new Set([3]), - [new Set([1]), new Set([2])], - JSON.stringify({ kind: `eq`, value: 3 }), - [ - JSON.stringify({ kind: `eq`, value: 1 }), - JSON.stringify({ kind: `eq`, value: 2 }), - ], - ), - ), - ).toBe(false) - }) - it(`rejects an uncovered window from the union classifier`, () => { const request: WindowRequest = { direction: `asc`, @@ -1413,6 +1400,29 @@ describe(`loadSubset coverage oracle`, () => { ), ) + it( + `discovered trace: a contradictory filtered window issues no transport work`, + expectExactCountFailure( + () => + countWindowLoads([ + { + where: { + kind: `and`, + operands: [ + { kind: `eq`, value: 0 }, + { kind: `eq`, value: 1 }, + ], + }, + direction: `asc`, + offset: 0, + limit: 1, + }, + ]), + 1, + 0, + ), + ) + it( `discovered trace: widening an unlimited offset starts another load`, expectAssertionFailure( @@ -1610,6 +1620,30 @@ describe(`loadSubset coverage oracle`, () => { ).toThrow() }) + it(`rejects repeated transport work for a covered compound predicate`, () => { + const covering: PredicateSpec = { + kind: `and`, + operands: [ + { kind: `range`, operator: `gte`, value: 0 }, + { kind: `not`, operand: { kind: `eq`, value: 2 } }, + ], + } + const covered: PredicateSpec = { + kind: `or`, + operands: [ + { kind: `eq`, value: 1 }, + { kind: `eq`, value: 3 }, + ], + } + + expect(() => + runCoverageTraceWithKnownFailures( + [covering, covered], + createAlwaysLoadingCoverageSubject, + ), + ).toThrow() + }) + it(`rejects repeated transport work for one covered window`, () => { expect(() => runWindowCoverageTrace( diff --git a/packages/db/tests/query/pagination-oracle.property.test.ts b/packages/db/tests/query/pagination-oracle.property.test.ts index 05b3b6298a..3a5b086706 100644 --- a/packages/db/tests/query/pagination-oracle.property.test.ts +++ b/packages/db/tests/query/pagination-oracle.property.test.ts @@ -420,7 +420,11 @@ async function runPaginationScenario( try { await live.preload() - for (const window of scenario.windows) { + expect(Array.from(live.values(), ({ id }) => id)).toEqual( + referenceWindow(rows, scenario.direction, initialWindow), + ) + + for (const window of scenario.windows.slice(1)) { const result = live.utils.setWindow(window) if (result instanceof Promise) await result @@ -1081,8 +1085,15 @@ async function runOnDemandPaginationScenario( try { await live.preload() expect(loads.length).toBeGreaterThan(0) + try { + expect(Array.from(live.values(), ({ id }) => id)).toEqual( + referenceWindow(authoritativeRows, scenario.direction, initialWindow), + ) + } catch (error) { + throw new TraceAssertionError(0, error) + } - for (const [index, window] of scenario.windows.entries()) { + for (const [index, window] of scenario.windows.slice(1).entries()) { const result = live.utils.setWindow(window) if (result instanceof Promise) await result @@ -1091,7 +1102,7 @@ async function runOnDemandPaginationScenario( referenceWindow(authoritativeRows, scenario.direction, window), ) } catch (error) { - throw new TraceAssertionError(index, error) + throw new TraceAssertionError(index + 1, error) } } From fa9d2f9a04a0c1612e502fffba4455f324ccc00d Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Thu, 20 Aug 2026 20:19:40 -0600 Subject: [PATCH 13/18] test: align includes preload error expectation --- packages/db/tests/query/includes-temporal-oracle.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/db/tests/query/includes-temporal-oracle.test.ts b/packages/db/tests/query/includes-temporal-oracle.test.ts index 5c89ff3f66..2fc9eebcbc 100644 --- a/packages/db/tests/query/includes-temporal-oracle.test.ts +++ b/packages/db/tests/query/includes-temporal-oracle.test.ts @@ -2,6 +2,7 @@ import { fc, test as fcTest } from '@fast-check/vitest' import { describe, expect, it, vi } from 'vitest' import { createCollection } from '../../src/collection/index.js' import { createDeferred } from '../../src/deferred.js' +import { CollectionIsInErrorStateError } from '../../src/errors.js' import { BasicIndex } from '../../src/indexes/basic-index.js' import { extractSimpleComparisons } from '../../src/query/expression-helpers.js' import { SubsetDemandController } from '../../src/query/live/subset-demand-controller.js' @@ -767,7 +768,10 @@ async function expectRejectedDemandEntersError(): Promise { await flushPromises() expect(loadCount).toBe(1) expect(live.status).toBe(`error`) - expect(preload.preloadSettled).toBe(false) + expect(preload.preloadSettled).toBe(true) + expect(preload.preloadFailure?.error).toBeInstanceOf( + CollectionIsInErrorStateError, + ) await live.cleanup() await preload.preloadOutcome From 5c312724f475bfe75dc27bdbc3f991de3a8f4b85 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 21 Aug 2026 06:40:24 -0600 Subject: [PATCH 14/18] test(db): harden loadSubset oracle boundaries --- packages/db/tests/oracle-config.ts | 41 +- .../query/load-subset-oracle.property.test.ts | 325 +++++++------- .../query/pagination-oracle.property.test.ts | 405 ++++++++++++------ packages/db/tests/reference-expression.ts | 59 +++ packages/db/tests/utils.test.ts | 4 +- packages/db/tests/utils.ts | 30 -- 6 files changed, 537 insertions(+), 327 deletions(-) create mode 100644 packages/db/tests/reference-expression.ts diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index 8ec28f414b..2a0375432a 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -1,18 +1,39 @@ -const multiplierText = process.env.TANSTACK_DB_ORACLE_RUNS_MULTIPLIER -const multiplier = multiplierText === undefined ? 1 : Number(multiplierText) -const seedText = process.env.TANSTACK_DB_ORACLE_SEED -const seed = seedText === undefined ? undefined : Number(seedText) +type OracleEnvironment = Record -if (!Number.isSafeInteger(multiplier) || multiplier < 1) { - throw new Error( - `TANSTACK_DB_ORACLE_RUNS_MULTIPLIER must be a positive integer`, - ) +export function readOracleRunConfig( + environment: OracleEnvironment = process.env, +): { multiplier: number; replaySeed: number | undefined } { + const multiplierValue = environment.TANSTACK_DB_ORACLE_RUNS_MULTIPLIER ?? `1` + const multiplier = Number(multiplierValue) + if ( + multiplierValue.trim() === `` || + !Number.isSafeInteger(multiplier) || + multiplier < 1 + ) { + throw new Error( + `TANSTACK_DB_ORACLE_RUNS_MULTIPLIER must be a positive integer`, + ) + } + + const seedValue = environment.TANSTACK_DB_ORACLE_SEED + if (seedValue === undefined) return { multiplier, replaySeed: undefined } + + const replaySeed = Number(seedValue) + if (seedValue.trim() === `` || !Number.isSafeInteger(replaySeed)) { + throw new Error(`TANSTACK_DB_ORACLE_SEED must be an integer`) + } + return { multiplier, replaySeed } } -if (seed !== undefined && !Number.isSafeInteger(seed)) { - throw new Error(`TANSTACK_DB_ORACLE_SEED must be an integer`) +export function oracleRandomParameters( + numRuns: number, + replaySeed: number | undefined, +): { numRuns: number; seed?: number } { + return replaySeed === undefined ? { numRuns } : { numRuns, seed: replaySeed } } +const { multiplier, replaySeed: seed } = readOracleRunConfig() + /** Keeps ordinary CI bounded while allowing long randomized oracle campaigns. */ export function oracleRuns(baseRuns: number): number { return baseRuns * multiplier diff --git a/packages/db/tests/query/load-subset-oracle.property.test.ts b/packages/db/tests/query/load-subset-oracle.property.test.ts index faafac4852..a0b295d78c 100644 --- a/packages/db/tests/query/load-subset-oracle.property.test.ts +++ b/packages/db/tests/query/load-subset-oracle.property.test.ts @@ -8,8 +8,12 @@ import { DeduplicatedLoadSubset } from '../../src/query/subset-dedupe.js' import { Func, PropRef, Value } from '../../src/query/ir.js' import { createTransaction } from '../../src/transactions.js' import { expectAssertionFailure } from '../expected-failure.js' +import { + oracleRandomParameters, + readOracleRunConfig, +} from '../oracle-config.js' +import { evaluateReferenceExpression } from '../reference-expression.js' import { TraceAssertionError } from '../trace-runner.js' -import { oracleRandomParameters, readOracleRunConfig } from '../utils.js' import type { BasicExpression } from '../../src/query/ir.js' import type { LoadSubsetOptions } from '../../src/types.js' @@ -150,24 +154,29 @@ const atomicPredicateSpecArbitrary: fc.Arbitrary = fc.oneof( }, ) -const predicateSpecArbitrary: fc.Arbitrary = fc.oneof( - { weight: 8, arbitrary: atomicPredicateSpecArbitrary }, - { - weight: 2, - arbitrary: fc.record({ +function booleanPredicateSpecArbitrary( + operand: fc.Arbitrary, +): fc.Arbitrary { + return fc.oneof( + fc.record({ kind: fc.constantFrom(`and` as const, `or` as const), - operands: fc.tuple( - atomicPredicateSpecArbitrary, - atomicPredicateSpecArbitrary, - ), + operands: fc.tuple(operand, operand), }), - }, + operand.map((nested) => ({ kind: `not` as const, operand: nested })), + ) +} + +const shallowPredicateSpecArbitrary = fc.oneof( + atomicPredicateSpecArbitrary, + booleanPredicateSpecArbitrary(atomicPredicateSpecArbitrary), +) + +const predicateSpecArbitrary: fc.Arbitrary = fc.oneof( + { weight: 8, arbitrary: atomicPredicateSpecArbitrary }, + { weight: 2, arbitrary: shallowPredicateSpecArbitrary }, { weight: 1, - arbitrary: atomicPredicateSpecArbitrary.map((operand) => ({ - kind: `not` as const, - operand, - })), + arbitrary: booleanPredicateSpecArbitrary(shallowPredicateSpecArbitrary), }, ) @@ -181,30 +190,23 @@ const nonEmptyInValuesArbitrary = fc.uniqueArray( { minLength: 1, maxLength: 7 }, ) -const asyncScenarioArbitrary: fc.Arbitrary = fc - .record({ - first: nonEmptyInValuesArbitrary, - second: nonEmptyInValuesArbitrary, - firstOutcome: fc.constantFrom( - `resolve`, - `reject`, - ), - secondOutcome: fc.constantFrom( - `resolve`, - `reject`, - ), - deliveryOrder: fc.constantFrom( - `forward`, - `reverse`, - ), - resetBeforeSettlement: fc.boolean(), - }) - .map((scenario) => - scenario.firstOutcome === `reject` && - scenario.second.every((value) => scenario.first.includes(value)) - ? { ...scenario, firstOutcome: `resolve` } - : scenario, - ) +const asyncScenarioArbitrary: fc.Arbitrary = fc.record({ + first: nonEmptyInValuesArbitrary, + second: nonEmptyInValuesArbitrary, + firstOutcome: fc.constantFrom( + `resolve`, + `reject`, + ), + secondOutcome: fc.constantFrom( + `resolve`, + `reject`, + ), + deliveryOrder: fc.constantFrom( + `forward`, + `reverse`, + ), + resetBeforeSettlement: fc.boolean(), +}) const concurrentAsyncScenarioArbitrary: fc.Arbitrary = fc.record({ @@ -317,58 +319,14 @@ function toRequiredWhere(predicate: PredicateSpec): BasicExpression { return toWhere(predicate) ?? new Value(true) } -function evaluateExpression( - expression: BasicExpression, - score: number, -): unknown { - switch (expression.type) { - case `ref`: - if (expression.path.at(-1) !== `score`) { - throw new Error(`Unsupported reference: ${expression.path.join(`.`)}`) - } - return score - case `val`: - return expression.value - case `func`: { - const args = expression.args.map((argument) => - evaluateExpression(argument, score), - ) - switch (expression.name) { - case `eq`: - return args[0] === args[1] - case `gt`: - return Number(args[0]) > Number(args[1]) - case `gte`: - return Number(args[0]) >= Number(args[1]) - case `lt`: - return Number(args[0]) < Number(args[1]) - case `lte`: - return Number(args[0]) <= Number(args[1]) - case `in`: - if (!Array.isArray(args[1])) { - throw new Error(`IN requires an array`) - } - return args[1].includes(args[0]) - case `and`: - return args.every(Boolean) - case `or`: - return args.some(Boolean) - case `not`: - return !args[0] - default: - throw new Error(`Unsupported predicate function: ${expression.name}`) - } - } - } -} - function matchingValues( where: BasicExpression | undefined, ): Set { return new Set( valueDomain.filter( (score) => - where === undefined || evaluateExpression(where, score) === true, + where === undefined || + evaluateReferenceExpression(where, { score }) === true, ), ) } @@ -468,19 +426,15 @@ function runCoverageTrace( loadedRegions.push(loaded) loadedRegionFingerprints.push(JSON.stringify(predicate)) } - - expectSetEqual(difference(requested, covered), new Set()) } } function runCoverageTraceWithKnownFailures( trace: ReadonlyArray, - createSubject = createDeduplicatedCoverageSubject, ): void { try { - runCoverageTrace(trace, createSubject) + runCoverageTrace(trace) } catch (error) { - if (createSubject !== createDeduplicatedCoverageSubject) throw error if ( error instanceof CoveredDemandRefetchedError && (isKnownUnionCompositionRefetch(error) || @@ -594,36 +548,50 @@ function windowPositions(request: WindowRequest): Set { return new Set(Array.from({ length }, (_, index) => request.offset + index)) } -function loadedWindowCovers( - requested: WindowRequest, - loaded: WindowRequest, +type WindowCoverageDescriptor = { + request: WindowRequest + whereFingerprint: string + orderFingerprint: string | undefined + matching: Set +} + +function describeWindowCoverage( + request: WindowRequest, +): WindowCoverageDescriptor { + const options = toWindowOptions(request) + return { + request, + whereFingerprint: JSON.stringify(options.where), + orderFingerprint: options.orderBy + ? JSON.stringify(options.orderBy) + : undefined, + matching: matchingValues(options.where), + } +} + +function describedWindowCovers( + requested: WindowCoverageDescriptor, + loaded: WindowCoverageDescriptor, ): boolean { - const requestedOptions = toWindowOptions(requested) - const loadedOptions = toWindowOptions(loaded) - // An unlimited load that starts at zero has every row in its predicate - // region. It can therefore cover any narrower predicate and let local query - // processing impose the requested order and window. if ( - loaded.limit === undefined && - loaded.offset === 0 && - isSubset( - matchingValues(requestedOptions.where), - matchingValues(loadedOptions.where), - ) + loaded.request.limit === undefined && + loaded.request.offset === 0 && + isSubset(requested.matching, loaded.matching) ) { return true } - if ( - JSON.stringify(requestedOptions.where) !== - JSON.stringify(loadedOptions.where) - ) { - return false - } - if (!requestedOptions.orderBy?.length) return true - if (!loadedOptions.orderBy?.length) return false - return ( - JSON.stringify(requestedOptions.orderBy) === - JSON.stringify(loadedOptions.orderBy) + if (requested.whereFingerprint !== loaded.whereFingerprint) return false + if (requested.orderFingerprint === undefined) return true + return requested.orderFingerprint === loaded.orderFingerprint +} + +function loadedWindowCovers( + requested: WindowRequest, + loaded: WindowRequest, +): boolean { + return describedWindowCovers( + describeWindowCoverage(requested), + describeWindowCoverage(loaded), ) } @@ -675,21 +643,27 @@ function isKnownUnlimitedOffsetDeduplication( function isKnownOffsetTruncatedUnlimitedDeduplication( error: UncoveredWindowDeduplicatedError, ): boolean { - const requestedOptions = toWindowOptions(error.requested) + const unlimitedLoads = error.loadedRegions.filter( + ({ request }) => request.limit === undefined, + ) + if (!unlimitedLoads.some(({ request }) => request.offset > 0)) return false - return error.loadedRegions.some(({ request: loaded }) => { - if (loaded.limit !== undefined || loaded.offset === 0) return false - const loadedOptions = toWindowOptions(loaded) - const priorLoads = error.loadedRegions.map(({ request }) => request) - return ( - isSubset( - matchingValues(requestedOptions.where), - matchingValues(loadedOptions.where), - ) && - countWindowLoads(priorLoads) === priorLoads.length && - countWindowLoads([...priorLoads, error.requested]) === priorLoads.length - ) - }) + // The known defect stores unlimited predicate coverage without its offset or + // ordering. Model that loss directly instead of replaying production dedupe. + if (unlimitedLoads.some(({ request }) => request.where === undefined)) { + return true + } + if (error.requested.where === undefined) return false + + const incorrectlyTrackedValues = unionSets( + unlimitedLoads.map(({ request }) => + matchingValues(toWindowOptions(request).where), + ), + ) + return isSubset( + matchingValues(toWindowOptions(error.requested).where), + incorrectlyTrackedValues, + ) } function isKnownCoveredWindowRefetch( @@ -735,11 +709,7 @@ function isKnownIndividuallyCoveredWindowRefetch( ) if (!coveredByOneRegion) return false - const replay = [ - ...error.loadedRegions.map(({ request }) => request), - error.requested, - ] - return countWindowLoads(replay) === replay.length + return true } const createWindowKeyBlindSubject: CoverageSubjectFactory = (recordLoad) => { @@ -764,6 +734,7 @@ function runWindowCoverageTrace( const loadedRegions: Array<{ request: WindowRequest positions: Set + coverage: WindowCoverageDescriptor }> = [] const loads: Array = [] const subject = createSubject((options) => { @@ -773,8 +744,9 @@ function runWindowCoverageTrace( for (const [checkpoint, request] of trace.entries()) { const requested = windowPositions(request) - const compatibleRegions = loadedRegions.filter(({ request: loaded }) => - loadedWindowCovers(request, loaded), + const requestedCoverage = describeWindowCoverage(request) + const compatibleRegions = loadedRegions.filter(({ coverage }) => + describedWindowCovers(requestedCoverage, coverage), ) const covered = new Set( compatibleRegions.flatMap(({ positions }) => [...positions]), @@ -811,20 +783,21 @@ function runWindowCoverageTrace( ) } for (const position of requested) covered.add(position) - loadedRegions.push({ request: { ...request }, positions: requested }) + loadedRegions.push({ + request: { ...request }, + positions: requested, + coverage: requestedCoverage, + }) } - expectSetEqual(difference(requested, covered), new Set()) } } function runWindowCoverageTraceWithKnownFailures( trace: ReadonlyArray, - createSubject = createDeduplicatedCoverageSubject, ): void { try { - runWindowCoverageTrace(trace, createSubject) + runWindowCoverageTrace(trace) } catch (error) { - if (createSubject !== createDeduplicatedCoverageSubject) throw error if ( error instanceof UncoveredWindowDeduplicatedError && (isKnownCompareOptionsDeduplication(error) || @@ -871,6 +844,14 @@ function expectDistinctWhereStartsDistinctLimitedWindowLoads( expect(countWindowLoads(predicates.map(createRequest))).toBe(2) } +function predicateDepth(predicate: PredicateSpec): number { + if (predicate.kind === `and` || predicate.kind === `or`) { + return 1 + Math.max(...predicate.operands.map(predicateDepth)) + } + if (predicate.kind === `not`) return 1 + predicateDepth(predicate.operand) + return 1 +} + async function runAsyncScenario( scenario: AsyncScenario, createSubject: CoverageSubjectFactory = createDeduplicatedCoverageSubject, @@ -1304,6 +1285,59 @@ describe(`loadSubset coverage oracle`, () => { ).toBe(true) }) + it(`generates nested boolean predicates`, () => { + const predicates = fc.sample(predicateSpecArbitrary, { + seed: 1751, + numRuns: 500, + }) + + expect(predicates.some((predicate) => predicateDepth(predicate) >= 3)).toBe( + true, + ) + }) + + it(`generates rejected requests shared by a covered waiter`, () => { + const scenarios = fc.sample(asyncScenarioArbitrary, { + seed: 1752, + numRuns: 500, + }) + + expect( + scenarios.some( + (scenario) => + scenario.firstOutcome === `reject` && + scenario.second.every((value) => scenario.first.includes(value)), + ), + ).toBe(true) + }) + + it(`rejects unrelated offset loss from the truncated-unlimited classifier`, () => { + expect( + isKnownOffsetTruncatedUnlimitedDeduplication( + new UncoveredWindowDeduplicatedError( + 1, + { + where: { kind: `eq`, value: 1 }, + direction: `asc`, + offset: 0, + limit: 1, + }, + [ + { + request: { + where: { kind: `eq`, value: 2 }, + direction: `asc`, + offset: 1, + limit: undefined, + }, + positions: new Set([1, 2]), + }, + ], + ), + ), + ).toBe(false) + }) + it(`keeps empty predicates out of the distinct-window corpus`, () => { expect( isDistinctNonEmptyWindowWherePair([ @@ -1561,7 +1595,7 @@ describe(`loadSubset coverage oracle`, () => { ], } expect(() => - runCoverageTraceWithKnownFailures( + runCoverageTrace( [predicate, predicate], createAlwaysLoadingCoverageSubject, ), @@ -1585,10 +1619,7 @@ describe(`loadSubset coverage oracle`, () => { } expect(() => - runCoverageTraceWithKnownFailures( - [covering, covered], - createAlwaysLoadingCoverageSubject, - ), + runCoverageTrace([covering, covered], createAlwaysLoadingCoverageSubject), ).toThrow() }) @@ -1660,7 +1691,7 @@ describe(`loadSubset coverage oracle`, () => { limit: 2, } expect(() => - runWindowCoverageTraceWithKnownFailures( + runWindowCoverageTrace( [first, { direction: `asc`, offset: 2, limit: 2 }, first], createAlwaysLoadingCoverageSubject, ), diff --git a/packages/db/tests/query/pagination-oracle.property.test.ts b/packages/db/tests/query/pagination-oracle.property.test.ts index 3a5b086706..cdef8e2ac5 100644 --- a/packages/db/tests/query/pagination-oracle.property.test.ts +++ b/packages/db/tests/query/pagination-oracle.property.test.ts @@ -6,14 +6,13 @@ 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 { TraceAssertionError } from '../trace-runner.js' import { - flushPromises, - mockSyncCollectionOptions, oracleRandomParameters, readOracleRunConfig, -} from '../utils.js' -import type { BasicExpression } from '../../src/query/ir.js' +} from '../oracle-config.js' +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' type PageRow = { @@ -39,6 +38,16 @@ type MultiOrderScenario = { limit: number } +type NullableCursorRow = { + id: number + rank: number | null +} + +type NullableCursorScenario = { + rank: number + direction: `asc` | `desc` +} + type PaginationWindow = { offset: number limit: number @@ -90,6 +99,15 @@ class PendingMutationTraceAssertionError extends TraceAssertionError { } } +class PendingHistoryTraceAssertionError extends TraceAssertionError { + constructor( + cause: unknown, + readonly deliveredRows: ReadonlyArray, + ) { + super(0, cause) + } +} + type PendingHistoryScenario = { ranks: ReadonlyArray direction: `asc` | `desc` @@ -100,11 +118,6 @@ type PendingHistoryScenario = { secondRank: number } -type PendingHistoryObservation = { - rows: Array - modeledDeliveredRows: Array -} - const scenarioArbitrary: fc.Arbitrary = fc.record({ ranks: fc.array(fc.integer({ min: -2, max: 2 }), { minLength: 1, @@ -296,6 +309,12 @@ const multiOrderScenarioArbitrary: fc.Arbitrary = fc limit: Math.min(requestedLimit, rows.length), })) +const nullableCursorScenarioArbitrary: fc.Arbitrary = + fc.record({ + rank: fc.integer({ min: -2, max: 2 }), + direction: fc.constantFrom(`asc` as const, `desc` as const), + }) + const { multiplier, replaySeed } = readOracleRunConfig() const orderedScenarioRuns = 12 * multiplier const transitionScenarioRuns = 8 * multiplier @@ -319,62 +338,20 @@ function referenceWindowRows( direction: `asc` | `desc`, window: PaginationWindow, ): Array { - const directionFactor = direction === `asc` ? 1 : -1 return [...rows] .sort( (left, right) => - (left.rank - right.rank) * directionFactor || left.id - right.id, + (left.rank - right.rank) * (direction === `asc` ? 1 : -1) || + left.id - right.id, ) .slice(window.offset, window.offset + window.limit) .map((row) => ({ ...row })) } -function readReference(expression: BasicExpression, row: PageRow): unknown { - if (expression.type === `val`) return expression.value - if (expression.type === `ref`) { - let value: unknown = row - for (const segment of expression.path) { - if (typeof value !== `object` || value === null) return undefined - value = (value as Record)[segment] - } - return value - } - - const args = expression.args.map((argument) => readReference(argument, row)) - switch (expression.name) { - case `and`: - return args.every(Boolean) - case `or`: - return args.some(Boolean) - case `eq`: - return args[0] === args[1] - case `gt`: - return compareReferenceValues(args[0], args[1]) > 0 - case `gte`: - return compareReferenceValues(args[0], args[1]) >= 0 - case `lt`: - return compareReferenceValues(args[0], args[1]) < 0 - case `lte`: - return compareReferenceValues(args[0], args[1]) <= 0 - default: - throw new Error(`unsupported reference expression: ${expression.name}`) - } -} - -function compareReferenceValues(left: unknown, right: unknown): number { - if (typeof left === `number` && typeof right === `number`) { - return left === right ? 0 : left < right ? -1 : 1 - } - if (typeof left === `string` && typeof right === `string`) { - return left === right ? 0 : left < right ? -1 : 1 - } - throw new Error(`cursor comparison requires like-typed numbers or strings`) -} - -function rowsForLoadSubset( - rows: ReadonlyArray, +function rowsForLoadSubset( + rows: ReadonlyArray, options: LoadSubsetOptions, -): Array { +): Array { if (!options.cursor) { const start = options.offset ?? 0 const end = @@ -383,14 +360,14 @@ function rowsForLoadSubset( } const current = rows.filter((row) => - Boolean(readReference(options.cursor!.whereCurrent, row)), + Boolean(evaluateReferenceExpression(options.cursor!.whereCurrent, row)), ) const from = rows.filter((row) => - Boolean(readReference(options.cursor!.whereFrom, row)), + Boolean(evaluateReferenceExpression(options.cursor!.whereFrom, row)), ) const limitedFrom = options.limit === undefined ? from : from.slice(0, options.limit) - const requested = new Map() + const requested = new Map() for (const row of [...current, ...limitedFrom]) requested.set(row.id, row) return [...requested.values()] } @@ -595,6 +572,119 @@ async function runMultiOrderScenarioWithKnownFailures( } } +async function runNullableCursorScenario( + scenario: NullableCursorScenario, +): Promise { + const rows: Array = [ + { id: 1, rank: null }, + { id: 2, rank: scenario.rank }, + ] + const orderedRows = [...rows].sort( + (left, right) => + compareNullableNumber(left.rank, right.rank, { + direction: scenario.direction, + nulls: `first`, + }) || left.id - right.id, + ) + const pending: Array = [] + let begin!: () => void + let write!: (message: { type: `insert`; value: NullableCursorRow }) => void + let commit!: () => void + const source = createCollection({ + id: `pagination-nullable-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 deferred.promise + }, + } + }, + }, + }) + const live = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank, { + direction: scenario.direction, + nulls: `first`, + }) + .orderBy(({ row }) => row.id, `asc`) + .limit(1), + ) + + 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 } }) + } + commit() + request.settled = true + request.deferred.resolve() + await preload + + try { + expect(Array.from(live.values(), ({ id }) => id)).toEqual([1]) + } catch (error) { + throw new TraceAssertionError(0, error) + } + } finally { + for (const request of pending) request.deferred.resolve() + live.cleanup() + source.cleanup() + } +} + +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 { @@ -717,10 +807,14 @@ type PageRowDifference = { expected: Array } -function readPageRowDifference(error: unknown): PageRowDifference | undefined { +function readPageRowDifference( + error: unknown, + acceptsCheckpoint: (checkpoint: number) => boolean = (checkpoint) => + checkpoint >= 1, +): PageRowDifference | undefined { if ( !(error instanceof TraceAssertionError) || - error.checkpoint < 1 || + !acceptsCheckpoint(error.checkpoint) || typeof error.cause !== `object` || error.cause === null || !(`actual` in error.cause) || @@ -742,24 +836,7 @@ function readPageRowDifferenceAtCheckpoint( error: unknown, checkpoint: number, ): PageRowDifference | undefined { - if ( - !(error instanceof TraceAssertionError) || - error.checkpoint !== 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, - actual: error.cause.actual, - expected: error.cause.expected, - } + return readPageRowDifference(error, (value) => value === checkpoint) } function sameRows( @@ -798,7 +875,6 @@ function replayOrderedSubscriptionWindow( limit: scenario.initialWindow.offset + scenario.initialWindow.limit, }).map((row) => [row.id, row]), ) - const sentIds = new Set(sentRows.keys()) let biggest = referenceWindowRows( [...sentRows.values()], scenario.direction, @@ -810,15 +886,20 @@ function replayOrderedSubscriptionWindow( referenceWindowRows([...sentRows.values()], scenario.direction, window) const refill = () => { - while (biggest !== undefined && currentResult().length < window.limit) { - const needed = window.limit - currentResult().length - const orderedRows = referenceWindowRows( - [...rows.values()], - scenario.direction, - { offset: 0, limit: rows.size }, - ) + 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 && !sentIds.has(row.id), + (row) => row.rank === biggest!.rank && !sentRows.has(row.id), ) const afterCursor = orderedRows .filter( @@ -827,14 +908,13 @@ function replayOrderedSubscriptionWindow( { id: 0, rank: row.rank }, { id: 0, rank: biggest!.rank }, scenario.direction, - ) > 0 && !sentIds.has(row.id), + ) > 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) { - sentIds.add(row.id) sentRows.set(row.id, { ...row }) if (comparePageRows(biggest, row, scenario.direction) < 0) { biggest = row @@ -851,7 +931,6 @@ function replayOrderedSubscriptionWindow( if (previous?.rank !== action.rank) { const row = { id: action.id, rank: action.rank } rows.set(action.id, row) - sentIds.add(row.id) sentRows.set(row.id, { ...row }) if ( biggest === undefined || @@ -862,7 +941,7 @@ function replayOrderedSubscriptionWindow( } } else { rows.delete(action.id) - if (sentIds.delete(action.id)) sentRows.delete(action.id) + sentRows.delete(action.id) } refill() } @@ -1329,10 +1408,30 @@ async function runPendingMutationScenario( finalLimit += 1 const retry = live.utils.setWindow({ offset: 0, limit: finalLimit }) - if (retry instanceof Promise) outstanding.push(retry) - expect(pending.length).toBeLessThanOrEqual(2) - if (pending.length === 2) await settlePending() - if (retry instanceof Promise) await retry + let retrySettled = retry === true + const observedRetry = + retry instanceof Promise + ? retry.then( + () => { + retrySettled = true + }, + (error: unknown) => { + retrySettled = true + throw error + }, + ) + : undefined + if (pending.length === 2) { + await settlePending() + } else { + await Promise.resolve() + await Promise.resolve() + expect(retrySettled).toBe(true) + } + if (observedRetry) { + outstanding.push(observedRetry) + await observedRetry + } } try { @@ -1655,20 +1754,16 @@ async function runPendingHistoryScenario( scenario.direction, { offset: 0, limit: scenario.wideLimit }, ) - const modeledDeliveredRows = referenceWindowRows( - [...rows.values()].filter(({ id }) => deliveredIds.has(id)), - scenario.direction, - { offset: 0, limit: scenario.wideLimit }, - ) - expect({ - rows: actual, - modeledDeliveredRows, - } satisfies PendingHistoryObservation).toEqual({ - rows: expected, - modeledDeliveredRows, - } satisfies PendingHistoryObservation) + expect(actual).toEqual(expected) } catch (error) { - throw new TraceAssertionError(0, error) + throw new PendingHistoryTraceAssertionError( + error, + referenceWindowRows( + [...rows.values()].filter(({ id }) => deliveredIds.has(id)), + scenario.direction, + { offset: 0, limit: scenario.wideLimit }, + ), + ) } } finally { for (const request of pending) request.deferred.resolve() @@ -1702,48 +1797,25 @@ function pendingHistoryRows( return rows } -function isPendingHistoryObservation( - value: unknown, -): value is PendingHistoryObservation { - return ( - typeof value === `object` && - value !== null && - `rows` in value && - isPageRowArray(value.rows) && - `modeledDeliveredRows` in value && - isPageRowArray(value.modeledDeliveredRows) - ) -} - function isKnownLatePendingHistoryUnderfill( scenario: PendingHistoryScenario, 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) || - !isPendingHistoryObservation(error.cause.actual) || - !isPendingHistoryObservation(error.cause.expected) - ) { + if (!(error instanceof PendingHistoryTraceAssertionError)) { return false } - const actual = error.cause.actual - const expected = error.cause.expected + const difference = readPageRowDifferenceAtCheckpoint(error, 0) + if (!difference) return false const authoritative = referenceWindowRows( [...pendingHistoryRows(scenario).values()], scenario.direction, { offset: 0, limit: scenario.wideLimit }, ) return ( - sameRows(expected.rows, authoritative) && - sameRows(actual.modeledDeliveredRows, expected.modeledDeliveredRows) && - !sameRows(actual.modeledDeliveredRows, authoritative) && - sameRows(actual.rows, actual.modeledDeliveredRows) + sameRows(difference.expected, authoritative) && + !sameRows(error.deliveredRows, authoritative) && + sameRows(difference.actual, error.deliveredRows) ) } @@ -2016,6 +2088,31 @@ describe(`pagination recomputation oracle`, () => { runMultiOrderScenarioWithKnownFailures, ) + fcTest.prop([nullableCursorScenarioArbitrary], { + numRuns: transitionScenarioRuns, + seed: 1665, + })( + `matches nullable cursor ordering while an async response is pending for a fixed seed`, + runNullableCursorScenarioWithKnownFailures, + ) + + fcTest.prop( + [nullableCursorScenarioArbitrary], + oracleRandomParameters(transitionScenarioRuns, replaySeed), + )( + `matches nullable cursor ordering while an async response is pending for a random or replayed seed`, + runNullableCursorScenarioWithKnownFailures, + ) + + 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: [ @@ -2161,6 +2258,36 @@ describe(`pagination recomputation oracle`, () => { runPendingMutationScenarioWithKnownFailures, ) + it.each( + ([`insert`, `update`, `delete`] as const).flatMap((mutationKind) => + ([`resolve`, `reject`] as const).flatMap((responseOutcome) => + ([`before-response`, `after-response`] as const).map( + (timing) => [mutationKind, responseOutcome, timing] as const, + ), + ), + ), + )( + `covers pending %s with a %s response %s deterministically`, + async (mutationKind, responseOutcome, timing) => { + const mutation: PendingMutation = + mutationKind === `insert` + ? { type: `insert`, row: { id: 5, rank: -1 } } + : mutationKind === `update` + ? { type: `update`, row: { id: 2, rank: -1 } } + : { type: `delete`, id: 2 } + await runPendingMutationScenarioWithKnownFailures( + { + ranks: [0, 1, 2, 3], + direction: `asc`, + limit: 2, + mutation, + responseOutcome, + }, + timing, + ) + }, + ) + fcTest.prop( [pendingMutationScenarioArbitrary, responseTimingArbitrary], oracleRandomParameters(transitionScenarioRuns, replaySeed), diff --git a/packages/db/tests/reference-expression.ts b/packages/db/tests/reference-expression.ts new file mode 100644 index 0000000000..dff7237ccb --- /dev/null +++ b/packages/db/tests/reference-expression.ts @@ -0,0 +1,59 @@ +import type { BasicExpression } from '../src/query/ir.js' + +function compareReferenceValues(left: unknown, right: unknown): number { + if (left === right) return 0 + // Query order cursors use nulls-first ordering. Treat null as the least + // value so adapters can evaluate the same cursor boundary independently. + if (left === null) return -1 + if (right === null) return 1 + if (typeof left === `number` && typeof right === `number`) { + return left < right ? -1 : 1 + } + if (typeof left === `string` && typeof right === `string`) { + return left < right ? -1 : 1 + } + throw new Error(`reference comparison requires like-typed numbers or strings`) +} + +/** Evaluate the BasicExpression subset used by test-only reference models. */ +export function evaluateReferenceExpression( + expression: BasicExpression, + row: object, +): unknown { + if (expression.type === `val`) return expression.value + if (expression.type === `ref`) { + let value: unknown = row + for (const segment of expression.path) { + if (typeof value !== `object` || value === null) return undefined + value = (value as Record)[segment] + } + return value + } + + const args = expression.args.map((argument) => + evaluateReferenceExpression(argument, row), + ) + switch (expression.name) { + case `and`: + return args.every(Boolean) + case `or`: + return args.some(Boolean) + case `not`: + return !args[0] + case `eq`: + return args[0] === args[1] + case `gt`: + return compareReferenceValues(args[0], args[1]) > 0 + case `gte`: + return compareReferenceValues(args[0], args[1]) >= 0 + case `lt`: + return compareReferenceValues(args[0], args[1]) < 0 + case `lte`: + return compareReferenceValues(args[0], args[1]) <= 0 + case `in`: + if (!Array.isArray(args[1])) throw new Error(`IN requires an array`) + return args[1].includes(args[0]) + default: + throw new Error(`unsupported reference expression: ${expression.name}`) + } +} diff --git a/packages/db/tests/utils.test.ts b/packages/db/tests/utils.test.ts index 2a65471c8b..0cfc2b27a2 100644 --- a/packages/db/tests/utils.test.ts +++ b/packages/db/tests/utils.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { Temporal } from 'temporal-polyfill' import { deepEquals } from '../src/utils' import { isPromiseLike } from '../src/utils/type-guards' -import { oracleRandomParameters, readOracleRunConfig } from './utils' +import { oracleRandomParameters, readOracleRunConfig } from './oracle-config' describe(`oracle run configuration`, () => { it(`reads the multiplier and replay seed from an explicit environment`, () => { @@ -24,7 +24,9 @@ describe(`oracle run configuration`, () => { it.each([ [{ TANSTACK_DB_ORACLE_RUNS_MULTIPLIER: `0` }, `positive integer`], [{ TANSTACK_DB_ORACLE_RUNS_MULTIPLIER: `1.5` }, `positive integer`], + [{ TANSTACK_DB_ORACLE_RUNS_MULTIPLIER: ` ` }, `positive integer`], [{ TANSTACK_DB_ORACLE_SEED: `1.5` }, `must be an integer`], + [{ TANSTACK_DB_ORACLE_SEED: ` ` }, `must be an integer`], ] satisfies ReadonlyArray, string]>)( `rejects invalid environment values`, (environment, message) => { diff --git a/packages/db/tests/utils.ts b/packages/db/tests/utils.ts index b31408d0b4..d025634a51 100644 --- a/packages/db/tests/utils.ts +++ b/packages/db/tests/utils.ts @@ -10,36 +10,6 @@ import type { import type { IndexConstructor } from '../src/indexes/base-index' import type { WithVirtualProps } from '../src/virtual-props.js' -type OracleEnvironment = Record - -export function readOracleRunConfig( - environment: OracleEnvironment = process.env, -): { multiplier: number; replaySeed: number | undefined } { - const multiplierValue = environment.TANSTACK_DB_ORACLE_RUNS_MULTIPLIER ?? `1` - const multiplier = Number(multiplierValue) - if (!Number.isSafeInteger(multiplier) || multiplier < 1) { - throw new Error( - `TANSTACK_DB_ORACLE_RUNS_MULTIPLIER must be a positive integer`, - ) - } - - const seedValue = environment.TANSTACK_DB_ORACLE_SEED - if (seedValue === undefined) return { multiplier, replaySeed: undefined } - - const replaySeed = Number(seedValue) - if (!Number.isSafeInteger(replaySeed)) { - throw new Error(`TANSTACK_DB_ORACLE_SEED must be an integer`) - } - return { multiplier, replaySeed } -} - -export function oracleRandomParameters( - numRuns: number, - replaySeed: number | undefined, -): { numRuns: number; seed?: number } { - return replaySeed === undefined ? { numRuns } : { numRuns, seed: replaySeed } -} - export type OutputWithVirtual< T extends object, TKey extends string | number = string | number, From 68f5b5759ec86e6d99e6ef740219d325dde6df68 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 21 Aug 2026 06:40:53 -0600 Subject: [PATCH 15/18] fix(db): harden initial sync error lifecycles --- .changeset/propagate-initial-query-errors.md | 6 +- docs/guides/collection-options-creator.md | 13 +- docs/guides/error-handling.md | 2 +- .../db/skills/db-core/custom-adapter/SKILL.md | 21 +- packages/db/src/collection/index.ts | 2 +- packages/db/src/collection/lifecycle.ts | 22 +- packages/db/src/collection/sync.ts | 59 ++++- .../query/live/collection-config-builder.ts | 42 +++- packages/db/src/types.ts | 7 +- packages/db/tests/collection-errors.test.ts | 174 +++++++++++++ .../query/includes-temporal-oracle.test.ts | 6 +- .../electric-db-collection/src/electric.ts | 14 +- .../tests/electric.test.ts | 18 ++ .../powersync-db-collection/src/powersync.ts | 11 +- .../tests/load-hooks.test.ts | 16 ++ packages/query-db-collection/src/query.ts | 6 +- .../load-subset-lifecycle-oracle.test.ts | 229 +++++++++++++++++- packages/rxdb-db-collection/src/rxdb.ts | 8 +- .../rxdb-db-collection/tests/rxdb.test.ts | 27 ++- .../trailbase-db-collection/src/trailbase.ts | 49 ++-- .../tests/trailbase.test.ts | 45 +--- 21 files changed, 677 insertions(+), 100 deletions(-) diff --git a/.changeset/propagate-initial-query-errors.md b/.changeset/propagate-initial-query-errors.md index 4bb081376d..b5143fecbc 100644 --- a/.changeset/propagate-initial-query-errors.md +++ b/.changeset/propagate-initial-query-errors.md @@ -1,6 +1,10 @@ --- '@tanstack/db': patch +'@tanstack/electric-db-collection': patch +'@tanstack/powersync-db-collection': patch '@tanstack/query-db-collection': patch +'@tanstack/rxdb-db-collection': patch +'@tanstack/trailbase-db-collection': patch --- -Propagate initial query sync failures to collection status and readiness promises while preserving a ready cached snapshot on later refetch failures. Prevent rejected deduplicated subset requests from creating detached promise rejections. +Propagate initial query sync failures through dependent live queries and readiness promises, including recovery and late subscribers, while preserving a ready cached snapshot on later refetch failures. Let sync adapters pass the original failure to `markError(error)` so readiness promises reject with that cause. Isolate adapter callbacks by sync session, preserve synchronous startup errors, and prevent rejected deduplicated subset requests from creating detached promise rejections. diff --git a/docs/guides/collection-options-creator.md b/docs/guides/collection-options-creator.md index de8cd2c120..ed8ac88e6d 100644 --- a/docs/guides/collection-options-creator.md +++ b/docs/guides/collection-options-creator.md @@ -77,6 +77,7 @@ const sync: SyncConfig['sync'] = (params) => { // 1. Initialize connection to your sync engine const connection = initializeConnection(config) + const initialSyncAbort = new AbortController() // 2. Set up real-time subscription FIRST (prevents race conditions) const eventBuffer: Array = [] @@ -110,7 +111,7 @@ const sync: SyncConfig['sync'] = (params) => { // 3. Perform initial data fetch async function initialSync() { try { - const data = await fetchInitialData() + const data = await fetchInitialData({ signal: initialSyncAbort.signal }) begin() // Start a transaction @@ -138,9 +139,12 @@ const sync: SyncConfig['sync'] = (params) => { // A complete initial snapshot is now available. markReady() } catch (error) { + if (initialSyncAbort.signal.aborted) return console.error('Initial sync failed:', error) // No usable initial snapshot exists. - markError() + // Only initial startup owns collection readiness. A later refetch + // failure must keep the last ready snapshot usable. + if (collection.status === 'loading') markError(error) } } @@ -148,6 +152,7 @@ const sync: SyncConfig['sync'] = (params) => { // 4. Return cleanup function return () => { + initialSyncAbort.abort() connection.close() // Clean up any timers, intervals, or other resources } @@ -164,7 +169,7 @@ The sync process follows this lifecycle: 2. **write()** - Add changes to the pending transaction (buffered until commit) 3. **commit()** - Apply all changes atomically to the collection state 4. **markReady()** - Signal that a usable initial or recovered snapshot exists -5. **markError()** - Signal that initial sync failed before producing a usable snapshot +5. **markError(error?)** - Signal that initial sync failed before producing a usable snapshot; pass the cause so readiness waits reject with it **Race Condition Prevention:** Many sync engines start real-time subscriptions before the initial sync completes. Your implementation MUST deduplicate events that arrive via subscription that represent the same data as the initial sync. Consider: @@ -901,7 +906,7 @@ const wrappedOnInsert = async (params) => { ## Best Practices -1. **Report initial sync status** - Call `markReady()` after a usable snapshot, or `markError()` if initial sync fails +1. **Report initial sync status** - Call `markReady()` after a usable snapshot, or `markError(error)` if initial sync fails 2. **Recover explicitly** - After an error, call `markReady()` only when a later sync has produced a usable snapshot 3. **Clean up resources** - Return a cleanup function from sync to prevent memory leaks 4. **Batch operations** - Use begin/commit to batch multiple changes for better performance diff --git a/docs/guides/error-handling.md b/docs/guides/error-handling.md index ac8e3a7d9c..0683eec7d4 100644 --- a/docs/guides/error-handling.md +++ b/docs/guides/error-handling.md @@ -448,7 +448,7 @@ const todoCollection = createCollection( When sync errors occur: - Error is logged to console: `[QueryCollection] Error observing query...` - An initial failure marks the collection as `error` because no usable snapshot exists -- Readiness waits such as `preload()` and `toArrayWhenReady()` reject while the collection is in that initial error state +- Readiness waits such as `preload()` and `toArrayWhenReady()` reject with the cause passed to `markError(error)` while the collection is in that initial error state - A later refetch failure keeps the collection `ready` and preserves its cached data - Error tracking counters are updated (`lastError`, `errorCount`) - A later successful refetch recovers an initial `error` collection to `ready`; a new readiness wait then resolves normally diff --git a/packages/db/skills/db-core/custom-adapter/SKILL.md b/packages/db/skills/db-core/custom-adapter/SKILL.md index a7af91e17d..386b68f8ae 100644 --- a/packages/db/skills/db-core/custom-adapter/SKILL.md +++ b/packages/db/skills/db-core/custom-adapter/SKILL.md @@ -48,9 +48,10 @@ function myBackendCollectionOptions(config: { return { getKey: config.getKey, sync: { - sync: ({ begin, write, commit, markReady, markError }) => { + sync: ({ begin, write, commit, markReady, markError, collection }) => { let isInitialSyncComplete = false const bufferedEvents: Array> = [] + const initialSyncAbort = new AbortController() // 1. Subscribe to real-time events FIRST const unsubscribe = myWebSocket.subscribe(config.endpoint, (event) => { @@ -64,7 +65,7 @@ function myBackendCollectionOptions(config: { }) // 2. Fetch initial data - void fetch(config.endpoint) + void fetch(config.endpoint, { signal: initialSyncAbort.signal }) .then(async (res) => { const items = await res.json() begin() @@ -85,12 +86,16 @@ function myBackendCollectionOptions(config: { markReady() }) .catch((error) => { + if (initialSyncAbort.signal.aborted) return console.error('Initial sync failed:', error) - markError() + // Only initial startup owns collection readiness. A later refetch + // failure must keep the last ready snapshot usable. + if (collection.status === 'loading') markError(error) }) // 5. Return cleanup function return () => { + initialSyncAbort.abort() unsubscribe() } }, @@ -259,7 +264,7 @@ sync: ({ begin, write, commit, markReady, metadata }) => { }) stream.on('ready', () => markReady()) - stream.on('initial-error', () => markError()) + stream.on('initial-error', (error) => markError(error)) return () => stream.close() } ``` @@ -328,9 +333,11 @@ sync: ({ begin, write, commit, markReady }) => { `markReady()` transitions the collection to "ready" status. Without it, live queries never resolve and `useLiveSuspenseQuery` hangs forever in Suspense. -If initial sync fails before it produces a usable snapshot, call `markError()` -instead. This rejects readiness waits and moves dependent live queries to the -error state. A later successful sync can call `markReady()` to recover. +If initial sync fails before it produces a usable snapshot, call +`markError(error)` instead. This rejects readiness waits with the supplied cause +and moves dependent live queries to the error state. Calling `markError()` +without a cause remains supported and rejects with a generic collection-state +error. A later successful sync can call `markReady()` to recover. Source: docs/guides/collection-options-creator.md diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index 15c29d3265..7b61a13503 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -464,7 +464,7 @@ export class CollectionImpl< * // Safe to access collection.state now * }) */ - public onFirstReady(callback: () => void): void { + public onFirstReady(callback: () => void): () => void { return this._lifecycle.onFirstReady(callback) } diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index b0e407690c..661e8410d0 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -36,6 +36,7 @@ export class CollectionLifecycleManager< public hasReceivedFirstCommit = false public onFirstReadyCallbacks: Array<() => void> = [] private idleCallbackId: number | null = null + private syncError: unknown /** * Creates a new CollectionLifecycleManager instance @@ -135,6 +136,7 @@ export class CollectionLifecycleManager< this.validateStatusTransition(this.status, `ready`) // A successful initial sync or recovery establishes a ready snapshot. if (this.status === `loading` || this.status === `error`) { + this.syncError = undefined this.setStatus(`ready`, true) // Call any registered first ready callbacks (only on first time becoming ready) @@ -159,10 +161,17 @@ export class CollectionLifecycleManager< } /** Mark an asynchronous sync failure after sync has started. */ - public markError(): void { + public markError(error?: unknown): void { + this.validateStatusTransition(this.status, `error`) + this.syncError = error this.setStatus(`error`) } + /** Return the cause supplied by the current sync session, if any. */ + public getSyncError(): unknown { + return this.syncError + } + /** * Start the garbage collection timer * Called when the collection becomes inactive (no subscribers) @@ -248,6 +257,7 @@ export class CollectionLifecycleManager< CleanupQueue.getInstance().cancel(this) this.hasBeenReady = false + this.syncError = undefined // Call any pending onFirstReady callbacks before clearing them. // This ensures preload() promises resolve during cleanup instead of hanging. @@ -287,14 +297,20 @@ export class CollectionLifecycleManager< * Useful for preloading collections * @param callback Function to call when the collection first becomes ready */ - public onFirstReady(callback: () => void): void { + public onFirstReady(callback: () => void): () => void { // If already ready, call immediately if (this.hasBeenReady) { callback() - return + return () => {} } this.onFirstReadyCallbacks.push(callback) + return () => { + const index = this.onFirstReadyCallbacks.indexOf(callback) + if (index !== -1) { + this.onFirstReadyCallbacks.splice(index, 1) + } + } } public cleanup(): void { diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts index c50f13b7a6..ce8f5729ce 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -59,6 +59,7 @@ export class CollectionSyncManager< private syncStartDeferred = false private syncStartRequested = false private deferredLoadSubsets: Array = [] + private syncEpoch = 0 /** * Creates a new CollectionSyncManager instance @@ -103,6 +104,8 @@ export class CollectionSyncManager< return } + const syncEpoch = ++this.syncEpoch + const isCurrentSync = () => syncEpoch === this.syncEpoch this.lifecycle.setStatus(`loading`) try { @@ -110,6 +113,7 @@ export class CollectionSyncManager< this.config.sync.sync({ collection: this.collection, begin: (options?: { immediate?: boolean }) => { + if (!isCurrentSync()) return this.state.pendingSyncedTransactions.push({ committed: false, layoutChanged: false, @@ -126,6 +130,7 @@ export class CollectionSyncManager< TKey >, ) => { + if (!isCurrentSync()) return const pendingTransaction = this.state.pendingSyncedTransactions[ this.state.pendingSyncedTransactions.length - 1 @@ -215,6 +220,7 @@ export class CollectionSyncManager< } }, commit: () => { + if (!isCurrentSync()) return const pendingTransaction = this.state.pendingSyncedTransactions[ this.state.pendingSyncedTransactions.length - 1 @@ -231,12 +237,13 @@ export class CollectionSyncManager< this.state.commitPendingTransactions() }, markReady: () => { - this.lifecycle.markReady() + if (isCurrentSync()) this.lifecycle.markReady() }, - markError: () => { - this.lifecycle.markError() + markError: (error?: unknown) => { + if (isCurrentSync()) this.lifecycle.markError(error) }, truncate: () => { + if (!isCurrentSync()) return const pendingTransaction = this.state.pendingSyncedTransactions[ this.state.pendingSyncedTransactions.length - 1 @@ -271,7 +278,7 @@ export class CollectionSyncManager< deletes: new Set(this.state.optimisticDeletes), } }, - metadata: this.createSyncMetadataApi(), + metadata: this.createSyncMetadataApi(isCurrentSync), }), ) @@ -292,7 +299,7 @@ export class CollectionSyncManager< ) } } catch (error) { - this.lifecycle.setStatus(`error`) + this.lifecycle.markError(error) throw error } } @@ -365,10 +372,13 @@ export class CollectionSyncManager< return pendingTransaction } - private createSyncMetadataApi(): SyncMetadataApi { + private createSyncMetadataApi( + isCurrentSync: () => boolean, + ): SyncMetadataApi { return { row: { get: (key) => { + if (!isCurrentSync()) return undefined const pendingTransaction = this.state.pendingSyncedTransactions[ this.state.pendingSyncedTransactions.length - 1 @@ -385,6 +395,7 @@ export class CollectionSyncManager< return this.state.syncedMetadata.get(key) }, set: (key, metadata) => { + if (!isCurrentSync()) return const pendingTransaction = this.getActivePendingSyncTransaction() pendingTransaction.rowMetadataWrites.set(key, { type: `set`, @@ -392,6 +403,7 @@ export class CollectionSyncManager< }) }, delete: (key) => { + if (!isCurrentSync()) return const pendingTransaction = this.getActivePendingSyncTransaction() pendingTransaction.rowMetadataWrites.set(key, { type: `delete`, @@ -400,6 +412,7 @@ export class CollectionSyncManager< }, collection: { get: (key) => { + if (!isCurrentSync()) return undefined const pendingTransaction = this.state.pendingSyncedTransactions[ this.state.pendingSyncedTransactions.length - 1 @@ -414,6 +427,7 @@ export class CollectionSyncManager< return this.state.syncedCollectionMetadata.get(key) }, set: (key, value) => { + if (!isCurrentSync()) return const pendingTransaction = this.getActivePendingSyncTransaction() pendingTransaction.collectionMetadataWrites.set(key, { type: `set`, @@ -421,12 +435,14 @@ export class CollectionSyncManager< }) }, delete: (key) => { + if (!isCurrentSync()) return const pendingTransaction = this.getActivePendingSyncTransaction() pendingTransaction.collectionMetadataWrites.set(key, { type: `delete`, }) }, list: (prefix) => { + if (!isCurrentSync()) return [] const merged = new Map(this.state.syncedCollectionMetadata) const pendingTransaction = this.state.pendingSyncedTransactions[ @@ -482,29 +498,36 @@ export class CollectionSyncManager< } if (this.lifecycle.status === `error`) { - reject(new CollectionIsInErrorStateError()) + reject(this.getPreloadError()) return } let settled = false + let startingSync = false let unsubscribeError = () => {} + let unsubscribeReady = () => {} const resolveReady = () => { if (settled) return settled = true unsubscribeError() + unsubscribeReady() resolve() } const rejectError = (error: unknown) => { if (settled) return settled = true unsubscribeError() + unsubscribeReady() reject(error) } // Register callback BEFORE starting sync to avoid race condition - this.lifecycle.onFirstReady(resolveReady) + unsubscribeReady = this.lifecycle.onFirstReady(resolveReady) unsubscribeError = this.collection.on(`status:error`, () => { - rejectError(new CollectionIsInErrorStateError()) + if (startingSync) { + return + } + rejectError(this.getPreloadError()) }) // Start sync if collection hasn't started yet or was cleaned up @@ -512,11 +535,17 @@ export class CollectionSyncManager< this.lifecycle.status === `idle` || this.lifecycle.status === `cleaned-up` ) { + startingSync = true try { this.startSync() } catch (error) { rejectError(error) return + } finally { + startingSync = false + } + if (this.collection.status === `error`) { + rejectError(this.getPreloadError()) } } }) @@ -530,6 +559,13 @@ export class CollectionSyncManager< return attempt } + private getPreloadError(): unknown { + const syncError = this.lifecycle.getSyncError() + return syncError === undefined + ? new CollectionIsInErrorStateError() + : syncError + } + /** * Gets whether the collection is currently loading more data */ @@ -645,6 +681,9 @@ export class CollectionSyncManager< } public cleanup(): void { + // Invalidate callbacks retained by asynchronous work from this session + // before invoking adapter cleanup or allowing a new session to start. + this.syncEpoch++ try { if (this.syncCleanupFn) { this.syncCleanupFn() @@ -665,6 +704,8 @@ export class CollectionSyncManager< }) } this.preloadPromise = null + this.syncLoadSubsetFn = null + this.syncUnloadSubsetFn = null this.syncStartDeferred = false this.syncStartRequested = false const deferredLoadSubsets = this.deferredLoadSubsets diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 67a9edf502..9e13a65701 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -112,6 +112,8 @@ export class CollectionConfigBuilder< // Error state tracking private isInErrorState = false + private fatalQueryError = false + private readonly erroredSourceIds = new Set() // Reference to the live query collection for error state transitions public liveQueryCollection?: Collection @@ -628,6 +630,8 @@ export class CollectionConfigBuilder< this.liveQueryCollection = config.collection // Reset error state from any previous sync session so a restarted sync can become ready again. this.isInErrorState = false + this.fatalQueryError = false + this.erroredSourceIds.clear() // Store config and syncState as instance properties for the duration of this sync session this.currentSyncConfig = config @@ -686,6 +690,9 @@ export class CollectionConfigBuilder< this.currentSyncState = undefined this.maybeRunGraphFn = undefined this.currentWindow = undefined + this.isInErrorState = false + this.fatalQueryError = false + this.erroredSourceIds.clear() // Clear all pending graph runs to prevent memory leaks from in-flight transactions // that may flush after the sync session ends @@ -947,6 +954,7 @@ export class CollectionConfigBuilder< */ private handleSourceStatusChange( config: SyncMethods, + sourceId: string, collectionId: string, event: AllCollectionEvents[`status:change`], ) { @@ -954,7 +962,8 @@ export class CollectionConfigBuilder< // Handle error state - any source collection in error puts live query in error if (status === `error`) { - this.transitionToError( + this.erroredSourceIds.add(sourceId) + this.setErrorState( `Source collection '${collectionId}' entered error state`, ) return @@ -970,6 +979,18 @@ export class CollectionConfigBuilder< return } + if (status === `ready`) { + const recovered = this.erroredSourceIds.delete(sourceId) + if ( + recovered && + !this.fatalQueryError && + this.erroredSourceIds.size === 0 + ) { + this.isInErrorState = false + this.maybeRunGraphFn?.() + } + } + // Update ready status based on all source collections this.updateLiveQueryStatus(config) } @@ -1007,6 +1028,11 @@ export class CollectionConfigBuilder< * Transition the live query to error state */ private transitionToError(message: string) { + this.fatalQueryError = true + this.setErrorState(message) + } + + private setErrorState(message: string) { this.isInErrorState = true // Log error to console for debugging @@ -1065,10 +1091,22 @@ export class CollectionConfigBuilder< // Subscribe to status changes for status flow const statusUnsubscribe = collection.on(`status:change`, (event) => { - this.handleSourceStatusChange(config, collectionId, event) + this.handleSourceStatusChange(config, sourceId, collectionId, event) }) syncState.unsubscribeCallbacks.add(statusUnsubscribe) + // The source may have failed before this live query subscribed. Register + // the listener first, then reconcile that current state so no transition + // can be missed between observation and subscription. + if (collection.status === `error`) { + this.handleSourceStatusChange(config, sourceId, collectionId, { + type: `status:change`, + collection, + status: `error`, + previousStatus: `error`, + }) + } + const subscription = collectionSubscriber.subscribe() this.subscriptions[sourceId] = subscription diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index bf76163511..73f1115db5 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -346,8 +346,11 @@ export interface SyncConfig< commit: () => void /** Signal that a usable initial or recovered snapshot is available. */ markReady: () => void - /** Signal that initial sync failed before producing a usable snapshot. */ - markError: () => void + /** + * Signal that initial sync failed before producing a usable snapshot. + * When supplied, `error` is preserved as the rejection reason from `preload()`. + */ + markError: (error?: unknown) => void truncate: () => void metadata?: SyncMetadataApi }) => void | CleanupFn | SyncConfigRes diff --git a/packages/db/tests/collection-errors.test.ts b/packages/db/tests/collection-errors.test.ts index db95427750..e0d67963c5 100644 --- a/packages/db/tests/collection-errors.test.ts +++ b/packages/db/tests/collection-errors.test.ts @@ -2,9 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createCollection } from '../src/collection/index.js' import { CollectionInErrorStateError, + CollectionIsInErrorStateError, InvalidCollectionStatusTransitionError, SyncCleanupError, } from '../src/errors' +import type { SyncConfig } from '../src/types' describe(`Collection Error Handling`, () => { let originalQueueMicrotask: typeof queueMicrotask @@ -246,7 +248,179 @@ describe(`Collection Error Handling`, () => { }) }) + describe(`Sync Session Isolation`, () => { + it(`preserves an asynchronous sync error and removes its first-ready waiter`, async () => { + let markError: (error?: unknown) => void = () => { + throw new Error(`Sync has not started`) + } + const collection = createCollection<{ id: string }>({ + id: `rejected-preload-waiter`, + getKey: (item) => item.id, + startSync: false, + sync: { + sync: (sync) => { + markError = sync.markError + }, + }, + }) + + const preload = collection.preload() + const stateWhenReady = collection.stateWhenReady() + const arrayWhenReady = collection.toArrayWhenReady() + expect(collection._lifecycle.onFirstReadyCallbacks).toHaveLength(1) + + const syncError = new Error(`Asynchronous sync failed exactly`) + markError(syncError) + await expect(preload).rejects.toBe(syncError) + await expect(stateWhenReady).rejects.toBe(syncError) + await expect(arrayWhenReady).rejects.toBe(syncError) + await expect(collection.preload()).rejects.toBe(syncError) + expect(collection._lifecycle.onFirstReadyCallbacks).toHaveLength(0) + + await collection.cleanup() + }) + + it(`uses the generic state error when asynchronous sync supplies no cause`, async () => { + let markError: (error?: unknown) => void = () => { + throw new Error(`Sync has not started`) + } + const collection = createCollection<{ id: string }>({ + id: `generic-asynchronous-sync-error`, + getKey: (item) => item.id, + startSync: false, + sync: { + sync: (sync) => { + markError = sync.markError + }, + }, + }) + + const preload = collection.preload() + markError() + + await expect(preload).rejects.toBeInstanceOf( + CollectionIsInErrorStateError, + ) + await collection.cleanup() + }) + + it(`ignores an error callback retained after cleanup`, async () => { + let markError: () => void = () => { + throw new Error(`Sync has not started`) + } + const collection = createCollection<{ id: string }>({ + id: `stale-error-after-cleanup`, + getKey: (item) => item.id, + startSync: false, + sync: { + sync: (sync) => { + markError = sync.markError + }, + }, + }) + const preload = collection.preload() + + await collection.cleanup() + await preload + markError() + + expect(collection.status).toBe(`cleaned-up`) + }) + + it(`ignores an error callback retained by an earlier sync session`, async () => { + const sessions: Array<{ + markError: () => void + markReady: () => void + }> = [] + const collection = createCollection<{ id: string }>({ + id: `stale-error-after-restart`, + getKey: (item) => item.id, + startSync: false, + sync: { + sync: ({ markError, markReady }) => { + sessions.push({ markError, markReady }) + }, + }, + }) + + await collection.cleanup() + const preload = collection.preload() + expect(sessions).toHaveLength(1) + const first = sessions[0]! + + await collection.cleanup() + await preload + const restartedPreload = collection.preload() + expect(sessions).toHaveLength(2) + const second = sessions[1]! + + first.markError() + expect(collection.status).toBe(`loading`) + + second.markReady() + await restartedPreload + expect(collection.status).toBe(`ready`) + }) + + it(`ignores transaction callbacks retained by an earlier sync session`, async () => { + type Item = { id: string } + type SyncMethods = Parameters[`sync`]>[0] + const sessions: Array = [] + const collection = createCollection({ + id: `stale-transaction-after-restart`, + getKey: (item) => item.id, + startSync: false, + sync: { + sync: (sync) => { + sessions.push(sync) + }, + }, + }) + + const firstPreload = collection.preload() + const first = sessions[0]! + await collection.cleanup() + await firstPreload + + const secondPreload = collection.preload() + const second = sessions[1]! + first.begin() + first.write({ type: `insert`, value: { id: `stale` } }) + first.commit() + first.markReady() + + expect(collection.status).toBe(`loading`) + expect(collection.get(`stale`)).toBeUndefined() + + second.begin() + second.write({ type: `insert`, value: { id: `current` } }) + second.commit() + second.markReady() + await secondPreload + + expect(collection.status).toBe(`ready`) + expect(collection.get(`current`)).toMatchObject({ id: `current` }) + }) + }) + describe(`Operation Validation Errors`, () => { + it(`preserves a synchronous sync startup error`, async () => { + const startupError = new Error(`Sync initialization failed exactly`) + const collection = createCollection<{ id: string }>({ + id: `exact-startup-error`, + getKey: (item) => item.id, + startSync: false, + sync: { + sync: () => { + throw startupError + }, + }, + }) + + await expect(collection.preload()).rejects.toBe(startupError) + expect(collection.status).toBe(`error`) + }) + it(`should throw helpful errors when trying to use operations on error status collection`, async () => { const collection = createCollection<{ id: string; name: string }>({ id: `error-status-test`, diff --git a/packages/db/tests/query/includes-temporal-oracle.test.ts b/packages/db/tests/query/includes-temporal-oracle.test.ts index 5c89ff3f66..2fc9eebcbc 100644 --- a/packages/db/tests/query/includes-temporal-oracle.test.ts +++ b/packages/db/tests/query/includes-temporal-oracle.test.ts @@ -2,6 +2,7 @@ import { fc, test as fcTest } from '@fast-check/vitest' import { describe, expect, it, vi } from 'vitest' import { createCollection } from '../../src/collection/index.js' import { createDeferred } from '../../src/deferred.js' +import { CollectionIsInErrorStateError } from '../../src/errors.js' import { BasicIndex } from '../../src/indexes/basic-index.js' import { extractSimpleComparisons } from '../../src/query/expression-helpers.js' import { SubsetDemandController } from '../../src/query/live/subset-demand-controller.js' @@ -767,7 +768,10 @@ async function expectRejectedDemandEntersError(): Promise { await flushPromises() expect(loadCount).toBe(1) expect(live.status).toBe(`error`) - expect(preload.preloadSettled).toBe(false) + expect(preload.preloadSettled).toBe(true) + expect(preload.preloadFailure?.error).toBeInstanceOf( + CollectionIsInErrorStateError, + ) await live.cleanup() await preload.preloadOutcome diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index 112213e899..9ebf93f7ed 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -1478,6 +1478,7 @@ function createElectricSync>( write, commit, markReady, + markError, truncate, collection, metadata, @@ -1571,19 +1572,22 @@ function createElectricSync>( (canUsePersistedResume ? persistedResumeState.handle : undefined), signal: abortController.signal, onError: (errorParams) => { - // Just immediately mark ready if there's an error to avoid blocking - // apps waiting for `.preload()` to finish. // Note that Electric sends a 409 error on a `must-refetch` message, but the // ShapeStream handled this and it will not reach this handler, therefor - // this markReady will not be triggers by a `must-refetch`. - markReady() + // this handler will not run for a `must-refetch`. + const initialSyncFailed = collection.status === `loading` + if (initialSyncFailed) { + markError(errorParams) + } if (shapeOptions.onError) { return shapeOptions.onError(errorParams) } else { console.error( `An error occurred while syncing collection: ${collection.id}, \n` + - `it has been marked as ready to avoid blocking apps waiting for '.preload()' to finish. \n` + + (initialSyncFailed + ? `the initial sync has been marked as failed. \n` + : `the last ready snapshot has been preserved. \n`) + `You can provide an 'onError' handler on the shapeOptions to handle this error, and this message will not be logged.`, errorParams, ) diff --git a/packages/electric-db-collection/tests/electric.test.ts b/packages/electric-db-collection/tests/electric.test.ts index 83b129e02c..2e6ae54eff 100644 --- a/packages/electric-db-collection/tests/electric.test.ts +++ b/packages/electric-db-collection/tests/electric.test.ts @@ -190,6 +190,24 @@ describe(`Electric Integration`, () => { expect(collection.status).toEqual(`ready`) }) + it(`reports an initial stream error instead of publishing an empty ready snapshot`, async () => { + const loggedError = vi.spyOn(console, `error`).mockImplementation(() => {}) + const preload = collection.preload() + const streamOptions = vi.mocked(ShapeStream).mock.calls.at(-1)?.[0] as + | { onError?: (error: unknown) => void } + | undefined + const initialError = new Error(`initial stream failed`) + + try { + streamOptions?.onError?.(initialError) + + expect(collection.status).toBe(`error`) + await expect(preload).rejects.toBe(initialError) + } finally { + loggedError.mockRestore() + } + }) + it(`should handle incoming insert messages and commit on up-to-date`, () => { // Simulate incoming insert message subscriber([ diff --git a/packages/powersync-db-collection/src/powersync.ts b/packages/powersync-db-collection/src/powersync.ts index f3c02aa85a..b52374012c 100644 --- a/packages/powersync-db-collection/src/powersync.ts +++ b/packages/powersync-db-collection/src/powersync.ts @@ -316,7 +316,7 @@ function createPowerSyncCollectionConfig< */ const sync: SyncConfig = { sync: (params) => { - const { begin, write, collection, commit, markReady } = params + const { begin, write, collection, commit, markReady, markError } = params const abortController = new AbortController() let disposeTracking: @@ -522,12 +522,15 @@ function createPowerSyncCollectionConfig< ), onReady: () => markReady(), }) - }).catch((error) => + }).catch((error) => { database.logger.error( `Could not start syncing process for ${viewName} into ${trackedTableName}`, error, - ), - ) + ) + if (collection.status === `loading`) { + markError(error) + } + }) return () => { database.logger.info( diff --git a/packages/powersync-db-collection/tests/load-hooks.test.ts b/packages/powersync-db-collection/tests/load-hooks.test.ts index d6ead38733..cc428816e8 100644 --- a/packages/powersync-db-collection/tests/load-hooks.test.ts +++ b/packages/powersync-db-collection/tests/load-hooks.test.ts @@ -75,6 +75,22 @@ describe(`Sync Streams`, () => { expect(onUnloadMock).toHaveBeenCalledOnce() }) + it(`eager mode: reports an initial load failure`, async () => { + const db = await createDatabase() + const initialError = new Error(`initial PowerSync load failed`) + const collection = createCollection( + powerSyncCollectionOptions({ + database: db, + table: APP_SCHEMA.props.products, + onLoad: () => Promise.reject(initialError), + }), + ) + onTestFinished(() => collection.cleanup()) + + await expect(collection.preload()).rejects.toBe(initialError) + expect(collection.status).toBe(`error`) + }) + it(`on-demand mode: should call onLoadSubset/onUnloadSubset for each live query`, async () => { const db = await createDatabase() await createTestProducts(db) diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index f6c2c62bd6..5396bd49d6 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -1619,7 +1619,7 @@ export function queryCollectionOptions( // collection state. Later refetch failures keep the last ready // snapshot available while utils expose the error. if (collection.status === `loading`) { - markError() + markError(result.error) } } } @@ -1691,7 +1691,9 @@ export function queryCollectionOptions( // In on-demand mode, there is no initial query, but retained-placeholder // maintenance still needs to finish before the collection is treated as ready. void startupRetentionMaintenancePromise.then(() => { - markReady() + if (collection.status === `loading`) { + markReady() + } }) } } diff --git a/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts b/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts index f0aa7fc701..ccda026c80 100644 --- a/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts +++ b/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts @@ -1,10 +1,17 @@ import { QueryClient } from '@tanstack/query-core' -import { IR, createCollection, createLiveQueryCollection } from '@tanstack/db' +import { + BasicIndex, + IR, + createCollection, + createLiveQueryCollection, + eq, +} from '@tanstack/db' import { describe, expect, it, vi } from 'vitest' import { expectAssertionFailure } from '../../db/tests/expected-failure.js' import { TraceAssertionError } from '../../db/tests/trace-runner.js' import { queryCollectionOptions } from '../src/query.js' import type { QueryFunctionContext } from '@tanstack/query-core' +import type { SyncMetadataApi } from '@tanstack/db' type Row = { id: string @@ -19,6 +26,7 @@ function createQueryClient(): QueryClient { queries: { gcTime: Number.POSITIVE_INFINITY, retry: false, + staleTime: Number.POSITIVE_INFINITY, }, }, }) @@ -73,8 +81,11 @@ async function expectInitialQueryFailureStatus(): Promise { await vi.waitFor(() => { expect(collection.status).toBe(`ready`) expect(collection.get(`recovered`)).toBeDefined() + expect(live.status).toBe(`ready`) + expect(live.get(`recovered`)).toBeDefined() }) await expect(collection.preload()).resolves.toBeUndefined() + await expect(live.preload()).resolves.toBeUndefined() } finally { await live.cleanup() await collection.cleanup() @@ -83,6 +94,119 @@ async function expectInitialQueryFailureStatus(): Promise { } } +async function expectLateDependentObservesInitialFailure(): Promise { + const error = new Error(`source failed before dependent construction`) + const queryClient = createQueryClient() + const id = `load-subset-late-dependent-error-${collectionSequence++}` + const loggedError = vi.spyOn(console, `error`).mockImplementation(() => {}) + const collection = createCollection( + queryCollectionOptions({ + id, + queryClient, + queryKey: [id], + queryFn: vi.fn().mockRejectedValue(error), + getKey: (row) => row.id, + startSync: true, + retry: false, + }), + ) + + await expect(collection.preload()).rejects.toBe(error) + expect(collection.status).toBe(`error`) + + const live = createLiveQueryCollection((query) => + query.from({ row: collection }).select(({ row }) => ({ id: row.id })), + ) + const livePreload = live.preload() + void livePreload.catch(() => undefined) + + try { + expect(live.status).toBe(`error`) + await expect(livePreload).rejects.toThrow() + } finally { + await live.cleanup() + await collection.cleanup() + await Promise.allSettled([livePreload]) + queryClient.clear() + loggedError.mockRestore() + } +} + +async function expectEveryFailedSourceToRecover(): Promise { + const createControlledSource = (id: string) => { + let fail: () => void = () => { + throw new Error(`Source '${id}' has not started`) + } + let recover: (row: Row) => void = (_row) => { + throw new Error(`Source '${id}' has not started`) + } + const collection = createCollection({ + id, + getKey: (row) => row.id, + startSync: false, + autoIndex: `eager`, + defaultIndexType: BasicIndex, + sync: { + sync: ({ begin, write, commit, markReady, markError }) => { + fail = markError + recover = (row) => { + begin() + write({ type: `insert`, value: row }) + commit() + markReady() + } + }, + }, + }) + return { + collection, + fail: () => fail(), + recover: (row: Row) => recover(row), + } + } + + const left = createControlledSource( + `load-subset-multi-error-left-${collectionSequence++}`, + ) + const right = createControlledSource( + `load-subset-multi-error-right-${collectionSequence++}`, + ) + const loggedError = vi.spyOn(console, `error`).mockImplementation(() => {}) + const live = createLiveQueryCollection((query) => + query + .from({ left: left.collection }) + .join({ right: right.collection }, ({ left: leftRow, right: rightRow }) => + eq(leftRow.id, rightRow.id), + ) + .select(({ left: row }) => ({ id: row.id })), + ) + const preload = live.preload() + void preload.catch(() => undefined) + + try { + left.fail() + right.fail() + await expect(preload).rejects.toThrow() + expect(live.status).toBe(`error`) + + left.recover({ id: `shared` }) + expect(left.collection.status).toBe(`ready`) + expect(right.collection.status).toBe(`error`) + expect(live.status).toBe(`error`) + + right.recover({ id: `shared` }) + await expect(live.preload()).resolves.toBeUndefined() + expect(live.status).toBe(`ready`) + expect(live.toArray.map((row) => row.id)).toEqual([`shared`]) + } finally { + await live.cleanup() + await left.collection.cleanup() + await right.collection.cleanup() + await Promise.allSettled([preload]) + loggedError.mockRestore() + } +} + async function expectRefetchFailureKeepsReadySnapshot(): Promise { const error = new Error(`refetch failed`) const queryClient = createQueryClient() @@ -125,6 +249,97 @@ async function expectRefetchFailureKeepsReadySnapshot(): Promise { } } +async function expectDeferredStartupReadyDoesNotOverrideError(): Promise { + const loggedError = vi.spyOn(console, `error`).mockImplementation(() => {}) + const queryClient = createQueryClient() + const id = `load-subset-deferred-ready-${collectionSequence++}` + const queryError = new Error(`cached observer failed`) + const queryFn = vi.fn().mockRejectedValue(queryError) + const baseOptions = queryCollectionOptions({ + id, + queryClient, + queryKey: [id], + queryFn, + getKey: (row) => row.id, + startSync: true, + syncMode: `on-demand`, + retry: false, + }) + const originalSync = baseOptions.sync + let syncParams!: Parameters[0] + const collection = createCollection({ + ...baseOptions, + sync: { + sync: (params) => { + syncParams = params + return originalSync.sync(params) + }, + }, + }) + + const firstLoad = collection._sync.loadSubset({}) + if (!(firstLoad instanceof Promise)) { + throw new Error(`The failing query must be asynchronous`) + } + await expect(firstLoad).rejects.toBe(queryError) + expect(collection.status).toBe(`ready`) + + let releaseScan!: () => void + const scanReleased = new Promise((resolve) => { + releaseScan = resolve + }) + let resolveMaintenanceDelete!: () => void + const maintenanceDeleted = new Promise((resolve) => { + resolveMaintenanceDelete = resolve + }) + const metadata = { + row: { + get: () => undefined, + set: () => {}, + delete: () => {}, + scanPersisted: async () => { + await scanReleased + return [] + }, + }, + collection: { + get: () => undefined, + set: () => {}, + delete: () => { + resolveMaintenanceDelete() + }, + list: () => [ + { + key: `queryCollection:gc:expired`, + value: { queryHash: `expired`, mode: `ttl`, expiresAt: 0 }, + }, + ], + }, + } as SyncMetadataApi + + collection._lifecycle.setStatus(`cleaned-up`) + collection._lifecycle.setStatus(`loading`) + const secondSync = originalSync.sync({ ...syncParams, metadata }) + + try { + expect(collection.status).toBe(`error`) + releaseScan() + await maintenanceDeleted + for (let turn = 0; turn < 10; turn++) await Promise.resolve() + expect(collection.status).toBe(`error`) + expect(collection.utils.lastError).toBe(queryError) + } finally { + if (typeof secondSync === `function`) { + await secondSync() + } else { + await secondSync?.cleanup?.() + } + await collection.cleanup() + queryClient.clear() + loggedError.mockRestore() + } +} + async function expectEquivalentPredicatesShareOneLoad( form: `commutative-and` | `reversed-equality`, ): Promise { @@ -287,10 +502,22 @@ describe(`loadSubset lifecycle oracle`, () => { await expectInitialQueryFailureStatus() }) + it(`reports an initial failure to a dependent created after the source failed`, async () => { + await expectLateDependentObservesInitialFailure() + }) + + it(`recovers a dependent only after every failed source recovers`, async () => { + await expectEveryFailedSourceToRecover() + }) + it(`keeps the last ready snapshot after a refetch failure`, async () => { await expectRefetchFailureKeepsReadySnapshot() }) + it(`does not let deferred startup readiness override a replayed error`, async () => { + await expectDeferredStartupReadyDoesNotOverrideError() + }) + it(`commutative predicate forms share one query-db transport load`, async () => { await expectAssertionFailure(expectEquivalentPredicatesShareOneLoad, { checkpoint: 0, diff --git a/packages/rxdb-db-collection/src/rxdb.ts b/packages/rxdb-db-collection/src/rxdb.ts index 9eb6b260b0..1d39757705 100644 --- a/packages/rxdb-db-collection/src/rxdb.ts +++ b/packages/rxdb-db-collection/src/rxdb.ts @@ -127,7 +127,7 @@ export function rxdbCollectionOptions( type SyncParams = Parameters[`sync`]>[0] const sync: SyncConfig = { sync: (params: SyncParams) => { - const { begin, write, commit, markReady } = params + const { begin, write, commit, markReady, markError, collection } = params let ready = false async function initialFetch() { @@ -250,7 +250,11 @@ export function rxdbCollectionOptions( markReady() } - start() + void start().catch((error: unknown) => { + if (collection.status === `loading`) { + markError(error) + } + }) return () => { const subs = getFromMapOrCreate( diff --git a/packages/rxdb-db-collection/tests/rxdb.test.ts b/packages/rxdb-db-collection/tests/rxdb.test.ts index c3ff49a760..e3df5c7a49 100644 --- a/packages/rxdb-db-collection/tests/rxdb.test.ts +++ b/packages/rxdb-db-collection/tests/rxdb.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { createCollection } from '@tanstack/db' import { addRxPlugin, @@ -103,6 +103,31 @@ describe(`RxDB Integration`, () => { } describe(`sync`, () => { + it(`reports an initial storage query failure`, async () => { + const db = await getDatababase() + const rxCollection: RxCollection = db.test + const initialError = new Error(`initial RxDB query failed`) + const query = vi + .spyOn(rxCollection.storageInstance, `query`) + .mockRejectedValueOnce(initialError) + const collection = createCollection( + rxdbCollectionOptions({ + rxCollection, + startSync: true, + syncBatchSize: 10, + }), + ) + + try { + await expect(collection.preload()).rejects.toBe(initialError) + expect(collection.status).toBe(`error`) + } finally { + query.mockRestore() + await collection.cleanup() + await db.remove() + } + }) + it(`should initialize and fetch initial data`, async () => { const initialItems = getTestData(2) diff --git a/packages/trailbase-db-collection/src/trailbase.ts b/packages/trailbase-db-collection/src/trailbase.ts index adddb6c470..e7b283e4a9 100644 --- a/packages/trailbase-db-collection/src/trailbase.ts +++ b/packages/trailbase-db-collection/src/trailbase.ts @@ -180,7 +180,7 @@ export function trailBaseCollectionOptions< type SyncParams = Parameters[`sync`]>[0] const sync = { sync: (params: SyncParams) => { - const { begin, write, commit, markReady } = params + const { begin, write, commit, markReady, markError, collection } = params let cancelled = false let periodicCleanupTask: ReturnType | undefined @@ -309,18 +309,25 @@ export function trailBaseCollectionOptions< } async function start() { - const eventStream = await config.recordApi.subscribe(`*`) - if (cancelled) { - await eventStream.cancel() - return - } - const reader = (eventReader = eventStream.getReader()) - - // Start listening for subscriptions first. Otherwise, we'd risk a gap - // between the initial fetch and starting to listen. - listen(reader) - + let reader: ReadableStreamDefaultReader | undefined try { + const eventStream = await config.recordApi.subscribe(`*`) + if (cancelled) { + await eventStream.cancel() + return + } + reader = eventReader = eventStream.getReader() + + // Start listening for subscriptions first. Otherwise, we'd risk a gap + // between the initial fetch and starting to listen. + void listen(reader).catch((error: unknown) => { + if (!cancelled && collection.status === `loading`) { + markError(error) + } else if (!cancelled) { + console.error(`TrailBase subscription failed`, error) + } + }) + // Eager mode: perform initial fetch to populate everything if (internalSyncMode === `eager`) { // Load everything on initial load. @@ -328,18 +335,20 @@ export function trailBaseCollectionOptions< if (cancelled) return fullSyncCompleted = true } - } catch (e) { + if (!cancelled && collection.status === `loading`) { + markReady() + } + } catch (error) { cancelEventReader() - throw e - } finally { - // Mark ready both if everything went well or if there's an error to - // avoid blocking apps waiting for `.preload()` to finish. - if (!cancelled) markReady() + if (!cancelled && collection.status === `loading`) { + markError(error) + } + return } // Lastly, start a periodic cleanup task that will be removed when the // reader closes. - if (cancelled) return + if (cancelled || !reader) return periodicCleanupTask = setInterval(() => { seenIds.setState((curr) => { @@ -367,7 +376,7 @@ export function trailBaseCollectionOptions< }) } - start() + void start() // Eager mode doesn't need subset loading if (internalSyncMode === `eager`) { diff --git a/packages/trailbase-db-collection/tests/trailbase.test.ts b/packages/trailbase-db-collection/tests/trailbase.test.ts index d105a416a8..7f08589323 100644 --- a/packages/trailbase-db-collection/tests/trailbase.test.ts +++ b/packages/trailbase-db-collection/tests/trailbase.test.ts @@ -1,13 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { createCollection } from '@tanstack/db' import { trailBaseCollectionOptions } from '../src/trailbase' -import { - flushPromises, - stripVirtualProps, - withExpectedRejection, -} from '../../db/tests/utils' -import { expectAssertionFailure } from '../../db/tests/expected-failure' -import { TraceAssertionError } from '../../db/tests/trace-runner' +import { stripVirtualProps } from '../../db/tests/utils' import type { CreateOperation, DeleteOperation, @@ -131,38 +125,21 @@ async function expectWildcardFailureSettlesPreload(): Promise { const recordApi = new MockRecordApi() recordApi.subscribe.mockRejectedValue(failure) - await withExpectedRejection(failure.message, async () => { - const collection = createCollection(setUp(recordApi)) - let settled = false - const preload = collection.preload().then( - () => { - settled = true - }, - () => { - settled = true - }, - ) + const collection = createCollection(setUp(recordApi)) + const preload = collection.preload() - try { - await flushPromises() - try { - expect(settled).toBe(true) - } catch (error) { - throw new TraceAssertionError(0, error) - } - } finally { - await collection.cleanup() - await preload - } - }) + try { + await expect(preload).rejects.toBe(failure) + expect(collection.status).toBe(`error`) + } finally { + await collection.cleanup() + await Promise.allSettled([preload]) + } } describe(`TrailBase Integration`, () => { it(`settles preload when wildcard subscription startup fails`, async () => { - await expectAssertionFailure(expectWildcardFailureSettlesPreload, { - checkpoint: 0, - classify: ({ actual, expected }) => actual === false && expected === true, - })() + await expectWildcardFailureSettlesPreload() }) it(`cancels its event subscription when the collection is cleaned up`, async () => { From 54544634af6e3ca4f205a59be5e680b019d4086f Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Fri, 21 Aug 2026 10:28:55 -0600 Subject: [PATCH 16/18] fix: address initial load error review --- .../query/load-subset-oracle.property.test.ts | 30 ++++++++++++++++--- .../query/pagination-oracle.property.test.ts | 18 +++++------ packages/db/tests/reference-expression.ts | 11 ++++--- .../load-subset-lifecycle-oracle.test.ts | 11 +++++-- packages/rxdb-db-collection/src/rxdb.ts | 25 +++++++++------- .../rxdb-db-collection/tests/rxdb.test.ts | 6 ++++ 6 files changed, 70 insertions(+), 31 deletions(-) diff --git a/packages/db/tests/query/load-subset-oracle.property.test.ts b/packages/db/tests/query/load-subset-oracle.property.test.ts index a0b295d78c..b3d2fc2e07 100644 --- a/packages/db/tests/query/load-subset-oracle.property.test.ts +++ b/packages/db/tests/query/load-subset-oracle.property.test.ts @@ -646,17 +646,18 @@ function isKnownOffsetTruncatedUnlimitedDeduplication( const unlimitedLoads = error.loadedRegions.filter( ({ request }) => request.limit === undefined, ) - if (!unlimitedLoads.some(({ request }) => request.offset > 0)) return false + const offsetLoads = unlimitedLoads.filter(({ request }) => request.offset > 0) + if (offsetLoads.length === 0) return false // The known defect stores unlimited predicate coverage without its offset or // ordering. Model that loss directly instead of replaying production dedupe. - if (unlimitedLoads.some(({ request }) => request.where === undefined)) { + if (offsetLoads.some(({ request }) => request.where === undefined)) { return true } if (error.requested.where === undefined) return false const incorrectlyTrackedValues = unionSets( - unlimitedLoads.map(({ request }) => + offsetLoads.map(({ request }) => matchingValues(toWindowOptions(request).where), ), ) @@ -709,7 +710,11 @@ function isKnownIndividuallyCoveredWindowRefetch( ) if (!coveredByOneRegion) return false - return true + const replay = [ + ...error.loadedRegions.map(({ request }) => request), + error.requested, + ] + return countWindowLoads(replay) === replay.length } const createWindowKeyBlindSubject: CoverageSubjectFactory = (recordLoad) => { @@ -1216,6 +1221,23 @@ function expectExactCountFailure( } describe(`loadSubset coverage oracle`, () => { + it(`orders a missing reference path with null`, () => { + const missing = new PropRef([`missing`]) + + expect( + evaluateReferenceExpression( + new Func(`lte`, [missing, new Value(null)]), + {}, + ), + ).toBe(true) + expect( + evaluateReferenceExpression( + new Func(`lt`, [missing, new Value(0)]), + {}, + ), + ).toBe(true) + }) + it(`rejects one-region coverage from the union-composition classifier`, () => { expect( isKnownUnionCompositionRefetch( diff --git a/packages/db/tests/query/pagination-oracle.property.test.ts b/packages/db/tests/query/pagination-oracle.property.test.ts index cdef8e2ac5..0c17368f22 100644 --- a/packages/db/tests/query/pagination-oracle.property.test.ts +++ b/packages/db/tests/query/pagination-oracle.property.test.ts @@ -90,7 +90,7 @@ type PendingMutationScenario = { responseOutcome: `resolve` | `reject` } -class PendingMutationTraceAssertionError extends TraceAssertionError { +class DeliveredRowsTraceAssertionError extends TraceAssertionError { constructor( cause: unknown, readonly deliveredRows: ReadonlyArray, @@ -99,14 +99,9 @@ class PendingMutationTraceAssertionError extends TraceAssertionError { } } -class PendingHistoryTraceAssertionError extends TraceAssertionError { - constructor( - cause: unknown, - readonly deliveredRows: ReadonlyArray, - ) { - super(0, cause) - } -} +class PendingMutationTraceAssertionError extends DeliveredRowsTraceAssertionError {} + +class PendingHistoryTraceAssertionError extends DeliveredRowsTraceAssertionError {} type PendingHistoryScenario = { ranks: ReadonlyArray @@ -649,6 +644,8 @@ 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, @@ -1424,8 +1421,7 @@ async function runPendingMutationScenario( if (pending.length === 2) { await settlePending() } else { - await Promise.resolve() - await Promise.resolve() + await flushPromises() expect(retrySettled).toBe(true) } if (observedRetry) { diff --git a/packages/db/tests/reference-expression.ts b/packages/db/tests/reference-expression.ts index dff7237ccb..51716b901a 100644 --- a/packages/db/tests/reference-expression.ts +++ b/packages/db/tests/reference-expression.ts @@ -2,10 +2,13 @@ import type { BasicExpression } from '../src/query/ir.js' function compareReferenceValues(left: unknown, right: unknown): number { if (left === right) return 0 - // Query order cursors use nulls-first ordering. Treat null as the least - // value so adapters can evaluate the same cursor boundary independently. - if (left === null) return -1 - if (right === null) return 1 + // Query order cursors use nulls-first ordering. Missing reference paths are + // equivalent to null so adapters can evaluate the same boundary independently. + const leftNullish = left === null || left === undefined + const rightNullish = right === null || right === undefined + if (leftNullish && rightNullish) return 0 + if (leftNullish) return -1 + if (rightNullish) return 1 if (typeof left === `number` && typeof right === `number`) { return left < right ? -1 : 1 } diff --git a/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts b/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts index ccda026c80..3e13faf653 100644 --- a/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts +++ b/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts @@ -292,7 +292,14 @@ async function expectDeferredStartupReadyDoesNotOverrideError(): Promise { const maintenanceDeleted = new Promise((resolve) => { resolveMaintenanceDelete = resolve }) - const metadata = { + type MetadataWithPersistedScan = SyncMetadataApi & { + row: SyncMetadataApi[`row`] & { + scanPersisted: () => Promise< + Array<{ key: string | number; value: Row; metadata?: unknown }> + > + } + } + const metadata: MetadataWithPersistedScan = { row: { get: () => undefined, set: () => {}, @@ -315,7 +322,7 @@ async function expectDeferredStartupReadyDoesNotOverrideError(): Promise { }, ], }, - } as SyncMetadataApi + } collection._lifecycle.setStatus(`cleaned-up`) collection._lifecycle.setStatus(`loading`) diff --git a/packages/rxdb-db-collection/src/rxdb.ts b/packages/rxdb-db-collection/src/rxdb.ts index 1d39757705..ef61631ace 100644 --- a/packages/rxdb-db-collection/src/rxdb.ts +++ b/packages/rxdb-db-collection/src/rxdb.ts @@ -210,7 +210,19 @@ export function rxdbCollectionOptions( commit() } - let sub: Subscription + let sub: Subscription | undefined + function stopOngoingFetch() { + buffer.length = 0 + if (!sub) return + getFromMapOrCreate( + OPEN_RXDB_SUBSCRIPTIONS, + rxCollection, + () => new Set(), + ).delete(sub) + sub.unsubscribe() + sub = undefined + } + function startOngoingFetch() { // Subscribe early and buffer live changes during initial load and ongoing sub = rxCollection.$.subscribe((ev) => { @@ -251,20 +263,13 @@ export function rxdbCollectionOptions( } void start().catch((error: unknown) => { + stopOngoingFetch() if (collection.status === `loading`) { markError(error) } }) - return () => { - const subs = getFromMapOrCreate( - OPEN_RXDB_SUBSCRIPTIONS, - rxCollection, - () => new Set(), - ) - subs.delete(sub) - sub.unsubscribe() - } + return stopOngoingFetch }, // Expose the getSyncMetadata function getSyncMetadata: undefined, diff --git a/packages/rxdb-db-collection/tests/rxdb.test.ts b/packages/rxdb-db-collection/tests/rxdb.test.ts index e3df5c7a49..dc6a4d2659 100644 --- a/packages/rxdb-db-collection/tests/rxdb.test.ts +++ b/packages/rxdb-db-collection/tests/rxdb.test.ts @@ -121,6 +121,12 @@ describe(`RxDB Integration`, () => { try { await expect(collection.preload()).rejects.toBe(initialError) expect(collection.status).toBe(`error`) + expect(OPEN_RXDB_SUBSCRIPTIONS.get(rxCollection)?.size ?? 0).toBe(0) + + await rxCollection.insert({ id: `after-failure`, name: `failed` }) + await flushPromises() + expect(OPEN_RXDB_SUBSCRIPTIONS.get(rxCollection)?.size ?? 0).toBe(0) + expect(collection.has(`after-failure`)).toBe(false) } finally { query.mockRestore() await collection.cleanup() From d4e98aa8efc757d6b1636285348ecb08dfe6be95 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:30:21 +0000 Subject: [PATCH 17/18] ci: apply automated fixes --- packages/db/tests/query/load-subset-oracle.property.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/db/tests/query/load-subset-oracle.property.test.ts b/packages/db/tests/query/load-subset-oracle.property.test.ts index b3d2fc2e07..cf2645607c 100644 --- a/packages/db/tests/query/load-subset-oracle.property.test.ts +++ b/packages/db/tests/query/load-subset-oracle.property.test.ts @@ -1231,10 +1231,7 @@ describe(`loadSubset coverage oracle`, () => { ), ).toBe(true) expect( - evaluateReferenceExpression( - new Func(`lt`, [missing, new Value(0)]), - {}, - ), + evaluateReferenceExpression(new Func(`lt`, [missing, new Value(0)]), {}), ).toBe(true) }) From 41458a294a083a8e0cbe123cdad72e608152a06e Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Fri, 21 Aug 2026 10:30:48 -0600 Subject: [PATCH 18/18] style: format oracle regression --- packages/db/tests/query/load-subset-oracle.property.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/db/tests/query/load-subset-oracle.property.test.ts b/packages/db/tests/query/load-subset-oracle.property.test.ts index b3d2fc2e07..cf2645607c 100644 --- a/packages/db/tests/query/load-subset-oracle.property.test.ts +++ b/packages/db/tests/query/load-subset-oracle.property.test.ts @@ -1231,10 +1231,7 @@ describe(`loadSubset coverage oracle`, () => { ), ).toBe(true) expect( - evaluateReferenceExpression( - new Func(`lt`, [missing, new Value(0)]), - {}, - ), + evaluateReferenceExpression(new Func(`lt`, [missing, new Value(0)]), {}), ).toBe(true) })