From ba8f1a236e48d92faaa9087cfc7775b28d87dcc5 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:54:20 +0800 Subject: [PATCH] fix(environment): reject contradictory learned sequences --- .../EnvironmentStateWriteSimulation.ts | 1 + .../environmentStateMadeValues.test.ts | 23 +++++++++++++++++++ .../environmentStateWriteCreate.ts | 3 +++ .../environmentStateWriteCreateCalls.ts | 8 +++++++ .../environmentStateWriteEffects.ts | 2 ++ 5 files changed, 37 insertions(+) diff --git a/src/domain/analysisView/EnvironmentStateWriteSimulation.ts b/src/domain/analysisView/EnvironmentStateWriteSimulation.ts index 4220c3e9f4..597e4d0ca3 100644 --- a/src/domain/analysisView/EnvironmentStateWriteSimulation.ts +++ b/src/domain/analysisView/EnvironmentStateWriteSimulation.ts @@ -296,6 +296,7 @@ export class EnvironmentStateWriteSimulation { updates: [], sequences: new Map(), starts: new Map(), + createCollections: [], steady: new Set(), unstamped: new Set(), kept: new Set(), diff --git a/src/domain/analysisView/environmentStateMadeValues.test.ts b/src/domain/analysisView/environmentStateMadeValues.test.ts index d15c7589bf..66375613c8 100644 --- a/src/domain/analysisView/environmentStateMadeValues.test.ts +++ b/src/domain/analysisView/environmentStateMadeValues.test.ts @@ -29,6 +29,29 @@ const ruleFor = (plan: ReturnType, field: string): unknown => plan.rules.find((rule) => rule.field === field); describe("values an application counts and makes with each created entity", () => { + it("does not adopt a sequence that contradicts an earlier accepted create", () => { + const a = { id: 1, title: "A", index: 1 }; + const b = { id: 2, title: "B", index: 2 }; + const c = { id: 3, title: "C", index: 15 }; + const d = { id: 4, title: "D", index: 15 }; + const e = { id: 5, title: "E", index: 16 }; + const rows: readonly TimedCaptureRow[] = [ + ["GET", "/api/tasks", [a, b]], + ["GET", "/api/tasks/1", a], + ["POST", "/api/tasks", c, 201, { title: "C" }], + ["GET", "/api/tasks/3", c], + ["GET", "/api/tasks", [a, b, c]], + ["POST", "/api/tasks", d, 201, { title: "D" }], + ["GET", "/api/tasks/4", d], + ["GET", "/api/tasks", [a, b, c, d]], + ["POST", "/api/tasks", e, 201, { title: "E" }], + ["GET", "/api/tasks/5", e], + ["GET", "/api/tasks", [a, b, c, d, e]], + ]; + const plan = createOf(rows); + expect(plan).toMatchObject({ served: false }); + expect(ruleFor(plan, "index")).toBeUndefined(); + }); it("learns a counter from where the first create stood, a template around it and the object made with each task", () => { const plan = createOf(COUNTED_TASK_ROWS); expect(plan).toMatchObject({ diff --git a/src/domain/analysisView/environmentStateWriteCreate.ts b/src/domain/analysisView/environmentStateWriteCreate.ts index 0fd8a4a15d..694c45d0c5 100644 --- a/src/domain/analysisView/environmentStateWriteCreate.ts +++ b/src/domain/analysisView/environmentStateWriteCreate.ts @@ -311,6 +311,7 @@ const earlierOf = (draft: WriteDraft) => ({ kept: draft.kept, clockOf: (field: string) => clockEvidence(draft, field), startOf: (field: string) => draft.starts.get(field), + createCollections: draft.createCollections, ampersands: draft.ampersands, }); @@ -968,6 +969,8 @@ export const applyCreate = ( draft.plan.rules, ); const inserted = insertCreated(context, draft, entity(stored, id)); + context.io.retain(collection.length * 2 + 16); + draft.createCollections.push(collection); return { kind: "applied", targets: [inserted, collectionTarget(type, collection)], diff --git a/src/domain/analysisView/environmentStateWriteCreateCalls.ts b/src/domain/analysisView/environmentStateWriteCreateCalls.ts index 52903710e9..d2e5bcdbe2 100644 --- a/src/domain/analysisView/environmentStateWriteCreateCalls.ts +++ b/src/domain/analysisView/environmentStateWriteCreateCalls.ts @@ -32,6 +32,7 @@ export interface EarlierEvidence { readonly clockOf: (field: string) => JsonValue | undefined; /** The integer the exemplar showed in an unruled field above every value its type held, where a sequence may start. */ readonly startOf?: (field: string) => number | undefined; + readonly createCollections?: readonly string[]; /** How the plan's accepted calls and the type's held entities spelled `&` in each field's slug; absent where none showed one. */ readonly ampersands?: ReadonlyMap; } @@ -269,6 +270,13 @@ const spellsExemplar = ( } if (rule.rule !== "sequence") return undefined; const start = calls.earlier.startOf?.(field); + const collections = calls.earlier.createCollections ?? []; + if ( + (rule.scope === "type" && collections.length > 1) || + (rule.scope === "collection" && + new Set(collections).size !== collections.length) + ) + return undefined; return start !== undefined && sequenceNumber(value) === start ? { ...rule, start } : undefined; diff --git a/src/domain/analysisView/environmentStateWriteEffects.ts b/src/domain/analysisView/environmentStateWriteEffects.ts index 27f1959865..4ace3c1121 100644 --- a/src/domain/analysisView/environmentStateWriteEffects.ts +++ b/src/domain/analysisView/environmentStateWriteEffects.ts @@ -96,6 +96,8 @@ export interface WriteDraft { readonly sequences: Map; /** Integers the exemplar showed in fields no rule wrote, above every value its type held, where a later call's sequence may start. */ readonly starts: Map; + /** Collections of accepted create calls, used to check late sequence adoption. */ + readonly createCollections: string[]; /** The collection a create writes into when its own path names no state type: the one its returned id was read under. */ readonly linked?: string | undefined; /** Fields an item action's call found already at the value it sets them to. */