diff --git a/packages/db/tests/query/includes-oracle.property.test.ts b/packages/db/tests/query/includes-oracle.property.test.ts index b0deb00c8..bcf958993 100644 --- a/packages/db/tests/query/includes-oracle.property.test.ts +++ b/packages/db/tests/query/includes-oracle.property.test.ts @@ -838,6 +838,18 @@ function updateModel( } } +function updateFullRowBatchModels( + step: FullRowBatchStep, + roots: Map, + levels: Array>, +): void { + if (step.level === 0) { + updateModel(roots, step.changes) + } else { + updateModel(levels[step.level - 1]!, step.changes) + } +} + function createFullRowBatchTraceDriver( depth: IncludeDepth, ): TraceDriver { @@ -847,13 +859,10 @@ function createFullRowBatchTraceDriver( apply: (step, { sources, roots, levels }) => { if (step.level === 0) { sources.roots.writeBatch(step.changes) - updateModel(roots, step.changes) - return + } else { + sources.levels[step.level - 1]!.writeBatch(step.changes) } - - const level = step.level - 1 - sources.levels[level]!.writeBatch(step.changes) - updateModel(levels[level]!, step.changes) + updateFullRowBatchModels(step, roots, levels) }, cleanup: cleanupStructuralTrace, } @@ -1001,16 +1010,24 @@ function createConnectedBatchPrefix( return steps } +type ConnectedBranch = { + idBase: number + groupBase: number +} + function createConnectedBatchBranches( depth: IncludeDepth, + branches: ReadonlyArray = [ + { idBase: 100, groupBase: 100 }, + { idBase: 200, groupBase: 200 }, + ], ): Array { - const branchRoots = [100, 200] const steps: Array = [ { level: 0, - changes: branchRoots.map((id) => ({ + changes: branches.map(({ idBase, groupBase }) => ({ type: `insert`, - value: batchRoot(id, id, id, 0), + value: batchRoot(idBase, groupBase, idBase, 0), })), }, ] @@ -1018,11 +1035,16 @@ function createConnectedBatchBranches( for (let level = 1; level <= depth; level++) { steps.push({ level: level as IncludeDepth, - changes: branchRoots.map((rootId) => ({ + changes: branches.map(({ idBase, groupBase }) => ({ type: `insert`, value: { - ...batchChild(rootId + level, rootId + level - 1, rootId + level, 0), - group: rootId + level, + ...batchChild( + idBase + level, + groupBase + level - 1, + idBase + level, + 0, + ), + group: groupBase + level, }, })), }) @@ -1034,7 +1056,6 @@ function createConnectedBatchBranches( function normalizeFullRowBatchInputs( depth: IncludeDepth, inputs: Array, - allowChildRelationshipUpdates: boolean, ): FullRowBatchScenario { const roots = new Map([[100, batchRoot(100, 100, 100, 0)]]) const levels = Array.from( @@ -1088,14 +1109,8 @@ function normalizeFullRowBatchInputs( const value: ChildRow = { id: change.id, - parentGroup: - !allowChildRelationshipUpdates && current - ? current.parentGroup - : change.parentGroup, - group: - !allowChildRelationshipUpdates && current - ? current.group - : change.group, + parentGroup: current ? current.parentGroup : change.parentGroup, + group: current ? current.group : change.group, value: change.value, position: change.position, } @@ -1117,11 +1132,9 @@ function fullRowBatchScenarioAtDepthArbitrary( maxLength: 10, }) .map((inputs) => { - const noise = normalizeFullRowBatchInputs( - depth, - inputs, - false, - ).steps.slice(depth + 1) + const noise = normalizeFullRowBatchInputs(depth, inputs).steps.slice( + depth + 1, + ) const changes: Array> = [100, 200].map((rootId) => ({ type: `update`, value: { @@ -1148,35 +1161,230 @@ function fullRowBatchScenarioAtDepthArbitrary( type VisibleRelationshipTransition = `reparent` | `rekey` +function otherBranch(branch: 0 | 1): 0 | 1 { + return branch === 0 ? 1 : 0 +} + +type VisibleRelationshipScenario = FullRowBatchScenario & { + transitionStepIndex: number +} + +type VisibleRelationshipScenarios = { + transitionOnly: VisibleRelationshipScenario + stateful: VisibleRelationshipScenario +} + +type VisibleScalarNoise = { + side: `before` | `after` + level: 0 | IncludeDepth + branch: 0 | 1 + value: number + position: number +} + +type VisibleRelationshipScenarioOptions = { + depth: IncludeDepth + targetLevel: IncludeDepth + sourceBranch: 0 | 1 + branches: readonly [ConnectedBranch, ConnectedBranch] + noise: ReadonlyArray +} & ( + | { transition: `reparent`; rekeyGroup?: never } + | { transition: `rekey`; rekeyGroup: number } +) + +function assertDisjointRelationshipKeys( + depth: IncludeDepth, + branches: readonly [ConnectedBranch, ConnectedBranch], + rekeyGroup: number | undefined, +): void { + const ids = branches.flatMap(({ idBase }) => + Array.from({ length: depth + 1 }, (_, level) => idBase + level), + ) + const groups = [ + ...branches.flatMap(({ groupBase }) => + Array.from({ length: depth + 1 }, (_, level) => groupBase + level), + ), + ...(rekeyGroup === undefined ? [] : [rekeyGroup]), + ] + if ( + new Set(ids).size !== ids.length || + new Set(groups).size !== groups.length + ) { + throw new Error(`Visible relationship keys overlap`) + } +} + +function createVisibleRelationshipScenario( + options: VisibleRelationshipScenarioOptions, +): VisibleRelationshipScenario { + const { depth, transition, targetLevel, sourceBranch, branches, noise } = + options + assertDisjointRelationshipKeys( + depth, + branches, + transition === `rekey` ? options.rekeyGroup : undefined, + ) + const steps = createConnectedBatchBranches(depth, branches) + const roots = new Map() + const levels = Array.from({ length: 4 }, () => new Map()) + + for (const step of steps) { + updateFullRowBatchModels(step, roots, levels) + } + + const appendNoise = (entry: VisibleScalarNoise): void => { + const branch = branches[entry.branch] + if (entry.level === 0) { + const current = roots.get(branch.idBase)! + const value = { + ...current, + value: entry.value, + position: entry.position, + } + roots.set(value.id, value) + steps.push({ level: 0, changes: [{ type: `update`, value }] }) + return + } + + const model = levels[entry.level - 1]! + const current = model.get(branch.idBase + entry.level)! + const value = { + ...current, + value: entry.value, + position: entry.position, + } + model.set(value.id, value) + steps.push({ + level: entry.level, + changes: [{ type: `update`, value }], + }) + } + + for (const entry of noise.filter(({ side }) => side === `before`)) { + appendNoise(entry) + } + + const source = branches[sourceBranch] + const destination = branches[otherBranch(sourceBranch)] + const targetModel = levels[targetLevel - 1]! + const current = targetModel.get(source.idBase + targetLevel)! + const value: ChildRow = { + ...current, + parentGroup: + transition === `reparent` + ? destination.groupBase + targetLevel - 1 + : current.parentGroup, + group: transition === `rekey` ? options.rekeyGroup : current.group, + } + const transitionStepIndex = steps.length + steps.push({ + level: targetLevel, + changes: [{ type: `update`, value }], + }) + targetModel.set(value.id, value) + + for (const entry of noise.filter(({ side }) => side === `after`)) { + appendNoise(entry) + } + + return { + depth, + steps, + transitionStepIndex, + } +} + function visibleRelationshipScenarioArbitrary( depth: IncludeDepth, transition: VisibleRelationshipTransition, -): fc.Arbitrary { - return fc - .array(fullRowBatchInputArbitrary(depth, `children`, 1), { - minLength: 1, - maxLength: 10, + targetLevel: IncludeDepth, +): fc.Arbitrary { + const branchArbitrary = fc.constantFrom<0 | 1>(0, 1) + const scalarNoiseArbitrary = ( + side: VisibleScalarNoise[`side`], + branch: fc.Arbitrary<0 | 1>, + ): fc.Arbitrary => + fc.record({ + side: fc.constant(side), + level: levelArbitrary(depth), + branch, + value: fc.integer({ min: -3, max: 3 }), + position: fc.integer({ min: -2, max: 2 }), }) - .map((inputs) => { - const noise = normalizeFullRowBatchInputs( - depth, - inputs, - true, - ).steps.slice(depth + 1) - const value: ChildRow = { - ...batchChild(101, transition === `reparent` ? 200 : 100, 101, 0), - group: transition === `rekey` ? 150 : 101, - } - return { - depth, - steps: [ - ...createConnectedBatchBranches(depth), - ...noise, - { level: 1, changes: [{ type: `update`, value }] }, - ], - } + return fc + .record({ + sourceBranch: branchArbitrary, + 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 }), + rekeyGroup: fc.integer({ min: 2_100, max: 2_500 }), + beforeNoise: scalarNoiseArbitrary(`before`, branchArbitrary), + extraBeforeNoise: fc.array( + scalarNoiseArbitrary(`before`, branchArbitrary), + { maxLength: 4 }, + ), + afterValues: fc.array( + fc.record({ + level: levelArbitrary(depth), + value: fc.integer({ min: -3, max: 3 }), + position: fc.integer({ min: -2, max: 2 }), + }), + { minLength: 1, maxLength: 5 }, + ), }) + .map( + ({ + sourceBranch, + leftIdBase, + leftGroupBase, + rightIdBase, + rightGroupBase, + rekeyGroup, + beforeNoise, + extraBeforeNoise, + afterValues, + }) => { + const stableBranch = otherBranch(sourceBranch) + // Updating two descendant levels after a reparent exposes a separate + // known defect, captured for every failing depth/level below. Keep the + // generated corpus green by updating only the branch that did not move. + const connectedNoise: Array = [ + beforeNoise, + ...extraBeforeNoise, + ...afterValues.map((entry) => ({ + ...entry, + side: `after` as const, + branch: stableBranch, + })), + ] + const options = { + depth, + targetLevel, + sourceBranch, + branches: [ + { idBase: leftIdBase, groupBase: leftGroupBase }, + { idBase: rightIdBase, groupBase: rightGroupBase }, + ], + ...(transition === `rekey` + ? { transition, rekeyGroup } + : { transition }), + } satisfies Omit + + return { + transitionOnly: createVisibleRelationshipScenario({ + ...options, + noise: [], + }), + stateful: createVisibleRelationshipScenario({ + ...options, + noise: connectedNoise, + }), + } + }, + ) } async function expectFullRowBatchScenarioMatches({ @@ -1198,11 +1406,7 @@ function recomputeFullRowBatchScenario( const levels = Array.from({ length: 4 }, () => new Map()) for (const step of steps.slice(0, stepCount)) { - if (step.level === 0) { - updateModel(roots, step.changes) - } else { - updateModel(levels[step.level - 1]!, step.changes) - } + updateFullRowBatchModels(step, roots, levels) } return recompute(roots, levels, depth) @@ -1351,7 +1555,7 @@ const flatMaterializationScenarioArbitrary = fc minLength: 1, maxLength: 12, }) - .map((inputs) => normalizeFullRowBatchInputs(1, inputs, false)) + .map((inputs) => normalizeFullRowBatchInputs(1, inputs)) async function expectFlatMaterializationScenarioMatches( materialization: FlatMaterialization, @@ -1653,24 +1857,85 @@ const intraBatchChildHandOffScenario: FullRowBatchScenario = { ], } +function createReparentedSubtreeUpdateScenario( + depth: 3 | 4, + targetLevel: 1 | 2, +): VisibleRelationshipScenario { + return createVisibleRelationshipScenario({ + depth, + transition: `reparent`, + targetLevel, + sourceBranch: 0, + branches: [ + { idBase: 100, groupBase: 600 }, + { idBase: 1_100, groupBase: 1_600 }, + ], + noise: [targetLevel + 1, targetLevel + 2].map((level) => ({ + side: `after`, + level: level as IncludeDepth, + branch: 0, + value: 1, + position: 0, + })), + }) +} + +const minimalRekeyScenario = createVisibleRelationshipScenario({ + depth: 3, + transition: `rekey`, + targetLevel: 1, + sourceBranch: 0, + branches: [ + { idBase: 100, groupBase: 600 }, + { idBase: 1_100, groupBase: 1_600 }, + ], + rekeyGroup: 2_100, + noise: [], +}) + describe(`includes recompute oracle`, () => { - fcTest(`covers a visible relationship transition at every depth`, () => { - const scenarios = ([1, 2, 3, 4] as const).map( - (depth) => - fc.sample(visibleRelationshipScenarioArbitrary(depth, `reparent`), { - numRuns: 1, - seed: 1721 + depth, - })[0]!, - ) + fcTest(`rejects overlapping visible relationship keys`, () => { + const base = { + depth: 4, + transition: `rekey`, + targetLevel: 1, + sourceBranch: 0, + noise: [], + } as const + const collisions: Array<{ + branches: readonly [ConnectedBranch, ConnectedBranch] + rekeyGroup: number + }> = [ + { + branches: [ + { idBase: 100, groupBase: 600 }, + { idBase: 102, groupBase: 1_600 }, + ], + rekeyGroup: 2_100, + }, + { + branches: [ + { idBase: 100, groupBase: 600 }, + { idBase: 1_100, groupBase: 602 }, + ], + rekeyGroup: 2_100, + }, + { + branches: [ + { idBase: 100, groupBase: 600 }, + { idBase: 1_100, groupBase: 1_600 }, + ], + rekeyGroup: 603, + }, + ] - for (const scenario of scenarios) { - const beforeTransition = recomputeFullRowBatchScenario( - scenario, - scenario.steps.length - 1, - ) - expect( - recomputeFullRowBatchScenario(scenario, scenario.steps.length), - ).not.toEqual(beforeTransition) + for (const collision of collisions) { + expect(() => + createVisibleRelationshipScenario({ + ...base, + ...collision, + }), + ).toThrow(/overlap/) } }) @@ -1689,6 +1954,31 @@ describe(`includes recompute oracle`, () => { ) } + for (const [depth, targetLevel] of [ + [3, 1], + [4, 1], + [4, 2], + ] as const) { + fcTest( + `discovered trace: later updates propagate through a reparented subtree at depth ${depth}, level ${targetLevel}`, + expectAssertionFailure( + () => + expectFullRowBatchScenarioMatches( + createReparentedSubtreeUpdateScenario(depth, targetLevel), + ), + { checkpoint: depth + 4 }, + ), + ) + } + + fcTest( + `discovered trace: rekeying a row detaches two descendant levels`, + expectAssertionFailure( + () => expectFullRowBatchScenarioMatches(minimalRekeyScenario), + { checkpoint: 5 }, + ), + ) + fcTest.prop( [ fc.constantFrom(`array`, `concat`), @@ -1711,37 +2001,59 @@ describe(`includes recompute oracle`, () => { expectFullRowBatchScenarioMatches, ) - const transitions: Array = - depth === 1 ? [`reparent`] : [`reparent`, `rekey`] + const transitions: Array = [ + `reparent`, + `rekey`, + ] for (const transition of transitions) { - fcTest.prop([visibleRelationshipScenarioArbitrary(depth, transition)], { - numRuns: 6, - seed: 1721 + depth, - })( - transition === `rekey` && depth >= 3 - ? `discovered trace: a visible rekey at depth ${depth}` - : `matches recomputation for a visible ${transition} at depth ${depth}`, - async (scenario) => { - const beforeTransition = recomputeFullRowBatchScenario( - scenario, - scenario.steps.length - 1, - ) - const result = recomputeFullRowBatchScenario( - scenario, - scenario.steps.length, - ) - - expect(result).not.toEqual(beforeTransition) - if (transition === `rekey` && depth >= 3) { - await expectAssertionFailure( - () => expectFullRowBatchScenarioMatches(scenario), - { checkpoint: scenario.steps.length }, - )() - } else { - await expectFullRowBatchScenarioMatches(scenario) - } - }, - ) + for (let targetLevel = 1; targetLevel <= depth; targetLevel++) { + // Incremental routing fails to fully detach a rekeyed row when two or + // more descendant include levels still hang below it. + const expectsFailure = + transition === `rekey` && targetLevel + 2 <= depth + fcTest.prop( + [ + visibleRelationshipScenarioArbitrary( + depth, + transition, + targetLevel as IncludeDepth, + ), + ], + { + numRuns: 4, + seed: 1721 + depth + targetLevel, + }, + )( + expectsFailure + ? `discovered trace: a visible rekey at depth ${depth}, level ${targetLevel}` + : `matches recomputation for a visible ${transition} at depth ${depth}, level ${targetLevel}`, + async (scenarios) => { + for (const scenario of [ + scenarios.transitionOnly, + scenarios.stateful, + ]) { + const beforeTransition = recomputeFullRowBatchScenario( + scenario, + scenario.transitionStepIndex, + ) + const result = recomputeFullRowBatchScenario( + scenario, + scenario.transitionStepIndex + 1, + ) + + expect(result).not.toEqual(beforeTransition) + if (expectsFailure) { + await expectAssertionFailure( + () => expectFullRowBatchScenarioMatches(scenario), + { checkpoint: scenario.transitionStepIndex + 1 }, + )() + } else { + await expectFullRowBatchScenarioMatches(scenario) + } + } + }, + ) + } } } diff --git a/packages/db/tests/query/includes-query-shape-oracle.test.ts b/packages/db/tests/query/includes-query-shape-oracle.test.ts new file mode 100644 index 000000000..479bd74b2 --- /dev/null +++ b/packages/db/tests/query/includes-query-shape-oracle.test.ts @@ -0,0 +1,493 @@ +import { fc, test as fcTest } from '@fast-check/vitest' +import { describe, expect } from 'vitest' +import { createCollection } from '../../src/collection/index.js' +import { BasicIndex } from '../../src/indexes/basic-index.js' +import { + createLiveQueryCollection, + eq, + materialize, +} from '../../src/query/index.js' +import { expectAssertionFailure } from '../expected-failure.js' +import { runTrace } from '../trace-runner.js' +import { mockSyncCollectionOptions } from '../utils.js' +import type { TraceDriver, TraceProjection } from '../trace-runner.js' + +let nextCollectionId = 0 + +function rowsById(rows: Array): Map { + return new Map(rows.map((row) => [row.id, row])) +} + +function createControlledCollection( + name: string, + initialData: Array = [], +) { + const options = mockSyncCollectionOptions({ + id: `${name}-${nextCollectionId++}`, + getKey: (row) => row.id, + initialData, + }) + const collection = createCollection(options) + + return { + collection, + write(type: `insert` | `update` | `delete`, value: T): void { + options.utils.begin() + options.utils.write({ type, value }) + options.utils.commit() + }, + } +} + +function stripVirtualProperties(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(stripVirtualProperties) + } + if (!value || typeof value !== `object`) { + return value + } + + return Object.fromEntries( + Object.entries(value) + .filter(([key]) => !key.startsWith(`$`)) + .map(([key, entry]) => [key, stripVirtualProperties(entry)]), + ) +} + +function assertRowsEqual(observed: unknown, expected: unknown): undefined { + expect(observed).toEqual(expected) + return undefined +} + +type Cleanable = { cleanup: () => Promise } + +async function cleanupQuery( + live: Cleanable, + sources: Array<{ collection: Cleanable }>, +): Promise { + await live.cleanup() + await Promise.all(sources.map(({ collection }) => collection.cleanup())) +} + +type ParentRow = { id: number } +type ChildRow = { id: number; parentId: number } + +type MultiplicitySources = ReturnType + +function createChildren(childCount: number): Array { + return Array.from({ length: childCount }, (_, index) => ({ + id: index + 1, + parentId: 1, + })) +} + +function createMultiplicitySources(childCount: number) { + const sources = { + parents: createControlledCollection(`join-parents`, [{ id: 1 }]), + children: createControlledCollection( + `join-children`, + createChildren(childCount), + ), + } + sources.parents.collection.createIndex((row) => row.id, { + indexType: BasicIndex, + }) + sources.children.collection.createIndex((row) => row.parentId, { + indexType: BasicIndex, + }) + return sources +} + +function createMultiplicityQuery(sources: MultiplicitySources) { + return createLiveQueryCollection({ + query: (q) => + q + .from({ parent: sources.parents.collection }) + .innerJoin( + { child: sources.children.collection }, + ({ parent, child }) => eq(parent.id, child.parentId), + ) + .select(({ parent }) => ({ id: parent.id })), + getKey: (row) => row.id, + }) +} + +type MultiplicityContext = { + sources: MultiplicitySources + live: ReturnType + parents: Map + children: Map +} + +function createMultiplicityDriver( + childCount: number, +): TraceDriver { + return { + setup: () => { + const parents = [{ id: 1 }] + const children = createChildren(childCount) + const sources = createMultiplicitySources(childCount) + return { + sources, + live: createMultiplicityQuery(sources), + parents: rowsById(parents), + children: rowsById(children), + } + }, + start: ({ live }) => live.preload(), + apply: (childId, { children, sources }) => { + const child = children.get(childId) + if (!child) throw new Error(`Missing child ${childId}`) + sources.children.write(`delete`, child) + children.delete(childId) + }, + cleanup: ({ live, sources }) => cleanupQuery(live, Object.values(sources)), + } +} + +const multiplicityProjection: TraceProjection< + MultiplicityContext, + unknown, + Array +> = { + observe: ({ live }) => stripVirtualProperties(live.toArray), + recompute: ({ children, parents }) => + [...parents.values()] + .filter((parent) => + [...children.values()].some((child) => child.parentId === parent.id), + ) + .sort((left, right) => left.id - right.id), + assertEqual: assertRowsEqual, +} + +type PartRow = { id: number } +type OrderRow = { id: number; partId: number } +type ProductionRow = { id: number; orderId: number } +type CorrelationTarget = `source` | `joined` + +function createCorrelationSources(correlationId: number, productionId: number) { + const sources = { + parts: createControlledCollection(`correlation-parts`, [ + { id: correlationId }, + ]), + orders: createControlledCollection(`correlation-orders`, [ + { id: correlationId, partId: correlationId }, + ]), + productions: createControlledCollection( + `correlation-productions`, + [{ id: productionId, orderId: correlationId }], + ), + } + sources.orders.collection.createIndex((row) => row.id, { + indexType: BasicIndex, + }) + sources.orders.collection.createIndex((row) => row.partId, { + indexType: BasicIndex, + }) + sources.productions.collection.createIndex((row) => row.orderId, { + indexType: BasicIndex, + }) + return sources +} + +type CorrelationSources = ReturnType + +function createCorrelationQuery( + sources: CorrelationSources, + target: CorrelationTarget, +) { + return createLiveQueryCollection((q) => + q + .from({ part: sources.parts.collection }) + .orderBy(({ part }) => part.id) + .select(({ part }) => { + const joined = q + .from({ production: sources.productions.collection }) + .innerJoin( + { order: sources.orders.collection }, + ({ production, order }) => eq(production.orderId, order.id), + ) + const correlated = + target === `joined` + ? joined.where(({ order }) => eq(order.partId, part.id)) + : joined.where(({ production }) => eq(production.orderId, part.id)) + + return { + id: part.id, + productions: materialize( + correlated + .orderBy(({ production }) => production.id) + .select(({ production }) => ({ + id: production.id, + orderId: production.orderId, + })), + ), + } + }), + ) +} + +type CorrelationContext = { + target: CorrelationTarget + sources: CorrelationSources + live: ReturnType + parts: Map + orders: Map + productions: Map +} + +function createCorrelationDriver( + target: CorrelationTarget, + correlationId: number, + productionId: number, +): TraceDriver { + return { + setup: () => { + const part = { id: correlationId } + const order = { id: correlationId, partId: correlationId } + const production = { id: productionId, orderId: correlationId } + const sources = createCorrelationSources(correlationId, productionId) + return { + target, + sources, + live: createCorrelationQuery(sources, target), + parts: rowsById([part]), + orders: rowsById([order]), + productions: rowsById([production]), + } + }, + start: ({ live }) => live.preload(), + apply: () => undefined, + cleanup: ({ live, sources }) => cleanupQuery(live, Object.values(sources)), + } +} + +type CorrelationResult = Array<{ + id: number + productions: Array +}> + +const correlationProjection: TraceProjection< + CorrelationContext, + unknown, + CorrelationResult +> = { + observe: ({ live }) => stripVirtualProperties(live.toArray), + recompute: ({ orders, parts, productions, target }) => + [...parts.values()] + .sort((left, right) => left.id - right.id) + .map((part) => ({ + id: part.id, + productions: [...productions.values()] + .filter((production) => { + const order = orders.get(production.orderId) + if (!order) return false + return target === `joined` + ? order.partId === part.id + : production.orderId === part.id + }) + .sort((left, right) => left.id - right.id), + })), + assertEqual: assertRowsEqual, +} + +type AuthorRow = { id: number; name: string } +type PostRow = { id: number; authorId: number | null } + +function createNullableSources( + authors: Array, + posts: Array, +) { + return { + authors: createControlledCollection(`nullable-authors`, authors), + posts: createControlledCollection(`nullable-posts`, posts), + } +} + +type NullableSources = ReturnType + +function createNullableQuery(sources: NullableSources) { + return createLiveQueryCollection((q) => + q + .from({ post: sources.posts.collection }) + .orderBy(({ post }) => post.id) + .select(({ post }) => ({ + id: post.id, + author: materialize( + q + .from({ author: sources.authors.collection }) + .where(({ author }) => eq(author.id, post.authorId)) + .select(({ author }) => ({ + id: author.id, + name: author.name, + })) + .findOne(), + ), + })), + ) +} + +type NullableContext = { + sources: NullableSources + live: ReturnType + authors: Map + posts: Map +} + +function createNullableDriver( + authors: Array, + posts: Array, +): TraceDriver { + return { + setup: () => { + const sources = createNullableSources( + authors.map((author) => ({ ...author })), + posts.map((post) => ({ ...post })), + ) + return { + sources, + live: createNullableQuery(sources), + authors: rowsById(authors), + posts: rowsById(posts), + } + }, + start: ({ live }) => live.preload(), + apply: (post, context) => { + context.sources.posts.write( + context.posts.has(post.id) ? `update` : `insert`, + { ...post }, + ) + context.posts.set(post.id, post) + }, + cleanup: ({ live, sources }) => cleanupQuery(live, Object.values(sources)), + } +} + +type NullableResult = Array<{ + id: number + author: AuthorRow | undefined +}> + +const nullableProjection: TraceProjection< + NullableContext, + unknown, + NullableResult +> = { + observe: ({ live }) => stripVirtualProperties(live.toArray), + recompute: ({ authors, posts }) => + [...posts.values()] + .sort((left, right) => left.id - right.id) + .map((post) => ({ + id: post.id, + author: post.authorId === null ? undefined : authors.get(post.authorId), + })), + assertEqual: assertRowsEqual, +} + +describe(`includes query-shape recompute oracle`, () => { + fcTest.prop([fc.integer({ min: 2, max: 5 })], { + numRuns: 12, + seed: 1703, + })( + `discovered trace: deleting one joined contributor preserves remaining multiplicity (#1703)`, + async (childCount) => { + await expectAssertionFailure( + () => + runTrace({ + steps: [1], + driver: createMultiplicityDriver(childCount), + projection: multiplicityProjection, + }), + { checkpoint: 1 }, + )() + }, + ) + + fcTest( + `matches recomputation when the final joined contributor is deleted`, + () => + runTrace({ + steps: [1], + driver: createMultiplicityDriver(1), + projection: multiplicityProjection, + }), + ) + + fcTest.prop( + [ + fc.record({ + correlationId: fc.integer({ min: 1, max: 100 }), + productionId: fc.integer({ min: 101, max: 200 }), + }), + ], + { numRuns: 12, seed: 1704 }, + )( + `discovered trace: materialization follows correlation through a joined alias (#1704)`, + async ({ correlationId, productionId }) => { + await expectAssertionFailure( + () => + runTrace({ + steps: [], + driver: createCorrelationDriver( + `joined`, + correlationId, + productionId, + ), + projection: correlationProjection, + }), + { checkpoint: 0 }, + )() + }, + ) + + fcTest( + `matches recomputation when materialization correlates through its source alias`, + () => + runTrace({ + steps: [], + driver: createCorrelationDriver(`source`, 1, 101), + projection: correlationProjection, + }), + ) + + fcTest.prop([fc.integer({ min: 1, max: 100 })], { + numRuns: 12, + seed: 1706, + })( + `discovered trace: findOne maps a null correlation key to undefined (#1706)`, + async (postId) => { + await expectAssertionFailure( + () => + runTrace({ + steps: [], + driver: createNullableDriver([], [{ id: postId, authorId: null }]), + projection: nullableProjection, + }), + { checkpoint: 0 }, + )() + }, + ) + + fcTest( + `matches recomputation for an unmatched non-null correlation key`, + () => + runTrace({ + steps: [], + driver: createNullableDriver([], [{ id: 1, authorId: 999 }]), + projection: nullableProjection, + }), + ) + + fcTest( + `matches recomputation when an existing correlation key becomes null`, + () => + runTrace({ + steps: [{ id: 1, authorId: null }], + driver: createNullableDriver( + [{ id: 1, name: `Ada` }], + [{ id: 1, authorId: 1 }], + ), + projection: nullableProjection, + }), + ) +})