diff --git a/packages/db/tests/query/includes-oracle.property.test.ts b/packages/db/tests/query/includes-oracle.property.test.ts index e7f7ea5d2..6c6bd3777 100644 --- a/packages/db/tests/query/includes-oracle.property.test.ts +++ b/packages/db/tests/query/includes-oracle.property.test.ts @@ -1,6 +1,7 @@ import { fc, test as fcTest } from '@fast-check/vitest' import { describe, expect } from 'vitest' import { + concat, createLiveQueryCollection, eq, materialize, @@ -776,7 +777,9 @@ function createStructuralTraceContext( async function cleanupStructuralTrace({ incremental, sources, -}: StructuralTraceContext): Promise { +}: Pick & { + incremental: { cleanup: () => Promise } +}): Promise { await incremental.cleanup() await cleanupSources(sources) } @@ -801,7 +804,24 @@ function createStructuralTraceDriver( type FullRowBatchStep = | { level: 0; changes: Array> } - | { level: 1; changes: Array> } + | { level: IncludeDepth; changes: Array> } + +type FullRowBatchInput = { + level: 0 | IncludeDepth + changes: Array<{ + type: `put` | `delete` + id: number + parentGroup: number + group: number + value: number + position: number + }> +} + +type FullRowBatchScenario = { + depth: IncludeDepth + steps: Array +} function updateModel( model: Map, @@ -816,12 +836,11 @@ function updateModel( } } -function createFullRowBatchTraceDriver(): TraceDriver< - FullRowBatchStep, - StructuralTraceContext -> { +function createFullRowBatchTraceDriver( + depth: IncludeDepth, +): TraceDriver { return { - setup: () => createStructuralTraceContext(1, `full`), + setup: () => createStructuralTraceContext(depth, `full`), start: ({ incremental }) => incremental.preload(), apply: (step, { sources, roots, levels }) => { if (step.level === 0) { @@ -830,8 +849,9 @@ function createFullRowBatchTraceDriver(): TraceDriver< return } - sources.levels[0].writeBatch(step.changes) - updateModel(levels[0]!, step.changes) + const level = step.level - 1 + sources.levels[level]!.writeBatch(step.changes) + updateModel(levels[level]!, step.changes) }, cleanup: cleanupStructuralTrace, } @@ -887,6 +907,363 @@ const fullRowBatchTrace: Array = [ }, ] +const fullRowSharedRoutingSeed: FullRowBatchScenario = { + depth: 1, + steps: [ + { + level: 0, + changes: [ + { type: `insert`, value: batchRoot(0, 2, 0, 0) }, + { type: `insert`, value: batchRoot(1, 2, 0, 0) }, + ], + }, + { + level: 0, + changes: [{ type: `delete`, value: batchRoot(1, 2, 0, 0) }], + }, + { + level: 0, + changes: [{ type: `insert`, value: batchRoot(1, 0, 0, 0) }], + }, + { + level: 1, + changes: [{ type: `insert`, value: batchChild(0, 2, 0, 0) }], + }, + ], +} + +function fullRowBatchInputArbitrary( + depth: IncludeDepth, + levels: `all` | `children`, + maxBatchSize = 3, +): fc.Arbitrary { + const levelsArbitrary = + levels === `all` + ? levelArbitrary(depth) + : fc.integer({ min: 1, max: depth }).map((level) => level as IncludeDepth) + + return levelsArbitrary.chain((level) => + fc + .uniqueArray( + fc.record({ + // Root delete/reinsert has a known failing seed below. Keep the + // generated green corpus out of that class while still generating + // inserts, replacements, and multi-change batches at the root. + type: + level === 0 + ? fc.constant(`put` as const) + : fc.constantFrom(`put` as const, `delete` as const), + id: fc.integer({ min: 0, max: 5 }), + parentGroup: fc.integer({ min: 0, max: 4 }), + group: fc.integer({ min: 0, max: 4 }), + value: fc.integer({ min: -3, max: 3 }), + position: fc.integer({ min: -2, max: 2 }), + }), + { + selector: (change) => change.id, + minLength: 1, + maxLength: maxBatchSize, + }, + ) + .map((changes) => ({ level, changes })), + ) +} + +function createConnectedBatchPrefix( + depth: IncludeDepth, +): Array { + // Generated ids stop at 5, so this path survives every later batch and + // guarantees that the selected depth is observable at every checkpoint. + const steps: Array = [ + { + level: 0, + changes: [{ type: `insert`, value: batchRoot(100, 100, 100, 0) }], + }, + ] + + for (let level = 1; level <= depth; level++) { + steps.push({ + level: level as IncludeDepth, + changes: [ + { + type: `insert`, + value: { + ...batchChild(100 + level, 99 + level, 100 + level, 0), + group: 100 + level, + }, + }, + ], + }) + } + + return steps +} + +function normalizeFullRowBatchInputs( + depth: IncludeDepth, + inputs: Array, + allowChildRelationshipUpdates: boolean, +): FullRowBatchScenario { + const roots = new Map([[100, batchRoot(100, 100, 100, 0)]]) + const levels = Array.from( + { length: 4 }, + (_, level) => + new Map( + level < depth + ? [ + [ + 101 + level, + { + ...batchChild(101 + level, 100 + level, 101 + level, 0), + group: 101 + level, + }, + ], + ] + : [], + ), + ) + const steps = createConnectedBatchPrefix(depth) + + for (const input of inputs) { + if (input.level === 0) { + const changes = input.changes.map((change): SyncChange => { + const current = roots.get(change.id) + if (change.type === `delete` && current) { + roots.delete(change.id) + return { type: `delete`, value: current } + } + + const value: RootRow = { + id: change.id, + group: current ? current.group : change.group, + value: change.value, + position: change.position, + } + roots.set(value.id, value) + return { type: current ? `update` : `insert`, value } + }) + steps.push({ level: 0, changes }) + continue + } + + const model = levels[input.level - 1]! + const changes = input.changes.map((change): SyncChange => { + const current = model.get(change.id) + if (change.type === `delete` && current) { + model.delete(change.id) + return { type: `delete`, value: current } + } + + const value: ChildRow = { + id: change.id, + parentGroup: + !allowChildRelationshipUpdates && current + ? current.parentGroup + : change.parentGroup, + group: + !allowChildRelationshipUpdates && current + ? current.group + : change.group, + value: change.value, + position: change.position, + } + model.set(value.id, value) + return { type: current ? `update` : `insert`, value } + }) + steps.push({ level: input.level, changes }) + } + + return { depth, steps } +} + +function fullRowBatchScenarioArbitrary( + allowChildRelationshipUpdates: boolean, + levels: `all` | `children` = `all`, + maxBatchSize = 3, +): fc.Arbitrary { + return depthArbitrary.chain((depth) => + fc + .array(fullRowBatchInputArbitrary(depth, levels, maxBatchSize), { + minLength: 1, + maxLength: 10, + }) + .map((inputs) => + normalizeFullRowBatchInputs( + depth, + inputs, + allowChildRelationshipUpdates, + ), + ), + ) +} + +async function expectFullRowBatchScenarioMatches({ + depth, + steps, +}: FullRowBatchScenario): Promise { + await runTrace({ + steps, + driver: createFullRowBatchTraceDriver(depth), + projection: structuralProjection, + }) +} + +type FlatMaterialization = `array` | `concat` + +function createFlatMaterializationQuery( + materialization: FlatMaterialization, + sources: Sources, +) { + if (materialization === `array`) { + return createLiveQueryCollection((q) => + q + .from({ root: sources.roots.collection }) + .orderBy(({ root }) => root.position) + .orderBy(({ root }) => root.id) + .select(({ root }) => ({ + id: root.id, + group: root.group, + children: materialize( + q + .from({ child: sources.levels[0].collection }) + .where(({ child }) => eq(child.parentGroup, root.group)) + .orderBy(({ child }) => child.position) + .orderBy(({ child }) => child.id) + .select(({ child }) => ({ id: child.id, value: child.value })), + ), + })), + ) + } + + return createLiveQueryCollection((q) => + q + .from({ root: sources.roots.collection }) + .orderBy(({ root }) => root.position) + .orderBy(({ root }) => root.id) + .select(({ root }) => ({ + id: root.id, + group: root.group, + content: concat( + toArray( + q + .from({ child: sources.levels[0].collection }) + .where(({ child }) => eq(child.parentGroup, root.group)) + .orderBy(({ child }) => child.position) + .orderBy(({ child }) => child.id) + .select(({ child }) => child.value), + ), + ), + })), + ) +} + +type FlatMaterializationContext = Omit< + StructuralTraceContext, + `incremental` +> & { + incremental: ReturnType +} + +function createFlatMaterializationDriver( + materialization: FlatMaterialization, +): TraceDriver { + return { + setup: () => { + const sources = createSources(`full`) + return { + depth: 1, + sources, + incremental: createFlatMaterializationQuery(materialization, sources), + roots: new Map(), + levels: Array.from({ length: 4 }, () => new Map()), + } + }, + start: ({ incremental }) => incremental.preload(), + apply: (step, { sources, roots, levels }) => { + if (step.level === 0) { + sources.roots.writeBatch(step.changes) + updateModel(roots, step.changes) + return + } + + if (step.level !== 1) { + throw new Error(`Flat materialization only supports depth 1`) + } + sources.levels[0].writeBatch(step.changes) + updateModel(levels[0]!, step.changes) + }, + cleanup: cleanupStructuralTrace, + } +} + +type FlatMaterializationResult = Array< + | { + id: number + group: number + children: Array<{ id: number; value: number }> + } + | { id: number; group: number; content: string } +> + +function recomputeFlatMaterialization( + materialization: FlatMaterialization, + roots: Map, + children: Map, +): FlatMaterializationResult { + return [...roots.values()].sort(compareRows).map((root) => { + const matching = [...children.values()] + .filter((child) => child.parentGroup === root.group) + .sort(compareRows) + return materialization === `array` + ? { + id: root.id, + group: root.group, + children: matching.map(({ id, value }) => ({ id, value })), + } + : { + id: root.id, + group: root.group, + content: matching.map(({ value }) => String(value)).join(``), + } + }) +} + +function flatMaterializationProjection( + materialization: FlatMaterialization, +): TraceProjection< + FlatMaterializationContext, + unknown, + FlatMaterializationResult +> { + return { + observe: ({ incremental }) => stripVirtualProperties(incremental.toArray), + recompute: ({ roots, levels }) => + recomputeFlatMaterialization(materialization, roots, levels[0]!), + assertEqual: (observed, expected) => { + expect(observed).toEqual(expected) + return undefined + }, + } +} + +const flatMaterializationScenarioArbitrary = fc + .array(fullRowBatchInputArbitrary(1, `all`), { + minLength: 1, + maxLength: 12, + }) + .map((inputs) => normalizeFullRowBatchInputs(1, inputs, false)) + +async function expectFlatMaterializationScenarioMatches( + materialization: FlatMaterialization, + scenario: FullRowBatchScenario, +): Promise { + await runTrace({ + steps: scenario.steps, + driver: createFlatMaterializationDriver(materialization), + projection: flatMaterializationProjection(materialization), + }) +} + const structuralProjection: TraceProjection< StructuralTraceContext, unknown, @@ -1149,7 +1526,75 @@ async function expectMaterializeScenarioMatches({ }) } +const intraBatchChildHandOffScenario: FullRowBatchScenario = { + depth: 1, + steps: [ + { + level: 0, + changes: [ + { type: `insert`, value: batchRoot(0, 1, 0, 0) }, + { type: `insert`, value: batchRoot(1, 3, 0, 0) }, + ], + }, + { + level: 1, + changes: [ + { type: `insert`, value: batchChild(5, 3, 0, 0) }, + { type: `insert`, value: batchChild(1, 1, 0, 0) }, + ], + }, + { + level: 1, + changes: [ + { type: `update`, value: batchChild(1, 0, 0, 0) }, + { type: `update`, value: batchChild(5, 1, 0, 0) }, + ], + }, + ], +} + describe(`includes recompute oracle`, () => { + for (const materialization of [`array`, `concat`] as const) { + fcTest( + `discovered trace: ${materialization} follows an intra-batch child hand-off`, + expectAssertionFailure(async () => { + await expectFlatMaterializationScenarioMatches( + materialization, + intraBatchChildHandOffScenario, + ) + }), + ) + } + + fcTest.prop( + [ + fc.constantFrom(`array`, `concat`), + flatMaterializationScenarioArbitrary, + ], + { + numRuns: 30, + seed: 1721, + }, + )(`matches recomputation for flat materializations`, (kind, scenario) => + expectFlatMaterializationScenarioMatches(kind, scenario), + ) + + fcTest.prop([fullRowBatchScenarioArbitrary(false)], { + numRuns: 40, + seed: 1719, + })( + `matches recomputation for generated full-row batches at every depth`, + expectFullRowBatchScenarioMatches, + ) + + fcTest.prop([fullRowBatchScenarioArbitrary(true, `children`, 1)], { + numRuns: 40, + seed: 1721, + })( + `matches recomputation for single-row child reparenting and rekeying`, + expectFullRowBatchScenarioMatches, + ) + fcTest( `discovered seed: nested scalar materialization follows a reference update`, expectAssertionFailure(async () => { @@ -1170,11 +1615,18 @@ describe(`includes recompute oracle`, () => { fcTest(`matches recomputation for full-row sync batches`, async () => { await runTrace({ steps: fullRowBatchTrace, - driver: createFullRowBatchTraceDriver(), + driver: createFullRowBatchTraceDriver(1), projection: structuralProjection, }) }) + fcTest( + `discovered seed: a reinserted parent drops its old shared route`, + expectAssertionFailure(() => + expectFullRowBatchScenarioMatches(fullRowSharedRoutingSeed), + ), + ) + fcTest(`supports repeated optimistic rollbacks in one history`, async () => { await expectScenarioMatches({ depth: 1,