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] 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)