From 6d5f278fd21d53c6e9619492ba6dc02d9948ee56 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 14 Aug 2026 13:42:41 -0600 Subject: [PATCH 1/3] test(db): cover includes route lifecycle histories --- .../query/includes-oracle.property.test.ts | 857 ++++++++++++++++++ 1 file changed, 857 insertions(+) diff --git a/packages/db/tests/query/includes-oracle.property.test.ts b/packages/db/tests/query/includes-oracle.property.test.ts index d4ce95761..5a86ea426 100644 --- a/packages/db/tests/query/includes-oracle.property.test.ts +++ b/packages/db/tests/query/includes-oracle.property.test.ts @@ -62,6 +62,7 @@ type OracleNode = RootRow & { type RelationshipNode = { id: number + value?: unknown children?: unknown } @@ -126,6 +127,22 @@ function classifyMissingReplacementChild( ) } +function classifyMissingSharedRouteSnapshot( + { actual, expected }: AssertionDifference, + enteringParentId: number, + existingParentId: number, + childId: number, +): boolean { + return ( + findRelationshipNode(actual, enteringParentId) !== undefined && + findRelationshipNode(expected, enteringParentId) !== undefined && + !hasDirectChild(actual, enteringParentId, childId) && + hasDirectChild(expected, enteringParentId, childId) && + hasDirectChild(actual, existingParentId, childId) && + hasDirectChild(expected, existingParentId, childId) + ) +} + type RelationshipProjectionNode = { id: number children?: Array @@ -1788,6 +1805,648 @@ function expectEveryHistoryStepVisible( } } +type RouteDestination = { + strategy: `fresh` | `restore` | `merge` | `split` | `retired` + route: number +} + +type RouteTransitionDescriptor = { + row: 0 | 1 + destination: RouteDestination + stepsBefore?: ReadonlyArray +} & ( + | { kind: `reparent`; level: IncludeDepth } + | { kind: `rekey`; level: 0 | IncludeDepth } +) + +type RouteLifecycleScenario = FullRowBatchScenario & { + transitionStepIndexes: ReadonlyArray +} + +type RouteLifecycleScenarioOptions = { + depth: IncludeDepth + branches: readonly [ConnectedBranch, ConnectedBranch] + descriptors: ReadonlyArray + prefixSteps?: ReadonlyArray + trailingSteps?: ReadonlyArray +} + +function rowAt( + branches: readonly [ConnectedBranch, ConnectedBranch], + row: 0 | 1, + level: 0 | IncludeDepth, +): number { + return branches[row].idBase + level +} + +function applyRouteLifecycleStep( + step: FullRowBatchStep, + roots: Map, + levels: Array>, + seenRoutes: Set, + retiredRouteOwners: Map, +): void { + const rows = step.level === 0 ? roots : levels[step.level - 1]! + const previousRouteOwners = new Map>() + + for (const change of step.changes) { + const previous = rows.get(change.value.id) + if (!previous) continue + const owners = previousRouteOwners.get(previous.group) ?? new Set() + owners.add(previous.id) + previousRouteOwners.set(previous.group, owners) + } + + updateFullRowBatchModels(step, roots, levels) + + for (const row of rows.values()) { + seenRoutes.add(row.group) + retiredRouteOwners.delete(row.group) + } + for (const [route, previousOwners] of previousRouteOwners) { + if ([...rows.values()].some((row) => row.group === route)) continue + if (previousOwners.size === 1) { + retiredRouteOwners.set(route, [...previousOwners][0]!) + } else { + retiredRouteOwners.delete(route) + } + } +} + +function createRouteLifecycleScenario({ + depth, + branches, + descriptors, + prefixSteps = createConnectedBatchBranches(depth, branches), + trailingSteps = [], +}: RouteLifecycleScenarioOptions): RouteLifecycleScenario { + const seenRoutes = new Set() + const retiredRouteOwners = new Map() + + const steps = [...prefixSteps] + const roots = new Map() + const levels = Array.from({ length: 4 }, () => new Map()) + for (const step of steps) { + applyRouteLifecycleStep(step, roots, levels, seenRoutes, retiredRouteOwners) + } + + const transitionStepIndexes: Array = [] + for (const descriptor of descriptors) { + for (const step of descriptor.stepsBefore ?? []) { + steps.push(step) + applyRouteLifecycleStep( + step, + roots, + levels, + seenRoutes, + retiredRouteOwners, + ) + } + + const id = rowAt(branches, descriptor.row, descriptor.level) + const rowsAtLevel = + descriptor.level === 0 + ? [...roots.values()] + : [...levels[descriptor.level - 1]!.values()] + const current = rowsAtLevel.find((row) => row.id === id) + if (!current) throw new Error(`Missing transition row ${id}`) + const currentRoute = + descriptor.kind === `rekey` + ? current.group + : (current as ChildRow).parentGroup + const destinationRows = + descriptor.kind === `rekey` + ? rowsAtLevel + : descriptor.level === 1 + ? [...roots.values()] + : [...levels[descriptor.level - 2]!.values()] + const destinationIsLive = destinationRows.some( + (row) => row.group === descriptor.destination.route, + ) + const currentRouteUsers = rowsAtLevel.filter((row) => + descriptor.kind === `rekey` + ? row.group === currentRoute + : (row as ChildRow).parentGroup === currentRoute, + ).length + const retiredOwner = retiredRouteOwners.get(descriptor.destination.route) + + switch (descriptor.destination.strategy) { + case `fresh`: + if (seenRoutes.has(descriptor.destination.route)) { + throw new Error(`fresh route must never have been used`) + } + break + case `restore`: + if (destinationIsLive || retiredOwner !== id) { + throw new Error(`restore route must have been retired by this row`) + } + break + case `merge`: + if ( + !destinationIsLive || + descriptor.destination.route === currentRoute + ) { + throw new Error(`merge route must be live and different`) + } + break + case `split`: + if (seenRoutes.has(descriptor.destination.route)) { + throw new Error(`split destination must be unused`) + } + if (currentRouteUsers < 2) { + throw new Error(`split source route must be shared`) + } + break + case `retired`: + if ( + destinationIsLive || + retiredOwner === undefined || + retiredOwner === id + ) { + throw new Error(`retired route must have been retired by another row`) + } + break + } + + let step: FullRowBatchStep + if (descriptor.level === 0) { + const root = roots.get(id) + if (!root) throw new Error(`Missing transition root ${id}`) + step = { + level: 0, + changes: [ + { + type: `update`, + value: { ...root, group: descriptor.destination.route }, + }, + ], + } + } else { + const child = levels[descriptor.level - 1]!.get(id) + if (!child) throw new Error(`Missing transition child ${id}`) + step = { + level: descriptor.level, + changes: [ + { + type: `update`, + value: + descriptor.kind === `rekey` + ? { ...child, group: descriptor.destination.route } + : { + ...child, + parentGroup: descriptor.destination.route, + }, + }, + ], + } + } + transitionStepIndexes.push(steps.length) + steps.push(step) + applyRouteLifecycleStep(step, roots, levels, seenRoutes, retiredRouteOwners) + } + + steps.push(...trailingSteps) + return { depth, steps, transitionStepIndexes } +} + +function expectEveryRouteTransitionVisible( + scenario: RouteLifecycleScenario, +): void { + for (const stepIndex of scenario.transitionStepIndexes) { + const before = relationshipOnly( + recomputeFullRowBatchScenario(scenario, stepIndex), + ) + const after = relationshipOnly( + recomputeFullRowBatchScenario(scenario, stepIndex + 1), + ) + expect(after).not.toEqual(before) + } +} + +const independentTransitionShapes = [ + `ancestor-descendant`, + `descendant-ancestor`, + `sibling`, + `cross-branch`, + `root`, +] as const + +type IndependentTransitionShape = (typeof independentTransitionShapes)[number] + +function independentFreshRoutesArbitrary( + shape: IndependentTransitionShape, +): fc.Arbitrary { + const first = fc.integer({ min: 2_100, max: 2_400 }) + if (shape === `sibling`) { + return fc.tuple(first, fc.integer({ min: 2_500, max: 2_800 })) + } + if (shape === `ancestor-descendant` || shape === `root`) { + return first.map((route) => [route, 2_500] as const) + } + return fc.constant([2_100, 2_500] as const) +} + +function independentTransitionDescriptors( + shape: IndependentTransitionShape, + branches: readonly [ConnectedBranch, ConnectedBranch], + freshRoutes: readonly [number, number], +): ReadonlyArray { + switch (shape) { + case `ancestor-descendant`: + return [ + { + kind: `reparent`, + level: 1, + row: 0, + destination: { + strategy: `merge`, + route: branches[1].groupBase, + }, + }, + { + kind: `rekey`, + level: 2, + row: 0, + destination: { strategy: `fresh`, route: freshRoutes[0] }, + }, + ] + case `descendant-ancestor`: + return [ + { + kind: `reparent`, + level: 2, + row: 0, + destination: { + strategy: `merge`, + route: branches[1].groupBase + 1, + }, + }, + { + kind: `reparent`, + level: 1, + row: 0, + destination: { + strategy: `merge`, + route: branches[1].groupBase, + }, + }, + ] + case `sibling`: + return [ + { + kind: `rekey`, + level: 2, + row: 0, + destination: { strategy: `fresh`, route: freshRoutes[0] }, + }, + { + kind: `rekey`, + level: 2, + row: 1, + destination: { strategy: `fresh`, route: freshRoutes[1] }, + }, + ] + case `cross-branch`: + return [ + { + kind: `reparent`, + level: 2, + row: 0, + destination: { + strategy: `merge`, + route: branches[1].groupBase + 1, + }, + }, + { + kind: `reparent`, + level: 2, + row: 1, + destination: { + strategy: `merge`, + route: branches[0].groupBase + 1, + }, + }, + ] + case `root`: + return [ + { + kind: `rekey`, + level: 0, + row: 0, + destination: { strategy: `fresh`, route: freshRoutes[0] }, + }, + { + kind: `reparent`, + level: 1, + row: 1, + destination: { strategy: `merge`, route: freshRoutes[0] }, + }, + ] + } +} + +function independentTransitionScenarioArbitrary( + shape: IndependentTransitionShape, +): fc.Arbitrary { + return fc + .record({ + ...generatedBranchArbitraries, + freshRoutes: independentFreshRoutesArbitrary(shape), + }) + .map(({ freshRoutes, ...branchOptions }) => { + const branches = createGeneratedBranches(branchOptions) + return createRouteLifecycleScenario({ + depth: 3, + branches, + descriptors: independentTransitionDescriptors( + shape, + branches, + freshRoutes, + ), + }) + }) +} + +const destinationHistories = [ + `fresh`, + `restore`, + `merge-split`, + `retired`, +] as const + +type DestinationHistory = (typeof destinationHistories)[number] + +function destinationHistoryDescriptors( + history: DestinationHistory, + branches: readonly [ConnectedBranch, ConnectedBranch], + freshRoute: number, +): ReadonlyArray { + const originalRoute = branches[0].groupBase + 2 + const sharedRoute = branches[1].groupBase + 2 + const first: RouteTransitionDescriptor = { + kind: `rekey`, + level: 2, + row: 0, + destination: { strategy: `fresh`, route: freshRoute }, + } + + switch (history) { + case `fresh`: + return [first] + case `restore`: + return [ + first, + { + ...first, + destination: { strategy: `restore`, route: originalRoute }, + }, + ] + case `merge-split`: + return [ + { + ...first, + destination: { strategy: `merge`, route: sharedRoute }, + }, + { + ...first, + destination: { strategy: `split`, route: freshRoute }, + }, + ] + case `retired`: + return [ + first, + { + kind: `rekey`, + level: 2, + row: 1, + destination: { strategy: `retired`, route: originalRoute }, + }, + ] + } +} + +function destinationHistoryScenarioArbitrary( + history: DestinationHistory, +): fc.Arbitrary { + return fc + .record({ + ...generatedBranchArbitraries, + freshRoute: fc.integer({ min: 2_100, max: 2_400 }), + }) + .map(({ freshRoute, ...branchOptions }) => { + const branches = createGeneratedBranches(branchOptions) + return createRouteLifecycleScenario({ + depth: 3, + branches, + descriptors: destinationHistoryDescriptors( + history, + branches, + freshRoute, + ), + }) + }) +} + +function relationshipNodeValue(value: unknown, id: number): unknown { + return findRelationshipNode(value, id)?.value +} + +function classifyMissingOrStaleChild( + { actual, expected }: AssertionDifference, + parentId: number, + childId: number, + expectedValue: number, +): boolean { + return ( + hasDirectChild(expected, parentId, childId) && + relationshipNodeValue(expected, childId) === expectedValue && + (!hasDirectChild(actual, parentId, childId) || + relationshipNodeValue(actual, childId) !== expectedValue) + ) +} + +function createInitiallySharedRoutePrefix( + depth: IncludeDepth, + parentLevel: 0 | 1 | 2, + branches: readonly [ConnectedBranch, ConnectedBranch], + enteringRow: 0 | 1 = 1, +): Array { + const enteringId = rowAt(branches, enteringRow, parentLevel) + const sharedRoute = branches[otherBranch(enteringRow)].groupBase + parentLevel + return createConnectedBatchBranches(depth, branches).map((step) => { + if (step.level !== parentLevel) return step + + if (step.level === 0) { + return { + level: 0, + changes: step.changes.map((change) => + change.value.id === enteringId + ? { ...change, value: { ...change.value, group: sharedRoute } } + : change, + ), + } + } + + return { + level: step.level, + changes: step.changes.map((change) => + change.value.id === enteringId + ? { ...change, value: { ...change.value, group: sharedRoute } } + : change, + ), + } + }) +} + +function createMergeIntoSharedRouteScenarios( + parentLevel: 0 | 1 | 2, + enteringRow: 0 | 1, +): ClassifiedHistoryScenario { + const depth = (parentLevel + 1) as IncludeDepth + const branches = transitionHistoryBranches + const childLevel = depth + const existingRow = otherBranch(enteringRow) + const sharedRoute = branches[existingRow].groupBase + parentLevel + const enteringParentId = rowAt(branches, enteringRow, parentLevel) + const childId = rowAt(branches, existingRow, childLevel) + const candidate = createRouteLifecycleScenario({ + depth, + branches, + descriptors: [ + { + kind: `rekey`, + level: parentLevel, + row: enteringRow, + destination: { strategy: `merge`, route: sharedRoute }, + }, + ], + }) + expectEveryRouteTransitionVisible(candidate) + + return { + control: { + depth, + steps: createInitiallySharedRoutePrefix( + depth, + parentLevel, + branches, + enteringRow, + ), + }, + candidate, + candidateCheckpoint: candidate.steps.length, + classify: (difference) => + classifyMissingSharedRouteSnapshot( + difference, + enteringParentId, + rowAt(branches, existingRow, parentLevel), + childId, + ), + } +} + +function createSharedRouteLastSubscriberScenario( + parentLevel: 0 | 1 | 2, +): FullRowBatchScenario { + const depth = (parentLevel + 1) as IncludeDepth + const branches = transitionHistoryBranches + const childLevel = depth + const sharedRoute = branches[0].groupBase + parentLevel + const childId = rowAt(branches, 0, childLevel) + const child = { + ...batchChild(childId, sharedRoute, childId, 0), + group: branches[0].groupBase + childLevel, + } + const descriptors: ReadonlyArray = [ + { + kind: `rekey`, + level: parentLevel, + row: 0, + destination: { strategy: `split`, route: 2_100 + parentLevel }, + }, + { + kind: `rekey`, + level: parentLevel, + row: 1, + destination: { strategy: `fresh`, route: 2_200 + parentLevel }, + }, + ] + const scenario = createRouteLifecycleScenario({ + depth, + branches, + descriptors, + prefixSteps: createInitiallySharedRoutePrefix(depth, parentLevel, branches), + trailingSteps: [ + { + level: childLevel, + changes: [ + { type: `update`, value: { ...child, value: child.value + 1 } }, + ], + }, + ], + }) + expectEveryRouteTransitionVisible(scenario) + return scenario +} + +function createSnapshotOnResubscribeScenarios( + parentLevel: 0 | 1 | 2, +): ClassifiedHistoryScenario { + const depth = (parentLevel + 1) as IncludeDepth + const branches = transitionHistoryBranches + const childLevel = depth + const parentId = rowAt(branches, 0, parentLevel) + const originalRoute = branches[0].groupBase + parentLevel + const childId = rowAt(branches, 0, childLevel) + const child = { + ...batchChild(childId, originalRoute, childId, 0), + group: branches[0].groupBase + childLevel, + } + const updatedChild = { ...child, value: child.value + 1 } + const prefix = createConnectedBatchBranches(depth, branches) + const childUpdate: FullRowBatchStep = { + level: childLevel, + changes: [{ type: `update`, value: updatedChild }], + } + const candidate = createRouteLifecycleScenario({ + depth, + branches, + descriptors: [ + { + kind: `rekey`, + level: parentLevel, + row: 0, + destination: { strategy: `fresh`, route: 2_300 + parentLevel }, + }, + { + kind: `rekey`, + level: parentLevel, + row: 0, + destination: { strategy: `restore`, route: originalRoute }, + stepsBefore: [childUpdate], + }, + ], + }) + expectEveryRouteTransitionVisible(candidate) + const control: FullRowBatchScenario = { + depth, + steps: [...prefix, childUpdate], + } + + return { + control, + candidate, + candidateCheckpoint: candidate.steps.length, + classify: (difference) => + classifyMissingOrStaleChild( + difference, + parentId, + childId, + updatedChild.value, + ), + } +} + type ClassifiedHistoryScenario = { control: FullRowBatchScenario greenVariants?: ReadonlyArray @@ -2783,6 +3442,204 @@ const { }) describe(`includes recompute oracle`, () => { + for (const [shapeIndex, shape] of independentTransitionShapes.entries()) { + fcTest.prop([independentTransitionScenarioArbitrary(shape)], { + numRuns: 4, + seed: 1734 + shapeIndex, + })( + `matches recomputation for independent ${shape} relationship targets`, + async (scenario) => { + expectEveryRouteTransitionVisible(scenario) + await expectFullRowBatchScenarioMatches(scenario) + }, + ) + } + + for (const [historyIndex, history] of destinationHistories.entries()) { + fcTest.prop([destinationHistoryScenarioArbitrary(history)], { + numRuns: 4, + seed: 1740 + historyIndex, + })( + `matches recomputation for the ${history} route destination history`, + async (scenario) => { + expectEveryRouteTransitionVisible(scenario) + await expectFullRowBatchScenarioMatches(scenario) + }, + ) + } + + for (const parentLevel of [0, 1, 2] as const) { + for (const enteringRow of [0, 1] as const) { + const expectsFailure = parentLevel === 0 + fcTest( + expectsFailure + ? `discovered trace: root ${enteringRow} entering a live shared route receives its snapshot` + : `matches recomputation when level-${parentLevel} row ${enteringRow} enters a live shared route`, + () => + (expectsFailure + ? expectClassifiedHistoryFailure + : expectClassifiedHistoryMatches)( + createMergeIntoSharedRouteScenarios(parentLevel, enteringRow), + ), + ) + } + + fcTest( + `matches recomputation when the last level-${parentLevel} shared-route subscriber leaves`, + () => + expectFullRowBatchScenarioMatches( + createSharedRouteLastSubscriberScenario(parentLevel), + ), + ) + + fcTest( + `matches recomputation after a level-${parentLevel} route resubscribes`, + () => + expectClassifiedHistoryMatches( + createSnapshotOnResubscribeScenarios(parentLevel), + ), + ) + } + + fcTest(`rejects invalid route destination strategies`, () => { + const branches = transitionHistoryBranches + const ownRoute = branches[0].groupBase + 2 + const otherRoute = branches[1].groupBase + 2 + const freshRoute = 2_100 + const invalidCases: ReadonlyArray<{ + descriptors: ReadonlyArray + message: RegExp + }> = [ + { + descriptors: [ + { + kind: `rekey`, + level: 2, + row: 0, + destination: { strategy: `fresh`, route: otherRoute }, + }, + ], + message: /fresh route must never have been used/, + }, + { + descriptors: [ + { + kind: `rekey`, + level: 2, + row: 0, + destination: { strategy: `restore`, route: ownRoute }, + }, + ], + message: /restore route must have been retired by this row/, + }, + { + descriptors: [ + { + kind: `rekey`, + level: 2, + row: 0, + destination: { strategy: `fresh`, route: freshRoute }, + }, + { + kind: `rekey`, + level: 2, + row: 1, + destination: { strategy: `restore`, route: ownRoute }, + }, + ], + message: /restore route must have been retired by this row/, + }, + { + descriptors: [ + { + kind: `rekey`, + level: 2, + row: 0, + destination: { strategy: `merge`, route: freshRoute }, + }, + ], + message: /merge route must be live and different/, + }, + { + descriptors: [ + { + kind: `rekey`, + level: 2, + row: 0, + destination: { strategy: `merge`, route: ownRoute }, + }, + ], + message: /merge route must be live and different/, + }, + { + descriptors: [ + { + kind: `rekey`, + level: 2, + row: 0, + destination: { strategy: `split`, route: freshRoute }, + }, + ], + message: /split source route must be shared/, + }, + { + descriptors: [ + { + kind: `rekey`, + level: 2, + row: 0, + destination: { strategy: `merge`, route: otherRoute }, + }, + { + kind: `rekey`, + level: 2, + row: 0, + destination: { strategy: `split`, route: ownRoute }, + }, + ], + message: /split destination must be unused/, + }, + { + descriptors: [ + { + kind: `rekey`, + level: 2, + row: 0, + destination: { strategy: `retired`, route: otherRoute }, + }, + ], + message: /retired route must have been retired by another row/, + }, + { + descriptors: [ + { + kind: `rekey`, + level: 2, + row: 0, + destination: { strategy: `fresh`, route: freshRoute }, + }, + { + kind: `rekey`, + level: 2, + row: 0, + destination: { strategy: `retired`, route: ownRoute }, + }, + ], + message: /retired route must have been retired by another row/, + }, + ] + + for (const invalidCase of invalidCases) { + expect(() => + createRouteLifecycleScenario({ + depth: 3, + branches, + descriptors: invalidCase.descriptors, + }), + ).toThrow(invalidCase.message) + } + }) + fcTest(`rejects overlapping visible relationship keys`, () => { const base = { depth: 4, From d6e4dbd31e5a72cfcd606a7722f094d99cea43d3 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 14 Aug 2026 15:04:08 -0600 Subject: [PATCH 2/3] test(db): tighten route lifecycle oracle --- .../query/includes-oracle.property.test.ts | 276 +++++++++++++++--- 1 file changed, 232 insertions(+), 44 deletions(-) diff --git a/packages/db/tests/query/includes-oracle.property.test.ts b/packages/db/tests/query/includes-oracle.property.test.ts index 5a86ea426..e93f825bb 100644 --- a/packages/db/tests/query/includes-oracle.property.test.ts +++ b/packages/db/tests/query/includes-oracle.property.test.ts @@ -1,3 +1,4 @@ +import { isDeepStrictEqual } from 'node:util' import { fc, test as fcTest } from '@fast-check/vitest' import { describe, expect } from 'vitest' import { @@ -131,15 +132,47 @@ function classifyMissingSharedRouteSnapshot( { actual, expected }: AssertionDifference, enteringParentId: number, existingParentId: number, - childId: number, + childIds: number | ReadonlyArray, ): boolean { + const expectedChildIds = Array.isArray(childIds) ? childIds : [childIds] + const expectedWithoutSnapshot = removeDirectChild( + expected, + enteringParentId, + expectedChildIds, + ) return ( - findRelationshipNode(actual, enteringParentId) !== undefined && - findRelationshipNode(expected, enteringParentId) !== undefined && - !hasDirectChild(actual, enteringParentId, childId) && - hasDirectChild(expected, enteringParentId, childId) && - hasDirectChild(actual, existingParentId, childId) && - hasDirectChild(expected, existingParentId, childId) + expectedChildIds.every( + (childId) => + hasDirectChild(expected, enteringParentId, childId) && + hasDirectChild(actual, existingParentId, childId) && + hasDirectChild(expected, existingParentId, childId), + ) && isDeepStrictEqual(actual, expectedWithoutSnapshot) + ) +} + +function removeDirectChild( + value: unknown, + parentId: number, + childIds: ReadonlyArray, +): unknown { + if (Array.isArray(value)) { + return value.map((entry) => removeDirectChild(entry, parentId, childIds)) + } + if (typeof value !== `object` || value === null) return value + + const isParent = isRelationshipNode(value) && value.id === parentId + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + key, + key === `children` && isParent && Array.isArray(entry) + ? entry + .filter( + (child) => + !isRelationshipNode(child) || !childIds.includes(child.id), + ) + .map((child) => removeDirectChild(child, parentId, childIds)) + : removeDirectChild(entry, parentId, childIds), + ]), ) } @@ -1812,11 +1845,18 @@ type RouteDestination = { type RouteTransitionDescriptor = { row: 0 | 1 - destination: RouteDestination stepsBefore?: ReadonlyArray } & ( - | { kind: `reparent`; level: IncludeDepth } - | { kind: `rekey`; level: 0 | IncludeDepth } + | { + kind: `reparent` + level: IncludeDepth + destination: { strategy: `merge`; route: number } + } + | { + kind: `rekey` + level: 0 | IncludeDepth + destination: RouteDestination + } ) type RouteLifecycleScenario = FullRowBatchScenario & { @@ -1892,6 +1932,12 @@ function createRouteLifecycleScenario({ const transitionStepIndexes: Array = [] for (const descriptor of descriptors) { + const destination = descriptor.destination as RouteDestination + if (descriptor.kind === `reparent` && destination.strategy !== `merge`) { + throw new Error( + `reparent transitions only support live merge destinations`, + ) + } for (const step of descriptor.stepsBefore ?? []) { steps.push(step) applyRouteLifecycleStep( @@ -2073,13 +2119,10 @@ function independentTransitionDescriptors( case `descendant-ancestor`: return [ { - kind: `reparent`, + kind: `rekey`, level: 2, row: 0, - destination: { - strategy: `merge`, - route: branches[1].groupBase + 1, - }, + destination: { strategy: `fresh`, route: freshRoutes[0] }, }, { kind: `reparent`, @@ -2145,6 +2188,34 @@ function independentTransitionDescriptors( } } +function independentTransitionPrefix( + shape: IndependentTransitionShape, + branches: readonly [ConnectedBranch, ConnectedBranch], +): Array { + const prefix = createConnectedBatchBranches(3, branches) + if (shape !== `sibling`) return prefix + + const secondSiblingId = rowAt(branches, 1, 2) + return prefix.map((step) => + step.level === 2 + ? { + ...step, + changes: step.changes.map((change) => + change.value.id === secondSiblingId + ? { + ...change, + value: { + ...change.value, + parentGroup: branches[0].groupBase + 1, + }, + } + : change, + ), + } + : step, + ) +} + function independentTransitionScenarioArbitrary( shape: IndependentTransitionShape, ): fc.Arbitrary { @@ -2158,6 +2229,7 @@ function independentTransitionScenarioArbitrary( return createRouteLifecycleScenario({ depth: 3, branches, + prefixSteps: independentTransitionPrefix(shape, branches), descriptors: independentTransitionDescriptors( shape, branches, @@ -2247,24 +2319,6 @@ function destinationHistoryScenarioArbitrary( }) } -function relationshipNodeValue(value: unknown, id: number): unknown { - return findRelationshipNode(value, id)?.value -} - -function classifyMissingOrStaleChild( - { actual, expected }: AssertionDifference, - parentId: number, - childId: number, - expectedValue: number, -): boolean { - return ( - hasDirectChild(expected, parentId, childId) && - relationshipNodeValue(expected, childId) === expectedValue && - (!hasDirectChild(actual, parentId, childId) || - relationshipNodeValue(actual, childId) !== expectedValue) - ) -} - function createInitiallySharedRoutePrefix( depth: IncludeDepth, parentLevel: 0 | 1 | 2, @@ -2391,11 +2445,10 @@ function createSharedRouteLastSubscriberScenario( function createSnapshotOnResubscribeScenarios( parentLevel: 0 | 1 | 2, -): ClassifiedHistoryScenario { +): Pick { const depth = (parentLevel + 1) as IncludeDepth const branches = transitionHistoryBranches const childLevel = depth - const parentId = rowAt(branches, 0, parentLevel) const originalRoute = branches[0].groupBase + parentLevel const childId = rowAt(branches, 0, childLevel) const child = { @@ -2436,14 +2489,6 @@ function createSnapshotOnResubscribeScenarios( return { control, candidate, - candidateCheckpoint: candidate.steps.length, - classify: (difference) => - classifyMissingOrStaleChild( - difference, - parentId, - childId, - updatedChild.value, - ), } } @@ -2899,7 +2944,10 @@ async function expectClassifiedHistoryMatches({ control, greenVariants = [], candidate, -}: ClassifiedHistoryScenario): Promise { +}: Pick< + ClassifiedHistoryScenario, + `control` | `greenVariants` | `candidate` +>): Promise { await expectFullRowBatchScenarioMatches(control) for (const greenVariant of greenVariants) { await expectFullRowBatchScenarioMatches(greenVariant) @@ -3442,6 +3490,144 @@ const { }) describe(`includes recompute oracle`, () => { + fcTest( + `shared-route snapshot classification rejects extra corruption`, + () => { + const expected = [ + { id: 1, value: 10, children: [{ id: 3, value: 30 }] }, + { id: 2, value: 20, children: [{ id: 3, value: 30 }] }, + ] + const actual = [ + { id: 1, value: 11, children: [] }, + { id: 2, value: 20, children: [{ id: 3, value: 31 }] }, + ] + + expect( + classifyMissingSharedRouteSnapshot({ actual, expected }, 1, 2, 3), + ).toBe(false) + }, + ) + + fcTest(`rejects subscriber lifecycle labels on reparent transitions`, () => { + expect(() => + createRouteLifecycleScenario({ + depth: 3, + branches: transitionHistoryBranches, + descriptors: [ + { + kind: `reparent`, + level: 2, + row: 0, + destination: { strategy: `fresh`, route: 2_100 }, + } as unknown as RouteTransitionDescriptor, + ], + }), + ).toThrow(/reparent transitions only support live merge destinations/) + }) + + fcTest(`the sibling topology targets rows under one parent`, () => { + const branches = transitionHistoryBranches + const prefix = independentTransitionPrefix(`sibling`, branches) + const levelTwo = prefix.find((step) => step.level === 2) + if (!levelTwo || levelTwo.level !== 2) throw new Error(`Missing level 2`) + const first = levelTwo.changes.find( + (change) => change.value.id === rowAt(branches, 0, 2), + ) + const second = levelTwo.changes.find( + (change) => change.value.id === rowAt(branches, 1, 2), + ) + if (!first || !second) throw new Error(`Missing sibling targets`) + + expect(first.value.parentGroup).toBe(second.value.parentGroup) + }) + + fcTest(`the descendant remains attached before its ancestor moves`, () => { + const branches = transitionHistoryBranches + const scenario = createRouteLifecycleScenario({ + depth: 3, + branches, + descriptors: independentTransitionDescriptors( + `descendant-ancestor`, + branches, + [2_100, 2_500], + ), + }) + const ancestorTransitionStep = scenario.transitionStepIndexes[1] + if (ancestorTransitionStep === undefined) { + throw new Error(`Missing ancestor transition`) + } + const beforeAncestorMove = recomputeFullRowBatchScenario( + scenario, + ancestorTransitionStep, + ) + + expect( + hasDirectChild( + beforeAncestorMove, + rowAt(branches, 0, 1), + rowAt(branches, 0, 2), + ), + ).toBe(true) + }) + + fcTest( + `discovered trace: a root entering a live route misses its ordered snapshot`, + async () => { + const branches = transitionHistoryBranches + const prefix = createConnectedBatchBranches(1, branches) + const childStep = prefix.find((step) => step.level === 1) + if (!childStep || childStep.level !== 1) + throw new Error(`Missing children`) + const scenario: FullRowBatchScenario = { + depth: 1, + steps: [ + prefix[0]!, + { + level: 1, + changes: [ + ...childStep.changes, + { + type: `insert`, + value: { + ...batchChild(3_000, branches[0].groupBase, 3_000, -1), + group: 2_500, + }, + }, + ], + }, + { + level: 0, + changes: [ + { + type: `update`, + value: batchRoot( + branches[1].idBase, + branches[0].groupBase, + branches[1].idBase, + 0, + ), + }, + ], + }, + ], + } + + await expectAssertionFailure( + () => expectFullRowBatchScenarioMatches(scenario), + { + checkpoint: 3, + classify: (difference) => + classifyMissingSharedRouteSnapshot( + difference, + branches[1].idBase, + branches[0].idBase, + [3_000, branches[0].idBase + 1], + ), + }, + )() + }, + ) + for (const [shapeIndex, shape] of independentTransitionShapes.entries()) { fcTest.prop([independentTransitionScenarioArbitrary(shape)], { numRuns: 4, @@ -3470,6 +3656,8 @@ describe(`includes recompute oracle`, () => { for (const parentLevel of [0, 1, 2] as const) { for (const enteringRow of [0, 1] as const) { + // Only roots currently miss an existing shared-route snapshot; nested + // subscribers receive the snapshot and remain green controls. const expectsFailure = parentLevel === 0 fcTest( expectsFailure From 879d5ccf257f19d8104ce1442aa6cc81aa330450 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 14 Aug 2026 18:29:20 -0600 Subject: [PATCH 3/3] test(db): tighten route lifecycle classifications --- .../query/includes-oracle.property.test.ts | 216 ++++++++++++++++-- 1 file changed, 195 insertions(+), 21 deletions(-) diff --git a/packages/db/tests/query/includes-oracle.property.test.ts b/packages/db/tests/query/includes-oracle.property.test.ts index e93f825bb..a70c8b397 100644 --- a/packages/db/tests/query/includes-oracle.property.test.ts +++ b/packages/db/tests/query/includes-oracle.property.test.ts @@ -101,17 +101,22 @@ function hasDirectChild(value: unknown, parentId: number, childId: number) { ) } -function classifyUnexpectedSharedChild( +function classifyUnexpectedSharedRoute( { actual, expected }: AssertionDifference, unexpectedParentId: number, expectedParentId: number, childId: number, ): boolean { + const expectedParent = findRelationshipNode(expected, expectedParentId) + const expectedWithSharedRoute = replaceDirectChildren( + expected, + unexpectedParentId, + Array.isArray(expectedParent?.children) ? expectedParent.children : [], + ) return ( - hasDirectChild(actual, unexpectedParentId, childId) && !hasDirectChild(expected, unexpectedParentId, childId) && - hasDirectChild(actual, expectedParentId, childId) && - hasDirectChild(expected, expectedParentId, childId) + hasDirectChild(expected, expectedParentId, childId) && + isDeepStrictEqual(actual, expectedWithSharedRoute) ) } @@ -120,11 +125,13 @@ function classifyMissingReplacementChild( replacementRowId: number, childId: number, ): boolean { + const expectedWithoutChild = removeDirectChild(expected, replacementRowId, [ + childId, + ]) return ( - findRelationshipNode(actual, replacementRowId) !== undefined && findRelationshipNode(expected, replacementRowId) !== undefined && - !hasDirectChild(actual, replacementRowId, childId) && - hasDirectChild(expected, replacementRowId, childId) + hasDirectChild(expected, replacementRowId, childId) && + isDeepStrictEqual(actual, expectedWithoutChild) ) } @@ -176,6 +183,29 @@ function removeDirectChild( ) } +function replaceDirectChildren( + value: unknown, + parentId: number, + children: ReadonlyArray, +): unknown { + if (Array.isArray(value)) { + return value.map((entry) => + replaceDirectChildren(entry, parentId, children), + ) + } + if (typeof value !== `object` || value === null) return value + + const isParent = isRelationshipNode(value) && value.id === parentId + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + key, + key === `children` && isParent + ? children + : replaceDirectChildren(entry, parentId, children), + ]), + ) +} + type RelationshipProjectionNode = { id: number children?: Array @@ -1883,8 +1913,8 @@ function applyRouteLifecycleStep( step: FullRowBatchStep, roots: Map, levels: Array>, - seenRoutes: Set, - retiredRouteOwners: Map, + seenRoutes: Set, + retiredRouteOwners: Map, ): void { const rows = step.level === 0 ? roots : levels[step.level - 1]! const previousRouteOwners = new Map>() @@ -1900,19 +1930,28 @@ function applyRouteLifecycleStep( updateFullRowBatchModels(step, roots, levels) for (const row of rows.values()) { - seenRoutes.add(row.group) - retiredRouteOwners.delete(row.group) + const route = routeIdentity(step.level, row.group) + seenRoutes.add(route) + retiredRouteOwners.delete(route) } for (const [route, previousOwners] of previousRouteOwners) { if ([...rows.values()].some((row) => row.group === route)) continue + const retiredRoute = routeIdentity(step.level, route) if (previousOwners.size === 1) { - retiredRouteOwners.set(route, [...previousOwners][0]!) + retiredRouteOwners.set(retiredRoute, [...previousOwners][0]!) } else { - retiredRouteOwners.delete(route) + retiredRouteOwners.delete(retiredRoute) } } } +function routeIdentity(level: 0 | IncludeDepth, route: number): string { + // This oracle has one include edge per level, so level plus correlation value + // is the complete route identity. Equal values at different levels are not + // the same subscription lifecycle. + return `${level}:${route}` +} + function createRouteLifecycleScenario({ depth, branches, @@ -1920,8 +1959,8 @@ function createRouteLifecycleScenario({ prefixSteps = createConnectedBatchBranches(depth, branches), trailingSteps = [], }: RouteLifecycleScenarioOptions): RouteLifecycleScenario { - const seenRoutes = new Set() - const retiredRouteOwners = new Map() + const seenRoutes = new Set() + const retiredRouteOwners = new Map() const steps = [...prefixSteps] const roots = new Map() @@ -1974,11 +2013,15 @@ function createRouteLifecycleScenario({ ? row.group === currentRoute : (row as ChildRow).parentGroup === currentRoute, ).length - const retiredOwner = retiredRouteOwners.get(descriptor.destination.route) + const destinationRoute = routeIdentity( + descriptor.level, + descriptor.destination.route, + ) + const retiredOwner = retiredRouteOwners.get(destinationRoute) switch (descriptor.destination.strategy) { case `fresh`: - if (seenRoutes.has(descriptor.destination.route)) { + if (seenRoutes.has(destinationRoute)) { throw new Error(`fresh route must never have been used`) } break @@ -1996,7 +2039,7 @@ function createRouteLifecycleScenario({ } break case `split`: - if (seenRoutes.has(descriptor.destination.route)) { + if (seenRoutes.has(destinationRoute)) { throw new Error(`split destination must be unused`) } if (currentRouteUsers < 2) { @@ -2492,6 +2535,69 @@ function createSnapshotOnResubscribeScenarios( } } +function createInitiallySharedRouteResubscribeScenario( + parentLevel: 0 | 1 | 2, +): ClassifiedHistoryScenario { + const depth = (parentLevel + 1) as IncludeDepth + const branches = transitionHistoryBranches + const childLevel = depth + const sharedRoute = branches[0].groupBase + parentLevel + const childId = rowAt(branches, 0, childLevel) + const child = { + ...batchChild(childId, sharedRoute, childId, 0), + group: branches[0].groupBase + childLevel, + } + const scenario = createRouteLifecycleScenario({ + depth, + branches, + prefixSteps: createInitiallySharedRoutePrefix(depth, parentLevel, branches), + descriptors: [ + { + kind: `rekey`, + level: parentLevel, + row: 0, + destination: { strategy: `split`, route: 2_100 + parentLevel }, + }, + { + kind: `rekey`, + level: parentLevel, + row: 1, + destination: { strategy: `fresh`, route: 2_200 + parentLevel }, + }, + { + kind: `rekey`, + level: parentLevel, + row: 1, + destination: { strategy: `restore`, route: sharedRoute }, + stepsBefore: [ + { + level: childLevel, + changes: [ + { + type: `update`, + value: { ...child, value: child.value + 1 }, + }, + ], + }, + ], + }, + ], + }) + expectEveryRouteTransitionVisible(scenario) + return { + control: createSharedRouteLastSubscriberScenario(parentLevel), + candidate: scenario, + candidateCheckpoint: scenario.steps.length, + classify: (difference) => + classifyUnexpectedSharedRoute( + difference, + rowAt(branches, 0, parentLevel), + rowAt(branches, 1, parentLevel), + childId, + ), + } +} + type ClassifiedHistoryScenario = { control: FullRowBatchScenario greenVariants?: ReadonlyArray @@ -2568,7 +2674,7 @@ function createRekeyRouteReuseFixture({ rekey, reuse: insertOldRoute, classify: (difference) => - classifyUnexpectedSharedChild( + classifyUnexpectedSharedRoute( difference, retiredRowId, insertedId, @@ -2858,7 +2964,7 @@ function createSharedRouteLifetimeScenarios( candidate: { depth: 1, steps: candidateSteps }, candidateCheckpoint: candidateSteps.length, classify: (difference) => - classifyUnexpectedSharedChild( + classifyUnexpectedSharedRoute( difference, departed.id, remaining.id, @@ -2903,7 +3009,7 @@ function createSharedRouteLifetimeScenarios( candidate: { depth: 2, steps: candidateSteps }, candidateCheckpoint: candidateSteps.length, classify: (difference) => - classifyUnexpectedSharedChild( + classifyUnexpectedSharedRoute( difference, departed.id, remaining.id, @@ -3508,6 +3614,39 @@ describe(`includes recompute oracle`, () => { }, ) + fcTest( + `unexpected shared-child classification rejects extra corruption`, + () => { + const expected = [ + { id: 1, value: 10, children: [] }, + { id: 2, value: 20, children: [{ id: 3, value: 30 }] }, + ] + const actual = [ + { id: 1, value: 11, children: [{ id: 3, value: 30 }] }, + { id: 2, value: 20, children: [{ id: 3, value: 31 }] }, + ] + + expect(classifyUnexpectedSharedRoute({ actual, expected }, 1, 2, 3)).toBe( + false, + ) + }, + ) + + fcTest(`missing replacement classification rejects extra corruption`, () => { + const expected = [ + { id: 1, value: 10, children: [{ id: 3, value: 30 }] }, + { id: 2, value: 20, children: [] }, + ] + const actual = [ + { id: 1, value: 11, children: [] }, + { id: 2, value: 21, children: [] }, + ] + + expect(classifyMissingReplacementChild({ actual, expected }, 1, 3)).toBe( + false, + ) + }) + fcTest(`rejects subscriber lifecycle labels on reparent transitions`, () => { expect(() => createRouteLifecycleScenario({ @@ -3525,6 +3664,29 @@ describe(`includes recompute oracle`, () => { ).toThrow(/reparent transitions only support live merge destinations/) }) + fcTest(`scopes rekey route histories to their include level`, () => { + expect(() => + createRouteLifecycleScenario({ + depth: 3, + branches: transitionHistoryBranches, + descriptors: [ + { + kind: `rekey`, + level: 1, + row: 0, + destination: { strategy: `fresh`, route: 2_100 }, + }, + { + kind: `rekey`, + level: 2, + row: 0, + destination: { strategy: `fresh`, route: 2_100 }, + }, + ], + }), + ).not.toThrow() + }) + fcTest(`the sibling topology targets rows under one parent`, () => { const branches = transitionHistoryBranches const prefix = independentTransitionPrefix(`sibling`, branches) @@ -3687,6 +3849,18 @@ describe(`includes recompute oracle`, () => { createSnapshotOnResubscribeScenarios(parentLevel), ), ) + + fcTest( + parentLevel === 0 + ? `discovered trace: an initially shared root route retires, changes, and resubscribes` + : `matches recomputation when an initially shared level-${parentLevel} route retires, changes, and resubscribes`, + () => + (parentLevel === 0 + ? expectClassifiedHistoryFailure + : expectClassifiedHistoryMatches)( + createInitiallySharedRouteResubscribeScenario(parentLevel), + ), + ) } fcTest(`rejects invalid route destination strategies`, () => {