fix(environment): compare soft-deleted read fields

This commit is contained in:
morluto committed 2026-10-28 04:53:27 +08:00
1 parent 0453253cb1
commit 1106a5263a
2 files changed
+46 -10

No files matched your search

@@ -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");
@@ -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<string, StateEntity>();
readonly #collections = new Map<string, string[]>();
readonly #documents = new Map<string, JsonValue>();
readonly #removed: RemovedTarget[] = [];
readonly #removed: StateEntity[] = [];
readonly #served = new ServedOrder();
/** Each held id's targets, of any type. */
readonly #named = new Map<string, Set<string>>();
@@ -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)