fix(environment): learn the soft-delete flag from item reads with a query too

A client may read a deleted record back only with a query, such as a
cache-busting parameter. Every successful read of the item tool now shows the
flag; only plain later reads still add fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Edgar ArakelyanandClaude Opus 5.5 committed 2026-10-26 17:08:33 -07:00
1 parent 9382102443
commit 4583240658
10 files changed
+41 -10

No files matched your search

+1 -1
View File
@@ -766,7 +766,7 @@ recorded call found, a nested clock on an entity holding no such object, and a
rendered answer missing a field the recorded response carried, and an update or delete of an entity the state does not hold answers
the recorded 400, 404 or 410 of the tool or of an item view of the type, if
any. A served delete of a type whose item view has a `deleted_flag` (the
member, `deleted`, `isDeleted` or `is_deleted`, its later reads set `true` on a
member, `deleted`, `isDeleted` or `is_deleted`, its reads set `true` on a
record the application soft-deleted, as EspoCRM does) keeps the entity until
`data/reset` or `data/add`, within the size `data/add` loads, so a read of it by
id answers its last fields with that flag `true`. A refused call leaves the
@@ -19,7 +19,7 @@ each write back:
- **Soft deletes.** An item read whose object is marked deleted (`deleted`, `isDeleted` or `is_deleted` set to `true`)
agrees with an entity the state does not hold, as a `404` does. Replay drops the entity such a read names, and such
an object never seeds or adopts an entity. The item view records the flag later reads set as `deleted_flag`, and
an object never seeds or adopts an entity. The item view records the flag its reads set as `deleted_flag`, and
serving keeps an entity a served delete removed from such a type until a reset or load, so a read of it by id answers
its last fields with the flag `true`, as the application does.
- **Page sizes.** `maxSize`, `max_results` and `maxResults` are page sizes, for a list read's whole-collection
+1 -1
View File
@@ -847,7 +847,7 @@ base recording, with the members the state still holds. A 400, 404 or 410, a
`null` body, or an object marked deleted (`deleted`, `isDeleted` or `is_deleted`
`true`, as EspoCRM answers a read of a deleted record) agrees with an entity the
copy does not hold, and such an object never seeds or adopts one; the item view
records the flag later reads set as `deleted_flag`, which serving answers by. A match confirms
records the flag its reads set as `deleted_flag`, which serving answers by. 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
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
LATER_BULK_ROWS,
LATER_FIELD_ROWS,
SOFT_DELETED_QUERY_ROWS,
SOFT_DELETED_ROWS,
} from "../../../tests/fixtures/environmentStateLaterReads.js";
import { servedFrom } from "../../../tests/fixtures/environmentStateServing.js";
@@ -52,6 +53,16 @@ describe("served writes modelled on later reads", () => {
});
});
it("reads a served-deleted contact back marked deleted when only reads with a query showed one", () => {
const contacts = servedFrom(SOFT_DELETED_QUERY_ROWS);
contacts.answer("delete_contacts_by_id", { id: "1" });
expect(contacts.answer("get_contacts_by_id", { id: "1" }).body).toEqual({
id: 1,
name: "Ada Finch",
deleted: true,
});
});
it("answers an update of an initial entity without the field only created entities show", () => {
const tasks = servedFrom(LATER_FIELD_ROWS);
const body = tasks.answer("patch_tasks_by_id", {
@@ -462,7 +462,7 @@ export class EnvironmentStateExtraction {
});
}
/** The members later plain item reads show of the entity they name, in first-seen order, and the flag they set on one the application soft-deleted. */
/** The members later plain item reads show of the entity they name, in first-seen order, and the flag any of its reads sets on one the application soft-deleted. */
#laterFields(
tool: StateToolFacts,
type: string,
@@ -479,17 +479,20 @@ export class EnvironmentStateExtraction {
record === undefined ||
record.status === null ||
record.status < 200 ||
record.status >= 300 ||
(record.query ?? "") !== "" ||
this.#beforeInitLimit(record, limit)
record.status >= 300
)
continue;
// Any read may show the soft-delete flag; only plain later reads add fields.
const later =
(record.query ?? "") === "" && !this.#beforeInitLimit(record, limit);
if (!later && flag !== undefined) continue;
this.#withBody(ordinal, (body) => {
const object =
body === undefined ? undefined : at(body, entity.pointer);
if (!isObject(object) || ownMember(object, entity.field) === undefined)
return;
flag ??= deletedFlag(object);
if (!later) return;
for (const name of Object.keys(object))
if (!seen.has(name)) {
seen.add(name);
@@ -67,7 +67,7 @@ export interface StatePlan {
readonly late?: readonly LateLine[];
/** The members later plain item reads show of the entity they name. */
readonly laterFields?: readonly string[];
/** The member later plain item reads set `true` on an entity the application soft-deleted. */
/** The member the tool's reads set `true` on an entity the application soft-deleted. */
readonly deletedFlag?: StateItemView["deleted_flag"];
/** The query keys that pick calls of the replayed reads out. */
readonly projectedKeys?: readonly ReplayKey[];
@@ -7,6 +7,7 @@ import {
import {
LATER_FIELD_ROWS,
MAX_SIZE_ROWS,
SOFT_DELETED_QUERY_ROWS,
SOFT_DELETED_ROWS,
} from "../../../tests/fixtures/environmentStateLaterReads.js";
import { environmentStateViewOf } from "../../../tests/fixtures/environmentStateWrites.js";
@@ -36,6 +37,10 @@ describe("deletes the application answers for afterwards", () => {
shape: "item",
deleted_flag: "deleted",
});
// Reads with a query, such as a cache-busting parameter, show the flag as well.
expect(item(SOFT_DELETED_QUERY_ROWS)).toMatchObject({
deleted_flag: "deleted",
});
expect(item(MAX_SIZE_ROWS)).not.toHaveProperty("deleted_flag");
});
@@ -151,7 +151,7 @@ export type StateItemView = EntityViewPlan &
ReplayedReads & {
readonly shape: "item";
readonly envelope_ordinal: number;
/** The member later reads set `true` on an entity the application soft-deleted and still answers by id. */
/** The member its reads set `true` on an entity the application soft-deleted and still answers by id. */
readonly deleted_flag?: (typeof DELETED_FLAGS)[number] | undefined;
};
@@ -173,7 +173,7 @@ const viewSchema = z.discriminatedUnion("shape", [
.enum(DELETED_FLAGS)
.optional()
.describe(
"Member later reads set true on an entity the application soft-deleted.",
"Member its reads set true on an entity the application soft-deleted.",
),
...replayedReadFields,
}),
+12
View File
@@ -30,6 +30,18 @@ export const SOFT_DELETED_ROWS: readonly TimedCaptureRow[] = [
["GET", "/api/contacts/3", { ...RUI, deleted: true }],
];
/** The same deletes read back only with a query, as a client that adds a cache-busting parameter does. */
export const SOFT_DELETED_QUERY_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", true],
["GET", "/api/contacts/2?_=1761520000", { ...MINA, deleted: true }],
["DELETE", "/api/contacts/3", true],
["GET", "/api/contacts/3?_=1761520001", { ...RUI, deleted: true }],
];
/** Contacts deleted, then listed with a page size named `maxSize` other than the initial list's. */
export const MAX_SIZE_ROWS: readonly TimedCaptureRow[] = [
["GET", "/api/contacts?maxSize=20&offset=0", page([ADA, MINA, RUI])],