fix(environment): learn the soft-delete flag in the state view and serve by it

Extraction now records the member later item reads set true on a record the
application soft-deleted, and the item view carries it as `deleted_flag`.
Serving reads that fact instead of scanning recorded bodies itself, so one
owner decides what a soft delete looks like.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Edgar Arakelyan
2026-10-26 16:51:12 -07:00
parent e29e9c741e
commit 9382102443
9 changed files with 63 additions and 60 deletions
+6 -5
View File
@@ -765,11 +765,12 @@ sending a `derived` rule's member to an entity that does not hold the value the
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 recorded item reads answer a deleted record
marked deleted (`deleted`, `isDeleted` or `is_deleted` `true`, 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 state unchanged. Values a write generates are
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
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
state unchanged. Values a write generates are
deterministic: the clock starts at `write_clock` and advances one second per
served write, sequences take one past both the largest held value and the
largest they generated, and UUIDs and tokens derive from the state Evidence
@@ -19,8 +19,9 @@ 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. 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.
an object never seeds or adopts an entity. The item view records the flag later 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
membership and for a list query's `limit` role.
- **Fields only later reads show.** An item view also shows the members that reads after its type's first write show
+2 -1
View File
@@ -846,7 +846,8 @@ the entity or list rendered into the envelope of the item view's or the line's
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. A match confirms
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
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
@@ -2,9 +2,7 @@ import { hasRepeatedQueryArgument } from "./environmentTools.js";
import { stateBodyJson } from "../../domain/analysisView/EnvironmentStateAccumulator.js";
import {
ownMember,
at,
collectionMatch,
deletedFlag,
collectionPathOf,
decodedSegment,
allLines,
@@ -277,51 +275,18 @@ export const namedEntity = (
store.entity(type, collection, id),
);
/** Per Evidence, the member each type's recorded item reads set `true` on a soft-deleted entity, or null for none. */
const deletedFlags = new WeakMap<
EnvironmentSource,
Map<string, string | null>
>();
/** The member a type's recorded item reads set `true` on an entity the application soft-deleted, if they show one. */
/** The member a type's item view learned its reads set `true` on an entity the application soft-deleted, if any. */
export const softDeleteFlag = (
serving: StateServing,
type: string,
): string | undefined => {
let flags = deletedFlags.get(serving.source);
if (flags === undefined) {
flags = new Map();
deletedFlags.set(serving.source, flags);
}
if (!flags.has(type))
flags.set(type, recordedDeletedFlag(serving, type) ?? null);
return flags.get(type) ?? undefined;
};
const recordedDeletedFlag = (
serving: StateServing,
type: string,
): string | undefined => {
for (const view of serving.store.state.views.values()) {
if (view.shape !== "item" || view.type !== type) continue;
const tool = serving.source.manifest.tools.find(
(candidate) => candidate.name === view.tool,
);
for (const ordinal of tool?.record_ordinals ?? []) {
const exchange = serving.source.exchanges.get(ordinal);
if (
exchange === undefined ||
exchange.status === null ||
exchange.status < 200 ||
exchange.status > 299
)
continue;
const body = recordedJson(exchange);
const flag =
body === undefined ? undefined : deletedFlag(at(body, view.pointer));
if (flag !== undefined) return flag;
}
}
for (const view of serving.store.state.views.values())
if (
view.shape === "item" &&
view.type === type &&
view.deleted_flag !== undefined
)
return view.deleted_flag;
return undefined;
};
@@ -10,6 +10,7 @@ import {
apiBaseLengths,
at,
collectionPathOf,
deletedFlag,
identifies,
IDENTITY_NAMES,
identityCandidates,
@@ -439,7 +440,10 @@ export class EnvironmentStateExtraction {
candidate: Candidate,
pointer: string,
recordings: readonly StateRecordFacts[],
replayed: Pick<StatePlan, "projected" | "late" | "laterFields"> = {},
replayed: Pick<
StatePlan,
"projected" | "late" | "laterFields" | "deletedFlag"
> = {},
): void {
this.#plans.push({
...candidate,
@@ -452,19 +456,23 @@ export class EnvironmentStateExtraction {
replayed.laterFields.length === 0
? {}
: { laterFields: replayed.laterFields }),
...(replayed.deletedFlag === undefined
? {}
: { deletedFlag: replayed.deletedFlag }),
});
}
/** The members later plain item reads show of the entity they name, in first-seen order. */
/** 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. */
#laterFields(
tool: StateToolFacts,
type: string,
entity: { readonly field: string; readonly pointer: string },
): string[] {
): Pick<StatePlan, "laterFields" | "deletedFlag"> {
const limit = this.#initLimit(type);
const names: string[] = [];
const seen = new Set<string>();
if (limit === undefined) return names;
let flag: StatePlan["deletedFlag"];
if (limit === undefined) return { laterFields: names };
for (const ordinal of tool.record_ordinals) {
const record = this.#byOrdinal.get(ordinal);
if (
@@ -481,6 +489,7 @@ export class EnvironmentStateExtraction {
body === undefined ? undefined : at(body, entity.pointer);
if (!isObject(object) || ownMember(object, entity.field) === undefined)
return;
flag ??= deletedFlag(object);
for (const name of Object.keys(object))
if (!seen.has(name)) {
seen.add(name);
@@ -489,7 +498,9 @@ export class EnvironmentStateExtraction {
}
});
}
return names;
return flag === undefined
? { laterFields: names }
: { laterFields: names, deletedFlag: flag };
}
/**
@@ -599,8 +610,8 @@ export class EnvironmentStateExtraction {
this.#chronology.positions.get(left)! -
this.#chronology.positions.get(right)!,
);
const laterFields = this.#laterFields(tool, type, { field, pointer });
this.#addPlan(candidate, pointer, kept, { projected, late, laterFields });
const later = this.#laterFields(tool, type, { field, pointer });
this.#addPlan(candidate, pointer, kept, { projected, late, ...later });
}
/**
@@ -67,6 +67,8 @@ 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. */
readonly deletedFlag?: StateItemView["deleted_flag"];
/** The query keys that pick calls of the replayed reads out. */
readonly projectedKeys?: readonly ReplayKey[];
}
@@ -233,6 +235,9 @@ export const checkItemPlan = (
fields: union.fields,
required_fields: union.required,
envelope_ordinal: plan.latest[0]!.ordinal,
...(plan.deletedFlag === undefined
? {}
: { deleted_flag: plan.deletedFlag }),
...declaredMembers(envelope.members, context.retain),
checked_record_ordinals: plan.latest.map((record) => record.ordinal),
...replayedReads(plan),
@@ -29,6 +29,16 @@ describe("deletes the application answers for afterwards", () => {
});
});
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");
expect(item(SOFT_DELETED_ROWS)).toMatchObject({
shape: "item",
deleted_flag: "deleted",
});
expect(item(MAX_SIZE_ROWS)).not.toHaveProperty("deleted_flag");
});
it.each([
[{ deleted: true }, true],
[{ isDeleted: true }, true],
@@ -151,6 +151,8 @@ 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. */
readonly deleted_flag?: (typeof DELETED_FLAGS)[number] | undefined;
};
/** A query parameter that asks for some fields only (`fields`, `fields[...]`, `select`, `$select`). */
@@ -190,12 +192,12 @@ export type StateListView = EntityViewPlan &
};
/** Members an application sets `true` on an entity it has soft-deleted but still answers for by id. */
const DELETED_FLAGS = ["deleted", "isDeleted", "is_deleted"];
export const DELETED_FLAGS = ["deleted", "isDeleted", "is_deleted"] as const;
/** The member an item read's object sets `true` to mark the entity soft-deleted, if it does. */
export const deletedFlag = (
object: JsonValue | undefined,
): string | undefined =>
): (typeof DELETED_FLAGS)[number] | undefined =>
isObject(object)
? DELETED_FLAGS.find((name) => ownMember(object, name) === true)
: undefined;
@@ -9,6 +9,7 @@ import type {
UnsignedAnalysisView,
} from "./analysisView.js";
import { EnvironmentStateAccumulator } from "./EnvironmentStateAccumulator.js";
import { DELETED_FLAGS } from "./environmentStateRules.js";
import { isClockZone } from "./environmentStateWriteValues.js";
import { CLOCK_PATTERNS } from "./environmentStateWriteClockFormats.js";
import {
@@ -168,6 +169,12 @@ const viewSchema = z.discriminatedUnion("shape", [
.describe(
"Record whose response, with the entity object replaced, every item response shares.",
),
deleted_flag: z
.enum(DELETED_FLAGS)
.optional()
.describe(
"Member later reads set true on an entity the application soft-deleted.",
),
...replayedReadFields,
}),
z.strictObject({