From 1106a5263a9d72e4f922df85f722bf37e962e76a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:53:27 +0800 Subject: [PATCH 1/2] fix(environment): compare soft-deleted read fields --- .../environmentStateLaterReads.test.ts | 16 ++++++++ .../environmentStateWriteChecks.ts | 40 ++++++++++++++----- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/src/domain/analysisView/environmentStateLaterReads.test.ts b/src/domain/analysisView/environmentStateLaterReads.test.ts index 7e1dd3c4e3..1771e4abe8 100644 --- a/src/domain/analysisView/environmentStateLaterReads.test.ts +++ b/src/domain/analysisView/environmentStateLaterReads.test.ts @@ -32,6 +32,22 @@ describe("deletes the application answers for afterwards", () => { }); }); + it("withholds a soft delete when its read changes another field", () => { + const rows: readonly TimedCaptureRow[] = SOFT_DELETED_ROWS.map((row) => + row[0] === "GET" && + row[1] === "/api/contacts/2" && + typeof row[2] === "object" && + row[2] !== null && + !Array.isArray(row[2]) && + row[2]["deleted"] === true + ? [row[0], row[1], { ...row[2], name: "Changed by deletion" }] + : row, + ); + expect(planOf(rows, "delete_contacts_by_id")).toMatchObject({ + served: false, + }); + }); + it("learns on the item view the flag later reads set on a soft-deleted contact", () => { const item = (rows: readonly TimedCaptureRow[]) => viewOf(rows).views.find((view) => view.tool === "get_contacts_by_id"); diff --git a/src/domain/analysisView/environmentStateWriteChecks.ts b/src/domain/analysisView/environmentStateWriteChecks.ts index 1536407f9f..9852e5cc85 100644 --- a/src/domain/analysisView/environmentStateWriteChecks.ts +++ b/src/domain/analysisView/environmentStateWriteChecks.ts @@ -5,10 +5,10 @@ import { ownMember, at, decodedSegment, + deletedFlag, isObject, listItems, listsAll, - markedDeleted, missingRequiredFields, pathSegmentMatches, renderStateEntity, @@ -57,7 +57,7 @@ export class SimulatedState { readonly #entities = new Map(); readonly #collections = new Map(); readonly #documents = new Map(); - readonly #removed: RemovedTarget[] = []; + readonly #removed: StateEntity[] = []; readonly #served = new ServedOrder(); /** Each held id's targets, of any type. */ readonly #named = new Map>(); @@ -265,11 +265,20 @@ export class SimulatedState { target.id === entity.id, ); if (index >= 0) this.#removed.splice(index, 1); - this.#removed.push({ - type: entity.type, - collection: entity.collection, - id: entity.id, - }); + this.#removed.push(entity); + } + + deleted( + type: string, + collection: string, + id: string, + ): StateEntity | undefined { + return this.#removed.find( + (entity) => + entity.type === type && + entity.collection === collection && + entity.id === id, + ); } /** Removed targets of this type the state does not hold again. */ @@ -497,9 +506,20 @@ export const checkItemRead = ( return check.body === undefined || entity === undefined ? undefined : { pass: false, targets, unheld: [] }; - // An entity the application answers for as soft-deleted is one serving no longer holds. - if (markedDeleted(object)) - return { pass: entity === undefined, targets, unheld: [] }; + // Serving keeps a removed entity's fields and changes only the soft-delete flag. + const flag = deletedFlag(object); + if (flag !== undefined) { + const removed = state.deleted(view.type, collection, id); + if (entity !== undefined) return { pass: false, targets, unheld: [] }; + if (removed === undefined) return { pass: true, targets, unheld: [] }; + const deleted = { ...removed, fields: { ...removed.fields, [flag]: true } }; + const shown = renderStateEntity(deleted, view, check.volatile, object); + return { + pass: same(replaceAt(check.base, view.pointer, shown), check.body), + targets, + unheld: [], + }; + } if (entity === undefined) return { pass: false, targets, unheld: targets }; // Serving refuses an entity lacking a field every recording carried, so no read can confirm one. if (missingRequiredFields(entity, view, check.volatile).length > 0) 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 2/2] 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. */