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 <[email protected]>
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
|
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 recorded item reads answer a deleted record
|
any. A served delete of a type whose item view has a `deleted_flag` (the
|
||||||
marked deleted (`deleted`, `isDeleted` or `is_deleted` `true`, as EspoCRM does)
|
member, `deleted`, `isDeleted` or `is_deleted`, its later reads set `true` on a
|
||||||
keeps the entity until `data/reset` or `data/add`, within the size `data/add`
|
record the application soft-deleted, as EspoCRM does) keeps the entity until
|
||||||
loads, so a read of it by id answers its last fields with that flag `true`. A
|
`data/reset` or `data/add`, within the size `data/add` loads, so a read of it by
|
||||||
refused call leaves the state unchanged. Values a write generates are
|
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
|
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
|
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
|
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`)
|
- **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. Serving keeps an entity a served delete removed from such a type until a
|
an object never seeds or adopts an entity. The item view records the flag later reads set as `deleted_flag`, and
|
||||||
reset or load, so a read of it by id answers its last fields with the flag `true`, as the application does.
|
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
|
- **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.
|
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
|
- **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
|
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. 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
|
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
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ import { hasRepeatedQueryArgument } from "./environmentTools.js";
|
|||||||
import { stateBodyJson } from "../../domain/analysisView/EnvironmentStateAccumulator.js";
|
import { stateBodyJson } from "../../domain/analysisView/EnvironmentStateAccumulator.js";
|
||||||
import {
|
import {
|
||||||
ownMember,
|
ownMember,
|
||||||
at,
|
|
||||||
collectionMatch,
|
collectionMatch,
|
||||||
deletedFlag,
|
|
||||||
collectionPathOf,
|
collectionPathOf,
|
||||||
decodedSegment,
|
decodedSegment,
|
||||||
allLines,
|
allLines,
|
||||||
@@ -277,51 +275,18 @@ export const namedEntity = (
|
|||||||
store.entity(type, collection, id),
|
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. */
|
/** The member a type's item view learned its reads set `true` on an entity the application soft-deleted, if any. */
|
||||||
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. */
|
|
||||||
export const softDeleteFlag = (
|
export const softDeleteFlag = (
|
||||||
serving: StateServing,
|
serving: StateServing,
|
||||||
type: string,
|
type: string,
|
||||||
): string | undefined => {
|
): string | undefined => {
|
||||||
let flags = deletedFlags.get(serving.source);
|
for (const view of serving.store.state.views.values())
|
||||||
if (flags === undefined) {
|
if (
|
||||||
flags = new Map();
|
view.shape === "item" &&
|
||||||
deletedFlags.set(serving.source, flags);
|
view.type === type &&
|
||||||
}
|
view.deleted_flag !== undefined
|
||||||
if (!flags.has(type))
|
)
|
||||||
flags.set(type, recordedDeletedFlag(serving, type) ?? null);
|
return view.deleted_flag;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
apiBaseLengths,
|
apiBaseLengths,
|
||||||
at,
|
at,
|
||||||
collectionPathOf,
|
collectionPathOf,
|
||||||
|
deletedFlag,
|
||||||
identifies,
|
identifies,
|
||||||
IDENTITY_NAMES,
|
IDENTITY_NAMES,
|
||||||
identityCandidates,
|
identityCandidates,
|
||||||
@@ -439,7 +440,10 @@ export class EnvironmentStateExtraction {
|
|||||||
candidate: Candidate,
|
candidate: Candidate,
|
||||||
pointer: string,
|
pointer: string,
|
||||||
recordings: readonly StateRecordFacts[],
|
recordings: readonly StateRecordFacts[],
|
||||||
replayed: Pick<StatePlan, "projected" | "late" | "laterFields"> = {},
|
replayed: Pick<
|
||||||
|
StatePlan,
|
||||||
|
"projected" | "late" | "laterFields" | "deletedFlag"
|
||||||
|
> = {},
|
||||||
): void {
|
): void {
|
||||||
this.#plans.push({
|
this.#plans.push({
|
||||||
...candidate,
|
...candidate,
|
||||||
@@ -452,19 +456,23 @@ export class EnvironmentStateExtraction {
|
|||||||
replayed.laterFields.length === 0
|
replayed.laterFields.length === 0
|
||||||
? {}
|
? {}
|
||||||
: { laterFields: replayed.laterFields }),
|
: { 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(
|
#laterFields(
|
||||||
tool: StateToolFacts,
|
tool: StateToolFacts,
|
||||||
type: string,
|
type: string,
|
||||||
entity: { readonly field: string; readonly pointer: string },
|
entity: { readonly field: string; readonly pointer: string },
|
||||||
): string[] {
|
): Pick<StatePlan, "laterFields" | "deletedFlag"> {
|
||||||
const limit = this.#initLimit(type);
|
const limit = this.#initLimit(type);
|
||||||
const names: string[] = [];
|
const names: string[] = [];
|
||||||
const seen = new Set<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) {
|
for (const ordinal of tool.record_ordinals) {
|
||||||
const record = this.#byOrdinal.get(ordinal);
|
const record = this.#byOrdinal.get(ordinal);
|
||||||
if (
|
if (
|
||||||
@@ -481,6 +489,7 @@ export class EnvironmentStateExtraction {
|
|||||||
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);
|
||||||
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);
|
||||||
@@ -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(left)! -
|
||||||
this.#chronology.positions.get(right)!,
|
this.#chronology.positions.get(right)!,
|
||||||
);
|
);
|
||||||
const laterFields = this.#laterFields(tool, type, { field, pointer });
|
const later = this.#laterFields(tool, type, { field, pointer });
|
||||||
this.#addPlan(candidate, pointer, kept, { projected, late, laterFields });
|
this.#addPlan(candidate, pointer, kept, { projected, late, ...later });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ 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. */
|
||||||
|
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[];
|
||||||
}
|
}
|
||||||
@@ -233,6 +235,9 @@ export const checkItemPlan = (
|
|||||||
fields: union.fields,
|
fields: union.fields,
|
||||||
required_fields: union.required,
|
required_fields: union.required,
|
||||||
envelope_ordinal: plan.latest[0]!.ordinal,
|
envelope_ordinal: plan.latest[0]!.ordinal,
|
||||||
|
...(plan.deletedFlag === undefined
|
||||||
|
? {}
|
||||||
|
: { deleted_flag: plan.deletedFlag }),
|
||||||
...declaredMembers(envelope.members, context.retain),
|
...declaredMembers(envelope.members, context.retain),
|
||||||
checked_record_ordinals: plan.latest.map((record) => record.ordinal),
|
checked_record_ordinals: plan.latest.map((record) => record.ordinal),
|
||||||
...replayedReads(plan),
|
...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([
|
it.each([
|
||||||
[{ deleted: true }, true],
|
[{ deleted: true }, true],
|
||||||
[{ isDeleted: true }, true],
|
[{ isDeleted: true }, true],
|
||||||
|
|||||||
@@ -151,6 +151,8 @@ 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. */
|
||||||
|
readonly deleted_flag?: (typeof DELETED_FLAGS)[number] | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** A query parameter that asks for some fields only (`fields`, `fields[...]`, `select`, `$select`). */
|
/** 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. */
|
/** 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. */
|
/** The member an item read's object sets `true` to mark the entity soft-deleted, if it does. */
|
||||||
export const deletedFlag = (
|
export const deletedFlag = (
|
||||||
object: JsonValue | undefined,
|
object: JsonValue | undefined,
|
||||||
): string | undefined =>
|
): (typeof DELETED_FLAGS)[number] | undefined =>
|
||||||
isObject(object)
|
isObject(object)
|
||||||
? DELETED_FLAGS.find((name) => ownMember(object, name) === true)
|
? DELETED_FLAGS.find((name) => ownMember(object, name) === true)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
UnsignedAnalysisView,
|
UnsignedAnalysisView,
|
||||||
} from "./analysisView.js";
|
} from "./analysisView.js";
|
||||||
import { EnvironmentStateAccumulator } from "./EnvironmentStateAccumulator.js";
|
import { EnvironmentStateAccumulator } from "./EnvironmentStateAccumulator.js";
|
||||||
|
import { DELETED_FLAGS } from "./environmentStateRules.js";
|
||||||
import { isClockZone } from "./environmentStateWriteValues.js";
|
import { isClockZone } from "./environmentStateWriteValues.js";
|
||||||
import { CLOCK_PATTERNS } from "./environmentStateWriteClockFormats.js";
|
import { CLOCK_PATTERNS } from "./environmentStateWriteClockFormats.js";
|
||||||
import {
|
import {
|
||||||
@@ -168,6 +169,12 @@ const viewSchema = z.discriminatedUnion("shape", [
|
|||||||
.describe(
|
.describe(
|
||||||
"Record whose response, with the entity object replaced, every item response shares.",
|
"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,
|
...replayedReadFields,
|
||||||
}),
|
}),
|
||||||
z.strictObject({
|
z.strictObject({
|
||||||
|
|||||||
Reference in new issue
Block a user