From 774f4a39467b3ae95b73a3d846ea33350ca46a7a Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 18 Aug 2026 23:21:07 +0100 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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(