diff --git a/docs/agent-environments.md b/docs/agent-environments.md index b8bfdd5c58..c7c9003ad3 100644 --- a/docs/agent-environments.md +++ b/docs/agent-environments.md @@ -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 diff --git a/docs/specs/agent-environments/write-later-reads/spec.md b/docs/specs/agent-environments/write-later-reads/spec.md index 7653efbc0d..e411ae81d7 100644 --- a/docs/specs/agent-environments/write-later-reads/spec.md +++ b/docs/specs/agent-environments/write-later-reads/spec.md @@ -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 diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index cc96f21dc9..639b7069bf 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -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 diff --git a/src/application/environment/environmentStateLaterReadsAnswer.test.ts b/src/application/environment/environmentStateLaterReadsAnswer.test.ts index 827e98d305..029dfc7257 100644 --- a/src/application/environment/environmentStateLaterReadsAnswer.test.ts +++ b/src/application/environment/environmentStateLaterReadsAnswer.test.ts @@ -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", { diff --git a/src/domain/analysisView/EnvironmentStateExtraction.ts b/src/domain/analysisView/EnvironmentStateExtraction.ts index be8854da63..1d0ad82995 100644 --- a/src/domain/analysisView/EnvironmentStateExtraction.ts +++ b/src/domain/analysisView/EnvironmentStateExtraction.ts @@ -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); diff --git a/src/domain/analysisView/environmentStateGate.ts b/src/domain/analysisView/environmentStateGate.ts index 7d38264e00..fb059287d5 100644 --- a/src/domain/analysisView/environmentStateGate.ts +++ b/src/domain/analysisView/environmentStateGate.ts @@ -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[]; diff --git a/src/domain/analysisView/environmentStateLaterReads.test.ts b/src/domain/analysisView/environmentStateLaterReads.test.ts index 19c28af4cc..cb807dd3eb 100644 --- a/src/domain/analysisView/environmentStateLaterReads.test.ts +++ b/src/domain/analysisView/environmentStateLaterReads.test.ts @@ -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"); }); diff --git a/src/domain/analysisView/environmentStateRules.ts b/src/domain/analysisView/environmentStateRules.ts index 5d289b455e..19e2ac94c2 100644 --- a/src/domain/analysisView/environmentStateRules.ts +++ b/src/domain/analysisView/environmentStateRules.ts @@ -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; }; diff --git a/src/domain/analysisView/environmentStateView.ts b/src/domain/analysisView/environmentStateView.ts index 3b7a06d965..ed46acdc92 100644 --- a/src/domain/analysisView/environmentStateView.ts +++ b/src/domain/analysisView/environmentStateView.ts @@ -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, }), diff --git a/tests/fixtures/environmentStateLaterReads.ts b/tests/fixtures/environmentStateLaterReads.ts index 00e545a4e3..ceffe4c657 100644 --- a/tests/fixtures/environmentStateLaterReads.ts +++ b/tests/fixtures/environmentStateLaterReads.ts @@ -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])],