mirror of
https://github.com/morluto/rea.git
synced 2026-10-28 18:35:53 +00:00
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:
1 parent
e29e9c741e
commit
9382102443
9 files changed
+63
-60
No files matched your search
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user