mirror of
https://github.com/morluto/rea.git
synced 2026-11-03 21:36:13 +00:00
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:
1 parent
88e16f1058
commit
3bf6ef397f
6 files changed
+88
-8
No files matched your search
@@ -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
@@ -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 => {
|
||||
|
||||
Reference in new issue
Block a user