fix(environment): answer a served delete with the deleted flag its recorded answer showed

An application may answer a delete with the record as it was and mark it
deleted only on later reads. Delete answers, single and bulk, now take
the flag's value from the recorded answer; reads of a served-deleted
entity still show it marked deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Edgar ArakelyanandClaude Opus 5.5 committed 2026-10-26 20:45:24 -07:00
1 parent 88e16f1058
commit 3bf6ef397f
6 files changed
+88 -8

No files matched your search

+3 -2
View File
@@ -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
@@ -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,
@@ -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),
@@ -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" });
@@ -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,
},
{
+23
View File
@@ -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 => {