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 <[email protected]>
This commit is contained in:
1 parent
9382102443
commit
4583240658
10 files changed
+41
-10
No files matched your search
@@ -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
|
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
|
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
|
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
|
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
|
`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
|
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`)
|
- **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
|
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
|
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.
|
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
|
- **Page sizes.** `maxSize`, `max_results` and `maxResults` are page sizes, for a list read's whole-collection
|
||||||
|
|||||||
@@ -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`
|
`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
|
`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
|
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
|
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
|
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
|
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 {
|
import {
|
||||||
LATER_BULK_ROWS,
|
LATER_BULK_ROWS,
|
||||||
LATER_FIELD_ROWS,
|
LATER_FIELD_ROWS,
|
||||||
|
SOFT_DELETED_QUERY_ROWS,
|
||||||
SOFT_DELETED_ROWS,
|
SOFT_DELETED_ROWS,
|
||||||
} from "../../../tests/fixtures/environmentStateLaterReads.js";
|
} from "../../../tests/fixtures/environmentStateLaterReads.js";
|
||||||
import { servedFrom } from "../../../tests/fixtures/environmentStateServing.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", () => {
|
it("answers an update of an initial entity without the field only created entities show", () => {
|
||||||
const tasks = servedFrom(LATER_FIELD_ROWS);
|
const tasks = servedFrom(LATER_FIELD_ROWS);
|
||||||
const body = tasks.answer("patch_tasks_by_id", {
|
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(
|
#laterFields(
|
||||||
tool: StateToolFacts,
|
tool: StateToolFacts,
|
||||||
type: string,
|
type: string,
|
||||||
@@ -479,17 +479,20 @@ export class EnvironmentStateExtraction {
|
|||||||
record === undefined ||
|
record === undefined ||
|
||||||
record.status === null ||
|
record.status === null ||
|
||||||
record.status < 200 ||
|
record.status < 200 ||
|
||||||
record.status >= 300 ||
|
record.status >= 300
|
||||||
(record.query ?? "") !== "" ||
|
|
||||||
this.#beforeInitLimit(record, limit)
|
|
||||||
)
|
)
|
||||||
continue;
|
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) => {
|
this.#withBody(ordinal, (body) => {
|
||||||
const object =
|
const object =
|
||||||
body === undefined ? undefined : at(body, entity.pointer);
|
body === undefined ? undefined : at(body, entity.pointer);
|
||||||
if (!isObject(object) || ownMember(object, entity.field) === undefined)
|
if (!isObject(object) || ownMember(object, entity.field) === undefined)
|
||||||
return;
|
return;
|
||||||
flag ??= deletedFlag(object);
|
flag ??= deletedFlag(object);
|
||||||
|
if (!later) return;
|
||||||
for (const name of Object.keys(object))
|
for (const name of Object.keys(object))
|
||||||
if (!seen.has(name)) {
|
if (!seen.has(name)) {
|
||||||
seen.add(name);
|
seen.add(name);
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export interface StatePlan {
|
|||||||
readonly late?: readonly LateLine[];
|
readonly late?: readonly LateLine[];
|
||||||
/** The members later plain item reads show of the entity they name. */
|
/** The members later plain item reads show of the entity they name. */
|
||||||
readonly laterFields?: readonly string[];
|
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"];
|
readonly deletedFlag?: StateItemView["deleted_flag"];
|
||||||
/** The query keys that pick calls of the replayed reads out. */
|
/** The query keys that pick calls of the replayed reads out. */
|
||||||
readonly projectedKeys?: readonly ReplayKey[];
|
readonly projectedKeys?: readonly ReplayKey[];
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
LATER_FIELD_ROWS,
|
LATER_FIELD_ROWS,
|
||||||
MAX_SIZE_ROWS,
|
MAX_SIZE_ROWS,
|
||||||
|
SOFT_DELETED_QUERY_ROWS,
|
||||||
SOFT_DELETED_ROWS,
|
SOFT_DELETED_ROWS,
|
||||||
} from "../../../tests/fixtures/environmentStateLaterReads.js";
|
} from "../../../tests/fixtures/environmentStateLaterReads.js";
|
||||||
import { environmentStateViewOf } from "../../../tests/fixtures/environmentStateWrites.js";
|
import { environmentStateViewOf } from "../../../tests/fixtures/environmentStateWrites.js";
|
||||||
@@ -36,6 +37,10 @@ describe("deletes the application answers for afterwards", () => {
|
|||||||
shape: "item",
|
shape: "item",
|
||||||
deleted_flag: "deleted",
|
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");
|
expect(item(MAX_SIZE_ROWS)).not.toHaveProperty("deleted_flag");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ export type StateItemView = EntityViewPlan &
|
|||||||
ReplayedReads & {
|
ReplayedReads & {
|
||||||
readonly shape: "item";
|
readonly shape: "item";
|
||||||
readonly envelope_ordinal: number;
|
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;
|
readonly deleted_flag?: (typeof DELETED_FLAGS)[number] | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ const viewSchema = z.discriminatedUnion("shape", [
|
|||||||
.enum(DELETED_FLAGS)
|
.enum(DELETED_FLAGS)
|
||||||
.optional()
|
.optional()
|
||||||
.describe(
|
.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,
|
...replayedReadFields,
|
||||||
}),
|
}),
|
||||||
|
|||||||
+12
@@ -30,6 +30,18 @@ export const SOFT_DELETED_ROWS: readonly TimedCaptureRow[] = [
|
|||||||
["GET", "/api/contacts/3", { ...RUI, deleted: true }],
|
["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. */
|
/** Contacts deleted, then listed with a page size named `maxSize` other than the initial list's. */
|
||||||
export const MAX_SIZE_ROWS: readonly TimedCaptureRow[] = [
|
export const MAX_SIZE_ROWS: readonly TimedCaptureRow[] = [
|
||||||
["GET", "/api/contacts?maxSize=20&offset=0", page([ADA, MINA, RUI])],
|
["GET", "/api/contacts?maxSize=20&offset=0", page([ADA, MINA, RUI])],
|
||||||
|
|||||||
Reference in new issue
Block a user