mirror of
https://github.com/morluto/rea.git
synced 2026-10-28 02:15:44 +00:00
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:
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
|
||||
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
|
||||
|
||||
@@ -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
@@ -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])],
|
||||
|
||||
Reference in new issue
Block a user