diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index ccc6e9700e..0ed1f35d98 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -849,8 +849,9 @@ base recording, with the members the state still holds. A 400, 404 or 410, a copy does not hold; such a read drops a held entity it names and adopts none, while an initial read keeps the record it shows held, as its item read answers it. The item view records the flag its reads set as `deleted_flag`, a field it shows -even when only a query read showed it, which serving answers by: a served delete -answers with the entity marked deleted, as later reads of it are. A match confirms +even when only a query read showed it, which serving answers by: later reads of +a served-deleted entity show it marked deleted, and a served delete answers with +the flag as its recorded answer showed it. A match confirms the writes that changed its entity or collection since it was last read, less those whose entity a read since showed absent, which no longer count for the collection; a mismatch contradicts them, or, when an unmodelled write came after them, is diff --git a/src/application/environment/environmentStateAnswer.ts b/src/application/environment/environmentStateAnswer.ts index 50441f65d1..f66748e96c 100644 --- a/src/application/environment/environmentStateAnswer.ts +++ b/src/application/environment/environmentStateAnswer.ts @@ -1,6 +1,7 @@ import { hasRepeatedQueryArgument } from "./environmentTools.js"; import { stateBodyJson } from "../../domain/analysisView/EnvironmentStateAccumulator.js"; import { + isObject, ownMember, collectionMatch, collectionPathOf, @@ -291,7 +292,7 @@ export const softDeleteFlag = ( }; /** Fields as an application that soft-deletes shows them once deleted: with the type's deleted flag set, when it has one. */ -export const deletedFields = ( +const deletedFields = ( serving: StateServing, type: string, fields: JsonObject, @@ -300,6 +301,19 @@ export const deletedFields = ( return flag === undefined ? fields : { ...fields, [flag]: true }; }; +/** Fields as a delete's answer shows them: with the type's deleted flag as its recorded answer `shown` gave it, if it did. */ +export const deleteAnswerFields = ( + serving: StateServing, + type: string, + fields: JsonObject, + shown: JsonValue | undefined, +): JsonObject => { + const flag = softDeleteFlag(serving, type); + const value = + flag === undefined || !isObject(shown) ? undefined : ownMember(shown, flag); + return value === undefined ? fields : { ...fields, [flag!]: value }; +}; + /** An entity a served delete removed, as an application that soft-deletes answers for it by id: with its deleted flag set. */ const deletedEntity = ( serving: StateServing, diff --git a/src/application/environment/environmentStateBulkAnswer.ts b/src/application/environment/environmentStateBulkAnswer.ts index 3b1a3cffdf..434cc9fdd9 100644 --- a/src/application/environment/environmentStateBulkAnswer.ts +++ b/src/application/environment/environmentStateBulkAnswer.ts @@ -23,7 +23,7 @@ import { callSegments } from "./environmentStateCallLines.js"; import { namedEntity, refused, - deletedFields, + deleteAnswerFields, softDeleteFlag, type StateAnswer, type StateServing, @@ -184,7 +184,12 @@ export const answerBulk = ( ...entity, fields: update ? entity.fields - : deletedFields(serving, entity.type, entity.fields), + : deleteAnswerFields( + serving, + entity.type, + entity.fields, + Array.isArray(recorded) ? recorded[0] : undefined, + ), sent: Object.keys(targets.value[index]!.body ?? {}), })), optionalFields(plan), diff --git a/src/application/environment/environmentStateLaterReadsAnswer.test.ts b/src/application/environment/environmentStateLaterReadsAnswer.test.ts index 7c74b3ee47..fca59e26d9 100644 --- a/src/application/environment/environmentStateLaterReadsAnswer.test.ts +++ b/src/application/environment/environmentStateLaterReadsAnswer.test.ts @@ -7,6 +7,8 @@ import { SOFT_DELETED_ANSWER_ROWS, SOFT_DELETED_QUERY_ROWS, SOFT_DELETED_ROWS, + STALE_DELETE_ANSWER_ROWS, + STALE_BULK_DELETE_ROWS, } from "../../../tests/fixtures/environmentStateLaterReads.js"; import { servedFrom } from "../../../tests/fixtures/environmentStateServing.js"; import type { JsonValue } from "../../domain/jsonValue.js"; @@ -91,6 +93,27 @@ describe("served writes modelled on later reads", () => { }); }); + it("answers a soft delete with the flag its recorded answer showed, and reads the record back marked deleted", () => { + const contacts = servedFrom(STALE_DELETE_ANSWER_ROWS); + expect(contacts.answer("delete_contacts_by_id", { id: "1" }).body).toEqual({ + id: 1, + name: "Ada Finch", + deleted: false, + }); + expect(contacts.answer("get_contacts_by_id", { id: "1" }).body).toEqual({ + id: 1, + name: "Ada Finch", + deleted: true, + }); + }); + + it("answers a soft bulk delete with the flag its recorded answer showed", () => { + const contacts = servedFrom(STALE_BULK_DELETE_ROWS); + expect( + contacts.answer("delete_contacts", { body: [{ id: 1 }] }).body, + ).toEqual([{ id: 1, name: "Ada Finch", deleted: false }]); + }); + it("reads a soft-deleted record back marked deleted when live records omit the flag", () => { const contacts = servedFrom(OMITTED_FLAG_ROWS); contacts.answer("delete_contacts_by_id", { id: "1" }); diff --git a/src/application/environment/environmentStateWriteAnswer.ts b/src/application/environment/environmentStateWriteAnswer.ts index 15352c1ad3..0be2912e81 100644 --- a/src/application/environment/environmentStateWriteAnswer.ts +++ b/src/application/environment/environmentStateWriteAnswer.ts @@ -40,6 +40,7 @@ import { spelledLike, } from "../../domain/analysisView/environmentStateWriteValues.js"; import { + at, ownMember, collectionMatch, decodedSegment, @@ -66,7 +67,7 @@ import { namedEntity, recordedJson, refused, - deletedFields, + deleteAnswerFields, softDeleteFlag, stateAnswer, type StateAnswer, @@ -666,12 +667,25 @@ const answerDelete = ( const target = targetEntity(serving, request); if (!target.ok) return target.error; const entity = target.value; - // A soft-deleting application answers with the entity already marked deleted. + const { response } = request.plan; + const exchange = + response === null + ? undefined + : serving.source.exchanges.get(response.ordinal); + const recorded = exchange === undefined ? undefined : recordedJson(exchange); + // A soft-deleting application answers with the entity's deleted flag as its recorded answer showed it. const answer = writeAnswer( serving, request, { - fields: deletedFields(serving, entity.type, entity.fields), + fields: deleteAnswerFields( + serving, + entity.type, + entity.fields, + recorded === undefined + ? undefined + : at(recorded, response!.pointer ?? ""), + ), id: entity.id, }, { diff --git a/tests/fixtures/environmentStateLaterReads.ts b/tests/fixtures/environmentStateLaterReads.ts index 358f09b39d..18807c6486 100644 --- a/tests/fixtures/environmentStateLaterReads.ts +++ b/tests/fixtures/environmentStateLaterReads.ts @@ -42,6 +42,29 @@ export const SOFT_DELETED_ANSWER_ROWS: readonly TimedCaptureRow[] = [ ["GET", "/api/contacts/3", { ...RUI, deleted: true }], ]; +/** Contacts deleted by calls that answer with the record as it was, then read back marked deleted. */ +export const STALE_DELETE_ANSWER_ROWS: readonly TimedCaptureRow[] = [ + ["GET", "/api/contacts?maxSize=20&offset=0", page([ADA, MINA, RUI])], + ["GET", "/api/contacts/1", ADA], + ["GET", "/api/contacts/2", MINA], + ["GET", "/api/contacts/3", RUI], + ["DELETE", "/api/contacts/2", MINA], + ["GET", "/api/contacts/2", { ...MINA, deleted: true }], + ["DELETE", "/api/contacts/3", RUI], + ["GET", "/api/contacts/3", { ...RUI, deleted: true }], +]; + +/** Contacts removed by one bulk call that answers them as they were, then read back marked deleted. */ +export const STALE_BULK_DELETE_ROWS: readonly TimedCaptureRow[] = [ + ["GET", "/api/contacts?maxSize=20&offset=0", page([ADA, MINA, RUI])], + ["GET", "/api/contacts/1", ADA], + ["GET", "/api/contacts/2", MINA], + ["GET", "/api/contacts/3", RUI], + ["DELETE", "/api/contacts", [MINA, RUI], 200, [{ id: 2 }, { id: 3 }]], + ["GET", "/api/contacts/2", { ...MINA, deleted: true }], + ["GET", "/api/contacts/3", { ...RUI, deleted: true }], +]; + /** Live contacts that omit `deleted`, one deleted and read back only with a query showing it marked, then a list without it. */ export const OMITTED_FLAG_ROWS: readonly TimedCaptureRow[] = (() => { const live = (entity: Entity): Entity => {