From a5613b89ebd4871c11b03d93d0a9e89d48a19b8d Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 14 Aug 2026 08:26:36 -0600 Subject: [PATCH 1/2] test(db): fuzz includes transition histories --- .../query/includes-oracle.property.test.ts | 683 +++++++++++++++++- 1 file changed, 677 insertions(+), 6 deletions(-) diff --git a/packages/db/tests/query/includes-oracle.property.test.ts b/packages/db/tests/query/includes-oracle.property.test.ts index bcf958993..ec5fc30e9 100644 --- a/packages/db/tests/query/includes-oracle.property.test.ts +++ b/packages/db/tests/query/includes-oracle.property.test.ts @@ -1196,16 +1196,25 @@ type VisibleRelationshipScenarioOptions = { function assertDisjointRelationshipKeys( depth: IncludeDepth, branches: readonly [ConnectedBranch, ConnectedBranch], - rekeyGroup: number | undefined, + { + extraIds = [], + extraGroups = [], + }: { + extraIds?: ReadonlyArray + extraGroups?: ReadonlyArray + } = {}, ): void { - const ids = branches.flatMap(({ idBase }) => - Array.from({ length: depth + 1 }, (_, level) => idBase + level), - ) + const ids = [ + ...branches.flatMap(({ idBase }) => + Array.from({ length: depth + 1 }, (_, level) => idBase + level), + ), + ...extraIds, + ] const groups = [ ...branches.flatMap(({ groupBase }) => Array.from({ length: depth + 1 }, (_, level) => groupBase + level), ), - ...(rekeyGroup === undefined ? [] : [rekeyGroup]), + ...extraGroups, ] if ( new Set(ids).size !== ids.length || @@ -1223,7 +1232,7 @@ function createVisibleRelationshipScenario( assertDisjointRelationshipKeys( depth, branches, - transition === `rekey` ? options.rekeyGroup : undefined, + transition === `rekey` ? { extraGroups: [options.rekeyGroup] } : {}, ) const steps = createConnectedBatchBranches(depth, branches) const roots = new Map() @@ -1387,6 +1396,509 @@ function visibleRelationshipScenarioArbitrary( ) } +type GeneratedBranchOptions = { + leftIdBase: number + leftGroupBase: number + rightIdBase: number + rightGroupBase: number +} + +const generatedBranchArbitraries = { + leftIdBase: fc.integer({ min: 100, max: 500 }), + leftGroupBase: fc.integer({ min: 600, max: 1_000 }), + rightIdBase: fc.integer({ min: 1_100, max: 1_500 }), + rightGroupBase: fc.integer({ min: 1_600, max: 2_000 }), +} + +function createGeneratedBranches({ + leftIdBase, + leftGroupBase, + rightIdBase, + rightGroupBase, +}: GeneratedBranchOptions): readonly [ConnectedBranch, ConnectedBranch] { + return [ + { idBase: leftIdBase, groupBase: leftGroupBase }, + { idBase: rightIdBase, groupBase: rightGroupBase }, + ] +} + +type TransitionHistoryScenario = FullRowBatchScenario & { + historyStartStepIndex: number +} + +type TransitionHistoryScenarioOptions = { + depth: IncludeDepth + targetLevel: IncludeDepth + firstTransition: VisibleRelationshipTransition + secondTransition: VisibleRelationshipTransition + sourceBranch: 0 | 1 + branches: readonly [ConnectedBranch, ConnectedBranch] + rekeyGroups: readonly [number, number] + insertedLevels: readonly [IncludeDepth, IncludeDepth] + insertedValues: readonly [number, number] + insertedPositions: readonly [number, number] +} + +function createTransitionHistoryScenario({ + depth, + targetLevel, + firstTransition, + secondTransition, + sourceBranch, + branches, + rekeyGroups, + insertedLevels, + insertedValues, + insertedPositions, +}: TransitionHistoryScenarioOptions): TransitionHistoryScenario { + // Fresh keys keep this matrix green through both transitions. Separate + // state-aware families below reuse retired routes and replace existing rows, + // so those known failures remain shrinkable without masking later steps. + const insertedRows = [ + { + id: 3_000, + group: 2_700, + value: insertedValues[0], + position: insertedPositions[0], + }, + { + id: 3_001, + group: 2_800, + value: insertedValues[1], + position: insertedPositions[1], + }, + ] as const + assertDisjointRelationshipKeys(depth, branches, { + extraIds: insertedRows.map(({ id }) => id), + extraGroups: [...rekeyGroups, ...insertedRows.map(({ group }) => group)], + }) + + const steps = createConnectedBatchBranches(depth, branches) + const historyStartStepIndex = steps.length + const source = branches[sourceBranch] + let current = { + ...batchChild( + source.idBase + targetLevel, + source.groupBase + targetLevel - 1, + source.idBase + targetLevel, + 0, + ), + group: source.groupBase + targetLevel, + } + const appendStep = ( + level: IncludeDepth, + changes: Array>, + ): void => { + steps.push({ level, changes }) + } + const insertRow = ( + row: (typeof insertedRows)[number], + level: IncludeDepth, + ): ChildRow => { + if (level !== targetLevel && level !== targetLevel + 1) { + throw new Error(`History inserts must touch the target or its child`) + } + return { + ...row, + parentGroup: level === targetLevel ? current.parentGroup : current.group, + } + } + const transition = ( + kind: VisibleRelationshipTransition, + rekeyGroup: number, + ): void => { + const currentParentBranch = branches.findIndex( + ({ groupBase }) => current.parentGroup === groupBase + targetLevel - 1, + ) + if (currentParentBranch !== 0 && currentParentBranch !== 1) { + throw new Error(`Transition target has no visible parent branch`) + } + const destination = branches[otherBranch(currentParentBranch)] + current = { + ...current, + parentGroup: + kind === `reparent` + ? destination.groupBase + targetLevel - 1 + : current.parentGroup, + group: kind === `rekey` ? rekeyGroup : current.group, + } + appendStep(targetLevel, [{ type: `update`, value: current }]) + } + const interleaveTransition = ( + kind: VisibleRelationshipTransition, + rekeyGroup: number, + row: (typeof insertedRows)[number], + level: IncludeDepth, + ): void => { + const inserted = insertRow(row, level) + appendStep(level, [{ type: `insert`, value: inserted }]) + transition(kind, rekeyGroup) + appendStep(level, [{ type: `delete`, value: inserted }]) + } + + interleaveTransition( + firstTransition, + rekeyGroups[0], + insertedRows[0], + insertedLevels[0], + ) + interleaveTransition( + secondTransition, + rekeyGroups[1], + insertedRows[1], + insertedLevels[1], + ) + + return { + depth, + steps, + historyStartStepIndex, + } +} + +function transitionHistoryPlacements( + depth: IncludeDepth, + firstTransition: VisibleRelationshipTransition, + secondTransition: VisibleRelationshipTransition, +): Array<{ + targetLevel: IncludeDepth + insertedLevels: readonly [IncludeDepth, IncludeDepth] +}> { + // A rekey with two descendant levels is already a known failure. Keep this + // history corpus on the green side of that boundary so the first transition + // cannot mask a later mismatch. + const minimumTargetLevel = + firstTransition === `rekey` || secondTransition === `rekey` + ? Math.max(1, depth - 1) + : 1 + const placements: Array<{ + targetLevel: IncludeDepth + insertedLevels: readonly [IncludeDepth, IncludeDepth] + }> = [] + + for (let level = minimumTargetLevel; level <= depth; level++) { + const targetLevel = level as IncludeDepth + const insertedLevels = ( + transition: VisibleRelationshipTransition, + ): ReadonlyArray => + // Rekeying disconnects an existing child from the target. Restrict that + // interleave to the target row so its later delete remains observable. + transition === `reparent` && targetLevel < depth + ? [targetLevel, (targetLevel + 1) as IncludeDepth] + : [targetLevel] + + for (const firstInsertedLevel of insertedLevels(firstTransition)) { + for (const secondInsertedLevel of insertedLevels(secondTransition)) { + placements.push({ + targetLevel, + insertedLevels: [firstInsertedLevel, secondInsertedLevel], + }) + } + } + } + + return placements +} + +function transitionHistoryScenariosArbitrary( + depth: IncludeDepth, + firstTransition: VisibleRelationshipTransition, + secondTransition: VisibleRelationshipTransition, +): fc.Arbitrary> { + const placements = transitionHistoryPlacements( + depth, + firstTransition, + secondTransition, + ) + + return fc + .record({ + sourceBranch: fc.constantFrom<0 | 1>(0, 1), + ...generatedBranchArbitraries, + firstRekeyGroup: fc.integer({ min: 2_100, max: 2_300 }), + secondRekeyGroup: fc.integer({ min: 2_400, max: 2_600 }), + firstInsertedValue: fc.integer({ min: -3, max: 3 }), + secondInsertedValue: fc.integer({ min: -3, max: 3 }), + firstInsertedPosition: fc.integer({ min: -2, max: 2 }), + secondInsertedPosition: fc.integer({ min: -2, max: 2 }), + }) + .map( + ({ + sourceBranch, + leftIdBase, + leftGroupBase, + rightIdBase, + rightGroupBase, + firstRekeyGroup, + secondRekeyGroup, + firstInsertedValue, + secondInsertedValue, + firstInsertedPosition, + secondInsertedPosition, + }) => + placements.map(({ targetLevel, insertedLevels }) => + createTransitionHistoryScenario({ + depth, + targetLevel, + firstTransition, + secondTransition, + sourceBranch, + branches: createGeneratedBranches({ + leftIdBase, + leftGroupBase, + rightIdBase, + rightGroupBase, + }), + rekeyGroups: [firstRekeyGroup, secondRekeyGroup], + insertedLevels, + insertedValues: [firstInsertedValue, secondInsertedValue], + insertedPositions: [firstInsertedPosition, secondInsertedPosition], + }), + ), + ) +} + +function expectEveryHistoryStepVisible( + scenario: TransitionHistoryScenario, +): void { + for ( + let stepIndex = scenario.historyStartStepIndex; + stepIndex < scenario.steps.length; + stepIndex++ + ) { + const before = recomputeFullRowBatchScenario(scenario, stepIndex) + const after = recomputeFullRowBatchScenario(scenario, stepIndex + 1) + expect(after).not.toEqual(before) + } +} + +type ClassifiedHistoryScenario = { + control: FullRowBatchScenario + candidate: FullRowBatchScenario + candidateCheckpoint: number +} + +type RekeyRouteReuseOptions = { + depth: 2 | 3 | 4 + sourceBranch: 0 | 1 + branches: readonly [ConnectedBranch, ConnectedBranch] + rekeyGroup: number + insertedId: number + insertedValue: number + insertedPosition: number +} + +function createRekeyRouteReuseScenarios({ + depth, + sourceBranch, + branches, + rekeyGroup, + insertedId, + insertedValue, + insertedPosition, +}: RekeyRouteReuseOptions): ClassifiedHistoryScenario { + const targetLevel = (depth - 1) as IncludeDepth + assertDisjointRelationshipKeys(depth, branches, { + extraIds: [insertedId], + extraGroups: [rekeyGroup], + }) + + const prefix = createConnectedBatchBranches(depth, branches) + const source = branches[sourceBranch] + const parentGroup = source.groupBase + targetLevel - 1 + const oldGroup = source.groupBase + targetLevel + const inserted = { + ...batchChild(insertedId, parentGroup, insertedValue, insertedPosition), + group: oldGroup, + } + const insertOldRoute: FullRowBatchStep = { + level: targetLevel, + changes: [{ type: `insert`, value: inserted }], + } + const rekey: FullRowBatchStep = { + level: targetLevel, + changes: [ + { + type: `update`, + value: { + ...batchChild( + source.idBase + targetLevel, + parentGroup, + source.idBase + targetLevel, + 0, + ), + group: rekeyGroup, + }, + }, + ], + } + + return { + control: { depth, steps: [...prefix, insertOldRoute] }, + candidate: { depth, steps: [...prefix, rekey, insertOldRoute] }, + candidateCheckpoint: prefix.length + 2, + } +} + +function rekeyRouteReuseScenarioArbitrary( + depth: 2 | 3 | 4, + sourceBranch: 0 | 1, +): fc.Arbitrary { + return fc + .record({ + ...generatedBranchArbitraries, + rekeyGroup: fc.integer({ min: 2_100, max: 2_600 }), + insertedId: fc.integer({ min: 3_000, max: 3_200 }), + insertedValue: fc.integer({ min: -3, max: 3 }), + insertedPosition: fc.integer({ min: -2, max: 2 }), + }) + .map( + ({ + leftIdBase, + leftGroupBase, + rightIdBase, + rightGroupBase, + rekeyGroup, + insertedId, + insertedValue, + insertedPosition, + }) => + createRekeyRouteReuseScenarios({ + depth, + sourceBranch, + branches: createGeneratedBranches({ + leftIdBase, + leftGroupBase, + rightIdBase, + rightGroupBase, + }), + rekeyGroup, + insertedId, + insertedValue, + insertedPosition, + }), + ) +} + +type MovedChildReplacementOptions = { + depth: 3 | 4 + targetLevel: IncludeDepth + sourceBranch: 0 | 1 + branches: readonly [ConnectedBranch, ConnectedBranch] + insertedId: number + insertedValue: number + insertedPosition: number +} + +function createMovedChildReplacementScenarios({ + depth, + targetLevel, + sourceBranch, + branches, + insertedId, + insertedValue, + insertedPosition, +}: MovedChildReplacementOptions): ClassifiedHistoryScenario { + if (targetLevel + 2 > depth) { + throw new Error(`Child replacement needs a visible grandchild`) + } + assertDisjointRelationshipKeys(depth, branches, { + extraIds: [insertedId], + }) + + const prefix = createConnectedBatchBranches(depth, branches) + const source = branches[sourceBranch] + const destination = branches[otherBranch(sourceBranch)] + const targetGroup = source.groupBase + targetLevel + const childLevel = (targetLevel + 1) as IncludeDepth + const childGroup = source.groupBase + childLevel + const existingChild = { + ...batchChild( + source.idBase + childLevel, + targetGroup, + source.idBase + childLevel, + 0, + ), + group: childGroup, + } + const replacementChild = { + ...batchChild(insertedId, targetGroup, insertedValue, insertedPosition), + group: childGroup, + } + const replaceChild: Array = [ + { + level: childLevel, + changes: [{ type: `delete`, value: existingChild }], + }, + { + level: childLevel, + changes: [{ type: `insert`, value: replacementChild }], + }, + ] + const reparent: FullRowBatchStep = { + level: targetLevel, + changes: [ + { + type: `update`, + value: { + ...batchChild( + source.idBase + targetLevel, + destination.groupBase + targetLevel - 1, + source.idBase + targetLevel, + 0, + ), + group: targetGroup, + }, + }, + ], + } + + return { + control: { depth, steps: [...prefix, ...replaceChild] }, + candidate: { depth, steps: [...prefix, reparent, ...replaceChild] }, + candidateCheckpoint: prefix.length + 3, + } +} + +function movedChildReplacementScenarioArbitrary( + depth: 3 | 4, + targetLevel: IncludeDepth, + sourceBranch: 0 | 1, +): fc.Arbitrary { + return fc + .record({ + ...generatedBranchArbitraries, + insertedId: fc.integer({ min: 3_000, max: 3_200 }), + insertedValue: fc.integer({ min: -3, max: 3 }), + insertedPosition: fc.integer({ min: -2, max: 2 }), + }) + .map( + ({ + leftIdBase, + leftGroupBase, + rightIdBase, + rightGroupBase, + insertedId, + insertedValue, + insertedPosition, + }) => + createMovedChildReplacementScenarios({ + depth, + targetLevel, + sourceBranch, + branches: createGeneratedBranches({ + leftIdBase, + leftGroupBase, + rightIdBase, + rightGroupBase, + }), + insertedId, + insertedValue, + insertedPosition, + }), + ) +} + async function expectFullRowBatchScenarioMatches({ depth, steps, @@ -1398,6 +1910,26 @@ async function expectFullRowBatchScenarioMatches({ }) } +async function expectClassifiedHistoryFailure({ + control, + candidate, + candidateCheckpoint, +}: ClassifiedHistoryScenario): Promise { + await expectFullRowBatchScenarioMatches(control) + await expectAssertionFailure( + () => expectFullRowBatchScenarioMatches(candidate), + { checkpoint: candidateCheckpoint }, + )() +} + +async function expectClassifiedHistoryMatches({ + control, + candidate, +}: ClassifiedHistoryScenario): Promise { + await expectFullRowBatchScenarioMatches(control) + await expectFullRowBatchScenarioMatches(candidate) +} + function recomputeFullRowBatchScenario( { depth, steps }: FullRowBatchScenario, stepCount: number, @@ -1893,6 +2425,43 @@ const minimalRekeyScenario = createVisibleRelationshipScenario({ noise: [], }) +const transitionHistoryBranches = [ + { idBase: 100, groupBase: 600 }, + { idBase: 1_100, groupBase: 1_600 }, +] as const + +// The first rekey is correct on its own. Reusing its old correlation key for a +// new visible row then resurrects the detached descendant under the old row. +const { + control: rekeyRouteReuseControl, + candidate: rekeyRouteResurrectionScenario, + candidateCheckpoint: rekeyRouteResurrectionCheckpoint, +} = createRekeyRouteReuseScenarios({ + depth: 2, + sourceBranch: 0, + branches: transitionHistoryBranches, + rekeyGroup: 2_100, + insertedId: 3_000, + insertedValue: 0, + insertedPosition: 0, +}) + +// The reparent and delete are each correct. Replacing the moved row's child +// under the same correlation key then loses the existing grandchild snapshot. +const { + control: childReplacementControl, + candidate: movedSubtreeChildReplacementScenario, + candidateCheckpoint: movedSubtreeChildReplacementCheckpoint, +} = createMovedChildReplacementScenarios({ + depth: 3, + targetLevel: 1, + sourceBranch: 0, + branches: transitionHistoryBranches, + insertedId: 3_000, + insertedValue: 0, + insertedPosition: 0, +}) + describe(`includes recompute oracle`, () => { fcTest(`rejects overlapping visible relationship keys`, () => { const base = { @@ -1979,6 +2548,76 @@ describe(`includes recompute oracle`, () => { ), ) + fcTest( + `discovered trace: reusing a rekeyed row's old route does not resurrect its child`, + expectAssertionFailure( + () => expectFullRowBatchScenarioMatches(rekeyRouteResurrectionScenario), + { checkpoint: rekeyRouteResurrectionCheckpoint }, + ), + ) + + fcTest( + `matches recomputation when sharing a route without rekeying its existing row`, + () => expectFullRowBatchScenarioMatches(rekeyRouteReuseControl), + ) + + fcTest( + `discovered trace: replacing a moved subtree child retains its grandchild`, + expectAssertionFailure( + () => + expectFullRowBatchScenarioMatches(movedSubtreeChildReplacementScenario), + { checkpoint: movedSubtreeChildReplacementCheckpoint }, + ), + ) + + fcTest( + `matches recomputation when replacing a child without reparenting its ancestor`, + () => expectFullRowBatchScenarioMatches(childReplacementControl), + ) + + for (const depth of [2, 3, 4] as const) { + for (const sourceBranch of [0, 1] as const) { + fcTest.prop([rekeyRouteReuseScenarioArbitrary(depth, sourceBranch)], { + numRuns: 4, + seed: 1726 + depth * 10 + sourceBranch, + })( + `discovered histories: reusing a retired route at depth ${depth}, branch ${sourceBranch}`, + expectClassifiedHistoryFailure, + ) + } + } + + for (const depth of [3, 4] as const) { + for (let targetLevel = 1; targetLevel <= depth - 2; targetLevel++) { + for (const sourceBranch of [0, 1] as const) { + // (depth 4, target level 2, source branch 1) is the observed green + // boundary. Pin it so this expected-failure class cannot expand unseen. + const expectsFailure = + depth !== 4 || targetLevel !== 2 || sourceBranch !== 1 + fcTest.prop( + [ + movedChildReplacementScenarioArbitrary( + depth, + targetLevel as IncludeDepth, + sourceBranch, + ), + ], + { + numRuns: 4, + seed: 1727 + depth * 10 + targetLevel * 2 + sourceBranch, + }, + )( + expectsFailure + ? `discovered histories: replacing a moved child at depth ${depth}, level ${targetLevel}, branch ${sourceBranch}` + : `matches recomputation when replacing a moved child at depth ${depth}, level ${targetLevel}, branch ${sourceBranch}`, + expectsFailure + ? expectClassifiedHistoryFailure + : expectClassifiedHistoryMatches, + ) + } + } + } + fcTest.prop( [ fc.constantFrom(`array`, `concat`), @@ -2057,6 +2696,38 @@ describe(`includes recompute oracle`, () => { } } + for (const depth of [1, 2, 3, 4] as const) { + const transitions: Array = [ + `reparent`, + `rekey`, + ] + for (const [firstIndex, firstTransition] of transitions.entries()) { + for (const [secondIndex, secondTransition] of transitions.entries()) { + fcTest.prop( + [ + transitionHistoryScenariosArbitrary( + depth, + firstTransition, + secondTransition, + ), + ], + { + numRuns: 3, + seed: 1725 + depth * 10 + firstIndex * 2 + secondIndex, + }, + )( + `matches recomputation for ${firstTransition} → ${secondTransition} histories at depth ${depth}`, + async (scenarios) => { + for (const scenario of scenarios) { + expectEveryHistoryStepVisible(scenario) + await expectFullRowBatchScenarioMatches(scenario) + } + }, + ) + } + } + } + fcTest( `discovered seed: nested scalar materialization follows a reference update`, expectAssertionFailure( From 65b34f14a3e722ad4158bc845d485458be4825b4 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 14 Aug 2026 10:10:27 -0600 Subject: [PATCH 2/2] test(db): strengthen includes history classifications --- packages/db/tests/expected-failure.test.ts | 42 +- packages/db/tests/expected-failure.ts | 41 +- .../query/includes-oracle.property.test.ts | 519 +++++++++++++++--- 3 files changed, 518 insertions(+), 84 deletions(-) diff --git a/packages/db/tests/expected-failure.test.ts b/packages/db/tests/expected-failure.test.ts index 4bb2f3cfb..deb9bd092 100644 --- a/packages/db/tests/expected-failure.test.ts +++ b/packages/db/tests/expected-failure.test.ts @@ -2,19 +2,20 @@ import { describe, expect, it } from 'vitest' import { expectAssertionFailure } from './expected-failure.js' import { TraceAssertionError } from './trace-runner.js' +function assertionMismatch(checkpoint: number): Promise { + try { + expect(`observed`).toBe(`expected`) + return Promise.resolve() + } catch (error) { + return Promise.reject(new TraceAssertionError(checkpoint, error)) + } +} + describe(`expected failure guard`, () => { it(`accepts an assertion mismatch at the expected checkpoint`, async () => { - const guarded = expectAssertionFailure( - () => { - try { - expect(`observed`).toBe(`expected`) - return Promise.resolve() - } catch (error) { - return Promise.reject(new TraceAssertionError(2, error)) - } - }, - { checkpoint: 2 }, - ) + const guarded = expectAssertionFailure(() => assertionMismatch(2), { + checkpoint: 2, + }) await guarded() }) @@ -43,6 +44,25 @@ describe(`expected failure guard`, () => { await expect(guarded()).rejects.toBeInstanceOf(Error) }) + it(`accepts an assertion mismatch with the expected difference`, async () => { + const guarded = expectAssertionFailure(() => assertionMismatch(2), { + checkpoint: 2, + classify: ({ actual, expected }) => + actual === `observed` && expected === `expected`, + }) + + await guarded() + }) + + it(`rejects an assertion mismatch with a different shape`, async () => { + const guarded = expectAssertionFailure(() => assertionMismatch(2), { + checkpoint: 2, + classify: ({ actual }) => actual === `different`, + }) + + await expect(guarded()).rejects.toBeInstanceOf(Error) + }) + it(`accepts an assertion mismatch with the expected message`, async () => { const guarded = expectAssertionFailure( () => diff --git a/packages/db/tests/expected-failure.ts b/packages/db/tests/expected-failure.ts index 8cf70dc22..f5a437f0c 100644 --- a/packages/db/tests/expected-failure.ts +++ b/packages/db/tests/expected-failure.ts @@ -1,20 +1,57 @@ import { expect } from 'vitest' +export type AssertionDifference = { + actual: unknown + expected: unknown +} + type ExpectedAssertionFailure = - | { checkpoint: number } + | { + checkpoint: number + classify?: (difference: AssertionDifference) => boolean + } | { message: string | RegExp } +function assertionDifference(error: unknown): AssertionDifference { + if ( + typeof error !== `object` || + error === null || + !(`cause` in error) || + typeof error.cause !== `object` || + error.cause === null || + !(`actual` in error.cause) || + !(`expected` in error.cause) + ) { + throw new Error(`Expected an assertion difference`) + } + + return { + actual: error.cause.actual, + expected: error.cause.expected, + } +} + export function expectAssertionFailure>( assertion: (...args: TArgs) => Promise, expected: ExpectedAssertionFailure, ): (...args: TArgs) => Promise { return async (...args) => { if (`checkpoint` in expected) { - await expect(assertion(...args)).rejects.toMatchObject({ + let error: unknown + try { + await assertion(...args) + } catch (caught) { + error = caught + } + + expect(error).toMatchObject({ name: `TraceAssertionError`, checkpoint: expected.checkpoint, cause: { name: `AssertionError` }, }) + if (expected.classify) { + expect(expected.classify(assertionDifference(error))).toBe(true) + } return } diff --git a/packages/db/tests/query/includes-oracle.property.test.ts b/packages/db/tests/query/includes-oracle.property.test.ts index ec5fc30e9..d4ce95761 100644 --- a/packages/db/tests/query/includes-oracle.property.test.ts +++ b/packages/db/tests/query/includes-oracle.property.test.ts @@ -16,6 +16,7 @@ import { } from '../utils.js' import { expectAssertionFailure } from '../expected-failure.js' import { runTrace } from '../trace-runner.js' +import type { AssertionDifference } from '../expected-failure.js' import type { TraceCheckpoint, TraceDriver, @@ -59,6 +60,86 @@ type OracleNode = RootRow & { children?: Array } +type RelationshipNode = { + id: number + children?: unknown +} + +function isRelationshipNode(value: unknown): value is RelationshipNode { + return ( + typeof value === `object` && + value !== null && + `id` in value && + typeof value.id === `number` + ) +} + +function findRelationshipNode( + value: unknown, + id: number, +): RelationshipNode | undefined { + if (!Array.isArray(value)) return undefined + + for (const entry of value) { + if (!isRelationshipNode(entry)) continue + if (entry.id === id) return entry + const nested = findRelationshipNode(entry.children, id) + if (nested) return nested + } + return undefined +} + +function hasDirectChild(value: unknown, parentId: number, childId: number) { + const parent = findRelationshipNode(value, parentId) + return ( + Array.isArray(parent?.children) && + parent.children.some( + (child) => isRelationshipNode(child) && child.id === childId, + ) + ) +} + +function classifyUnexpectedSharedChild( + { actual, expected }: AssertionDifference, + unexpectedParentId: number, + expectedParentId: number, + childId: number, +): boolean { + return ( + hasDirectChild(actual, unexpectedParentId, childId) && + !hasDirectChild(expected, unexpectedParentId, childId) && + hasDirectChild(actual, expectedParentId, childId) && + hasDirectChild(expected, expectedParentId, childId) + ) +} + +function classifyMissingReplacementChild( + { actual, expected }: AssertionDifference, + replacementRowId: number, + childId: number, +): boolean { + return ( + findRelationshipNode(actual, replacementRowId) !== undefined && + findRelationshipNode(expected, replacementRowId) !== undefined && + !hasDirectChild(actual, replacementRowId, childId) && + hasDirectChild(expected, replacementRowId, childId) + ) +} + +type RelationshipProjectionNode = { + id: number + children?: Array +} + +function relationshipOnly( + nodes: ReadonlyArray, +): Array { + return nodes.map(({ id, children }) => ({ + id, + ...(children ? { children: relationshipOnly(children) } : {}), + })) +} + type MaterializeRoot = { id: number; middleId: number } type MaterializeMiddle = { id: number; sharedId: number } type MaterializeShared = { id: number; leafId: number } @@ -1160,11 +1241,30 @@ function fullRowBatchScenarioAtDepthArbitrary( } type VisibleRelationshipTransition = `reparent` | `rekey` +type BranchDeliveryOrder = `forward` | `reverse` +const branchDeliveryOrders: ReadonlyArray = [ + `forward`, + `reverse`, +] function otherBranch(branch: 0 | 1): 0 | 1 { return branch === 0 ? 1 : 0 } +function deliverBranches( + branches: readonly [ConnectedBranch, ConnectedBranch], + order: BranchDeliveryOrder, +): readonly [ConnectedBranch, ConnectedBranch] { + return order === `forward` ? branches : [branches[1], branches[0]] +} + +function deliveredBranchIndex( + branch: 0 | 1, + order: BranchDeliveryOrder, +): 0 | 1 { + return order === `forward` ? branch : otherBranch(branch) +} + type VisibleRelationshipScenario = FullRowBatchScenario & { transitionStepIndex: number } @@ -1530,6 +1630,15 @@ function createTransitionHistoryScenario({ row: (typeof insertedRows)[number], level: IncludeDepth, ): void => { + if (kind === `rekey`) { + if (level !== targetLevel + 1) { + throw new Error(`Rekey histories must seed the new child route`) + } + transition(kind, rekeyGroup) + appendStep(level, [{ type: `insert`, value: insertRow(row, level) }]) + return + } + const inserted = insertRow(row, level) appendStep(level, [{ type: `insert`, value: inserted }]) transition(kind, rekeyGroup) @@ -1564,28 +1673,32 @@ function transitionHistoryPlacements( targetLevel: IncludeDepth insertedLevels: readonly [IncludeDepth, IncludeDepth] }> { - // A rekey with two descendant levels is already a known failure. Keep this - // history corpus on the green side of that boundary so the first transition - // cannot mask a later mismatch. + // A rekey needs one child level so it changes relationship membership. It + // also fails when two descendant levels remain. The single-transition matrix + // pins that failure and will turn red when this continuation can be widened. const minimumTargetLevel = firstTransition === `rekey` || secondTransition === `rekey` ? Math.max(1, depth - 1) : 1 + const maximumTargetLevel = + firstTransition === `rekey` || secondTransition === `rekey` + ? depth - 1 + : depth const placements: Array<{ targetLevel: IncludeDepth insertedLevels: readonly [IncludeDepth, IncludeDepth] }> = [] - for (let level = minimumTargetLevel; level <= depth; level++) { + for (let level = minimumTargetLevel; level <= maximumTargetLevel; level++) { const targetLevel = level as IncludeDepth const insertedLevels = ( transition: VisibleRelationshipTransition, ): ReadonlyArray => - // Rekeying disconnects an existing child from the target. Restrict that - // interleave to the target row so its later delete remains observable. - transition === `reparent` && targetLevel < depth - ? [targetLevel, (targetLevel + 1) as IncludeDepth] - : [targetLevel] + transition === `rekey` + ? [(targetLevel + 1) as IncludeDepth] + : targetLevel < depth + ? [targetLevel, (targetLevel + 1) as IncludeDepth] + : [targetLevel] for (const firstInsertedLevel of insertedLevels(firstTransition)) { for (const secondInsertedLevel of insertedLevels(secondTransition)) { @@ -1604,6 +1717,7 @@ function transitionHistoryScenariosArbitrary( depth: IncludeDepth, firstTransition: VisibleRelationshipTransition, secondTransition: VisibleRelationshipTransition, + sourceBranch: 0 | 1, ): fc.Arbitrary> { const placements = transitionHistoryPlacements( depth, @@ -1613,7 +1727,6 @@ function transitionHistoryScenariosArbitrary( return fc .record({ - sourceBranch: fc.constantFrom<0 | 1>(0, 1), ...generatedBranchArbitraries, firstRekeyGroup: fc.integer({ min: 2_100, max: 2_300 }), secondRekeyGroup: fc.integer({ min: 2_400, max: 2_600 }), @@ -1624,7 +1737,6 @@ function transitionHistoryScenariosArbitrary( }) .map( ({ - sourceBranch, leftIdBase, leftGroupBase, rightIdBase, @@ -1666,16 +1778,22 @@ function expectEveryHistoryStepVisible( stepIndex < scenario.steps.length; stepIndex++ ) { - const before = recomputeFullRowBatchScenario(scenario, stepIndex) - const after = recomputeFullRowBatchScenario(scenario, stepIndex + 1) + const before = relationshipOnly( + recomputeFullRowBatchScenario(scenario, stepIndex), + ) + const after = relationshipOnly( + recomputeFullRowBatchScenario(scenario, stepIndex + 1), + ) expect(after).not.toEqual(before) } } type ClassifiedHistoryScenario = { control: FullRowBatchScenario + greenVariants?: ReadonlyArray candidate: FullRowBatchScenario candidateCheckpoint: number + classify: (difference: AssertionDifference) => boolean } type RekeyRouteReuseOptions = { @@ -1688,7 +1806,7 @@ type RekeyRouteReuseOptions = { insertedPosition: number } -function createRekeyRouteReuseScenarios({ +function createRekeyRouteReuseFixture({ depth, sourceBranch, branches, @@ -1696,7 +1814,12 @@ function createRekeyRouteReuseScenarios({ insertedId, insertedValue, insertedPosition, -}: RekeyRouteReuseOptions): ClassifiedHistoryScenario { +}: RekeyRouteReuseOptions): { + prefix: Array + rekey: FullRowBatchStep + reuse: FullRowBatchStep + classify: (difference: AssertionDifference) => boolean +} { const targetLevel = (depth - 1) as IncludeDepth assertDisjointRelationshipKeys(depth, branches, { extraIds: [insertedId], @@ -1733,16 +1856,74 @@ function createRekeyRouteReuseScenarios({ ], } + const retiredRowId = source.idBase + targetLevel + const childId = source.idBase + targetLevel + 1 + return { - control: { depth, steps: [...prefix, insertOldRoute] }, - candidate: { depth, steps: [...prefix, rekey, insertOldRoute] }, + prefix, + rekey, + reuse: insertOldRoute, + classify: (difference) => + classifyUnexpectedSharedChild( + difference, + retiredRowId, + insertedId, + childId, + ), + } +} + +function createRekeyRouteReuseScenarios( + options: RekeyRouteReuseOptions, +): ClassifiedHistoryScenario { + const { depth } = options + const { prefix, rekey, reuse, classify } = + createRekeyRouteReuseFixture(options) + + return { + control: { depth, steps: [...prefix, reuse] }, + candidate: { depth, steps: [...prefix, rekey, reuse] }, candidateCheckpoint: prefix.length + 2, + classify, + } +} + +function createIntraBatchRekeyRouteReuseScenarios( + options: RekeyRouteReuseOptions, +): ClassifiedHistoryScenario { + const { depth } = options + const { prefix, rekey, reuse, classify } = + createRekeyRouteReuseFixture(options) + if (rekey.level === 0 || rekey.level !== reuse.level) { + throw new Error(`Intra-batch route reuse must share a child level`) + } + + return { + control: { + depth, + steps: [ + ...prefix, + { level: rekey.level, changes: [...reuse.changes, ...rekey.changes] }, + ], + }, + candidate: { + depth, + steps: [ + ...prefix, + { level: rekey.level, changes: [...rekey.changes, ...reuse.changes] }, + ], + }, + candidateCheckpoint: prefix.length + 1, + classify, } } function rekeyRouteReuseScenarioArbitrary( depth: 2 | 3 | 4, sourceBranch: 0 | 1, + createScenario: ( + options: RekeyRouteReuseOptions, + ) => ClassifiedHistoryScenario = createRekeyRouteReuseScenarios, ): fc.Arbitrary { return fc .record({ @@ -1763,7 +1944,7 @@ function rekeyRouteReuseScenarioArbitrary( insertedValue, insertedPosition, }) => - createRekeyRouteReuseScenarios({ + createScenario({ depth, sourceBranch, branches: createGeneratedBranches({ @@ -1852,19 +2033,48 @@ function createMovedChildReplacementScenarios({ }, ], } + const atomicReplacement = ( + order: `delete-first` | `insert-first`, + ): FullRowBatchScenario => ({ + depth, + steps: [ + ...prefix, + reparent, + { + level: childLevel, + changes: + order === `delete-first` + ? [ + { type: `delete`, value: existingChild }, + { type: `insert`, value: replacementChild }, + ] + : [ + { type: `insert`, value: replacementChild }, + { type: `delete`, value: existingChild }, + ], + }, + ], + }) + const grandchildId = source.idBase + targetLevel + 2 return { control: { depth, steps: [...prefix, ...replaceChild] }, + greenVariants: [ + atomicReplacement(`delete-first`), + atomicReplacement(`insert-first`), + ], candidate: { depth, steps: [...prefix, reparent, ...replaceChild] }, candidateCheckpoint: prefix.length + 3, + classify: (difference) => + classifyMissingReplacementChild(difference, insertedId, grandchildId), } } -function movedChildReplacementScenarioArbitrary( +function movedChildReplacementMirrorsArbitrary( depth: 3 | 4, targetLevel: IncludeDepth, sourceBranch: 0 | 1, -): fc.Arbitrary { +): fc.Arbitrary> { return fc .record({ ...generatedBranchArbitraries, @@ -1881,24 +2091,123 @@ function movedChildReplacementScenarioArbitrary( insertedId, insertedValue, insertedPosition, - }) => - createMovedChildReplacementScenarios({ - depth, - targetLevel, - sourceBranch, - branches: createGeneratedBranches({ - leftIdBase, - leftGroupBase, - rightIdBase, - rightGroupBase, - }), - insertedId, - insertedValue, - insertedPosition, - }), + }) => { + const branches = createGeneratedBranches({ + leftIdBase, + leftGroupBase, + rightIdBase, + rightGroupBase, + }) + const createMirror = (deliveryOrder: BranchDeliveryOrder) => + createMovedChildReplacementScenarios({ + depth, + targetLevel, + sourceBranch: deliveredBranchIndex(sourceBranch, deliveryOrder), + branches: deliverBranches(branches, deliveryOrder), + insertedId, + insertedValue, + insertedPosition, + }) + + return { + forward: createMirror(`forward`), + reverse: createMirror(`reverse`), + } + }, ) } +function createSharedRouteLifetimeScenarios( + parentLevel: 0 | 1, +): ClassifiedHistoryScenario { + if (parentLevel === 0) { + const departed = batchRoot(100, 600, 100, 0) + const remaining = batchRoot(1_100, 600, 1_100, 1) + const child = { ...batchChild(101, 600, 101, 0), group: 601 } + const controlSteps: Array = [ + { level: 0, changes: [{ type: `insert`, value: departed }] }, + { level: 1, changes: [{ type: `insert`, value: child }] }, + { + level: 0, + changes: [{ type: `update`, value: { ...departed, group: 700 } }], + }, + { + level: 1, + changes: [ + { type: `update`, value: { ...child, value: child.value + 1 } }, + ], + }, + ] + const candidateSteps: Array = [ + { + level: 0, + changes: [ + { type: `insert`, value: departed }, + { type: `insert`, value: remaining }, + ], + }, + ...controlSteps.slice(1), + ] + + return { + control: { depth: 1, steps: controlSteps }, + candidate: { depth: 1, steps: candidateSteps }, + candidateCheckpoint: candidateSteps.length, + classify: (difference) => + classifyUnexpectedSharedChild( + difference, + departed.id, + remaining.id, + child.id, + ), + } + } + + const root = batchRoot(100, 600, 100, 0) + const departed = { ...batchChild(101, 600, 101, 0), group: 601 } + const remaining = { ...batchChild(1_101, 600, 1_101, 1), group: 601 } + const child = { ...batchChild(102, 601, 102, 0), group: 602 } + const controlSteps: Array = [ + { level: 0, changes: [{ type: `insert`, value: root }] }, + { level: 1, changes: [{ type: `insert`, value: departed }] }, + { level: 2, changes: [{ type: `insert`, value: child }] }, + { + level: 1, + changes: [{ type: `update`, value: { ...departed, group: 700 } }], + }, + { + level: 2, + changes: [ + { type: `update`, value: { ...child, value: child.value + 1 } }, + ], + }, + ] + const candidateSteps: Array = [ + controlSteps[0]!, + { + level: 1, + changes: [ + { type: `insert`, value: departed }, + { type: `insert`, value: remaining }, + ], + }, + ...controlSteps.slice(2), + ] + + return { + control: { depth: 2, steps: controlSteps }, + candidate: { depth: 2, steps: candidateSteps }, + candidateCheckpoint: candidateSteps.length, + classify: (difference) => + classifyUnexpectedSharedChild( + difference, + departed.id, + remaining.id, + child.id, + ), + } +} + async function expectFullRowBatchScenarioMatches({ depth, steps, @@ -1912,21 +2221,30 @@ async function expectFullRowBatchScenarioMatches({ async function expectClassifiedHistoryFailure({ control, + greenVariants = [], candidate, candidateCheckpoint, + classify, }: ClassifiedHistoryScenario): Promise { await expectFullRowBatchScenarioMatches(control) + for (const greenVariant of greenVariants) { + await expectFullRowBatchScenarioMatches(greenVariant) + } await expectAssertionFailure( () => expectFullRowBatchScenarioMatches(candidate), - { checkpoint: candidateCheckpoint }, + { checkpoint: candidateCheckpoint, classify }, )() } async function expectClassifiedHistoryMatches({ control, + greenVariants = [], candidate, }: ClassifiedHistoryScenario): Promise { await expectFullRowBatchScenarioMatches(control) + for (const greenVariant of greenVariants) { + await expectFullRowBatchScenarioMatches(greenVariant) + } await expectFullRowBatchScenarioMatches(candidate) } @@ -2436,6 +2754,7 @@ const { control: rekeyRouteReuseControl, candidate: rekeyRouteResurrectionScenario, candidateCheckpoint: rekeyRouteResurrectionCheckpoint, + classify: classifyRekeyRouteResurrection, } = createRekeyRouteReuseScenarios({ depth: 2, sourceBranch: 0, @@ -2452,6 +2771,7 @@ const { control: childReplacementControl, candidate: movedSubtreeChildReplacementScenario, candidateCheckpoint: movedSubtreeChildReplacementCheckpoint, + classify: classifyMovedSubtreeChildReplacement, } = createMovedChildReplacementScenarios({ depth: 3, targetLevel: 1, @@ -2552,7 +2872,10 @@ describe(`includes recompute oracle`, () => { `discovered trace: reusing a rekeyed row's old route does not resurrect its child`, expectAssertionFailure( () => expectFullRowBatchScenarioMatches(rekeyRouteResurrectionScenario), - { checkpoint: rekeyRouteResurrectionCheckpoint }, + { + checkpoint: rekeyRouteResurrectionCheckpoint, + classify: classifyRekeyRouteResurrection, + }, ), ) @@ -2566,7 +2889,10 @@ describe(`includes recompute oracle`, () => { expectAssertionFailure( () => expectFullRowBatchScenarioMatches(movedSubtreeChildReplacementScenario), - { checkpoint: movedSubtreeChildReplacementCheckpoint }, + { + checkpoint: movedSubtreeChildReplacementCheckpoint, + classify: classifyMovedSubtreeChildReplacement, + }, ), ) @@ -2584,19 +2910,42 @@ describe(`includes recompute oracle`, () => { `discovered histories: reusing a retired route at depth ${depth}, branch ${sourceBranch}`, expectClassifiedHistoryFailure, ) + + fcTest.prop( + [ + rekeyRouteReuseScenarioArbitrary( + depth, + sourceBranch, + createIntraBatchRekeyRouteReuseScenarios, + ), + ], + { + numRuns: 4, + seed: 1733 + depth * 10 + sourceBranch, + }, + )( + `discovered histories: intra-batch rekey then retired-route reuse at depth ${depth}, branch ${sourceBranch}`, + expectClassifiedHistoryFailure, + ) } } + for (const parentLevel of [0, 1] as const) { + fcTest( + `discovered trace: a departed level-${parentLevel} shared-route subscriber receives later child updates`, + () => + expectClassifiedHistoryFailure( + createSharedRouteLifetimeScenarios(parentLevel), + ), + ) + } + for (const depth of [3, 4] as const) { for (let targetLevel = 1; targetLevel <= depth - 2; targetLevel++) { for (const sourceBranch of [0, 1] as const) { - // (depth 4, target level 2, source branch 1) is the observed green - // boundary. Pin it so this expected-failure class cannot expand unseen. - const expectsFailure = - depth !== 4 || targetLevel !== 2 || sourceBranch !== 1 fcTest.prop( [ - movedChildReplacementScenarioArbitrary( + movedChildReplacementMirrorsArbitrary( depth, targetLevel as IncludeDepth, sourceBranch, @@ -2604,15 +2953,28 @@ describe(`includes recompute oracle`, () => { ], { numRuns: 4, - seed: 1727 + depth * 10 + targetLevel * 2 + sourceBranch, + seed: 1727 + depth * 100 + targetLevel * 10 + sourceBranch, }, )( - expectsFailure - ? `discovered histories: replacing a moved child at depth ${depth}, level ${targetLevel}, branch ${sourceBranch}` - : `matches recomputation when replacing a moved child at depth ${depth}, level ${targetLevel}, branch ${sourceBranch}`, - expectsFailure - ? expectClassifiedHistoryFailure - : expectClassifiedHistoryMatches, + `classifies forward/reverse delivery mirrors when replacing a moved child at depth ${depth}, level ${targetLevel}, source ${sourceBranch}`, + async (scenarios) => { + for (const deliveryOrder of branchDeliveryOrders) { + const deliveredSource = deliveredBranchIndex( + sourceBranch, + deliveryOrder, + ) + // At depth 4, level 2, replacement stays green only when the + // moved branch was delivered second. The mirrors share one + // generated fixture, so delivery order is the only difference. + const expectsFailure = + depth !== 4 || targetLevel !== 2 || deliveredSource !== 1 + await ( + expectsFailure + ? expectClassifiedHistoryFailure + : expectClassifiedHistoryMatches + )(scenarios[deliveryOrder]) + } + }, ) } } @@ -2703,27 +3065,42 @@ describe(`includes recompute oracle`, () => { ] for (const [firstIndex, firstTransition] of transitions.entries()) { for (const [secondIndex, secondTransition] of transitions.entries()) { - fcTest.prop( - [ - transitionHistoryScenariosArbitrary( - depth, - firstTransition, - secondTransition, - ), - ], - { - numRuns: 3, - seed: 1725 + depth * 10 + firstIndex * 2 + secondIndex, - }, - )( - `matches recomputation for ${firstTransition} → ${secondTransition} histories at depth ${depth}`, - async (scenarios) => { - for (const scenario of scenarios) { - expectEveryHistoryStepVisible(scenario) - await expectFullRowBatchScenarioMatches(scenario) - } - }, - ) + if ( + transitionHistoryPlacements(depth, firstTransition, secondTransition) + .length === 0 + ) { + continue + } + + for (const sourceBranch of [0, 1] as const) { + fcTest.prop( + [ + transitionHistoryScenariosArbitrary( + depth, + firstTransition, + secondTransition, + sourceBranch, + ), + ], + { + numRuns: 3, + seed: + 1725 + + depth * 100 + + firstIndex * 10 + + secondIndex * 2 + + sourceBranch, + }, + )( + `matches recomputation for ${firstTransition} → ${secondTransition} histories at depth ${depth}, branch ${sourceBranch}`, + async (scenarios) => { + for (const scenario of scenarios) { + expectEveryHistoryStepVisible(scenario) + await expectFullRowBatchScenarioMatches(scenario) + } + }, + ) + } } } }