merge: inherit environment review fixes

This commit is contained in:
morluto committed 2026-10-28 04:54:47 +08:00
commit c37964b080
7 files changed
+83 -10

No files matched your search

@@ -296,6 +296,7 @@ export class EnvironmentStateWriteSimulation {
updates: [],
sequences: new Map(),
starts: new Map(),
createCollections: [],
steady: new Set(),
unstamped: new Set(),
kept: new Set(),
@@ -32,6 +32,22 @@ describe("deletes the application answers for afterwards", () => {
});
});
it("withholds a soft delete when its read changes another field", () => {
const rows: readonly TimedCaptureRow[] = SOFT_DELETED_ROWS.map((row) =>
row[0] === "GET" &&
row[1] === "/api/contacts/2" &&
typeof row[2] === "object" &&
row[2] !== null &&
!Array.isArray(row[2]) &&
row[2]["deleted"] === true
? [row[0], row[1], { ...row[2], name: "Changed by deletion" }]
: row,
);
expect(planOf(rows, "delete_contacts_by_id")).toMatchObject({
served: false,
});
});
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");
@@ -29,6 +29,29 @@ const ruleFor = (plan: ReturnType<typeof createOf>, field: string): unknown =>
plan.rules.find((rule) => rule.field === field);
describe("values an application counts and makes with each created entity", () => {
it("does not adopt a sequence that contradicts an earlier accepted create", () => {
const a = { id: 1, title: "A", index: 1 };
const b = { id: 2, title: "B", index: 2 };
const c = { id: 3, title: "C", index: 15 };
const d = { id: 4, title: "D", index: 15 };
const e = { id: 5, title: "E", index: 16 };
const rows: readonly TimedCaptureRow[] = [
["GET", "/api/tasks", [a, b]],
["GET", "/api/tasks/1", a],
["POST", "/api/tasks", c, 201, { title: "C" }],
["GET", "/api/tasks/3", c],
["GET", "/api/tasks", [a, b, c]],
["POST", "/api/tasks", d, 201, { title: "D" }],
["GET", "/api/tasks/4", d],
["GET", "/api/tasks", [a, b, c, d]],
["POST", "/api/tasks", e, 201, { title: "E" }],
["GET", "/api/tasks/5", e],
["GET", "/api/tasks", [a, b, c, d, e]],
];
const plan = createOf(rows);
expect(plan).toMatchObject({ served: false });
expect(ruleFor(plan, "index")).toBeUndefined();
});
it("learns a counter from where the first create stood, a template around it and the object made with each task", () => {
const plan = createOf(COUNTED_TASK_ROWS);
expect(plan).toMatchObject({
@@ -5,10 +5,10 @@ import {
ownMember,
at,
decodedSegment,
deletedFlag,
isObject,
listItems,
listsAll,
markedDeleted,
missingRequiredFields,
pathSegmentMatches,
renderStateEntity,
@@ -57,7 +57,7 @@ export class SimulatedState {
readonly #entities = new Map<string, StateEntity>();
readonly #collections = new Map<string, string[]>();
readonly #documents = new Map<string, JsonValue>();
readonly #removed: RemovedTarget[] = [];
readonly #removed: StateEntity[] = [];
readonly #served = new ServedOrder();
/** Each held id's targets, of any type. */
readonly #named = new Map<string, Set<string>>();
@@ -265,11 +265,20 @@ export class SimulatedState {
target.id === entity.id,
);
if (index >= 0) this.#removed.splice(index, 1);
this.#removed.push({
type: entity.type,
collection: entity.collection,
id: entity.id,
});
this.#removed.push(entity);
}
deleted(
type: string,
collection: string,
id: string,
): StateEntity | undefined {
return this.#removed.find(
(entity) =>
entity.type === type &&
entity.collection === collection &&
entity.id === id,
);
}
/** Removed targets of this type the state does not hold again. */
@@ -497,9 +506,20 @@ export const checkItemRead = (
return check.body === undefined || entity === undefined
? undefined
: { pass: false, targets, unheld: [] };
// An entity the application answers for as soft-deleted is one serving no longer holds.
if (markedDeleted(object))
return { pass: entity === undefined, targets, unheld: [] };
// Serving keeps a removed entity's fields and changes only the soft-delete flag.
const flag = deletedFlag(object);
if (flag !== undefined) {
const removed = state.deleted(view.type, collection, id);
if (entity !== undefined) return { pass: false, targets, unheld: [] };
if (removed === undefined) return { pass: true, targets, unheld: [] };
const deleted = { ...removed, fields: { ...removed.fields, [flag]: true } };
const shown = renderStateEntity(deleted, view, check.volatile, object);
return {
pass: same(replaceAt(check.base, view.pointer, shown), check.body),
targets,
unheld: [],
};
}
if (entity === undefined) return { pass: false, targets, unheld: targets };
// Serving refuses an entity lacking a field every recording carried, so no read can confirm one.
if (missingRequiredFields(entity, view, check.volatile).length > 0)
@@ -311,6 +311,7 @@ const earlierOf = (draft: WriteDraft) => ({
kept: draft.kept,
clockOf: (field: string) => clockEvidence(draft, field),
startOf: (field: string) => draft.starts.get(field),
createCollections: draft.createCollections,
ampersands: draft.ampersands,
});
@@ -978,6 +979,8 @@ export const applyCreate = (
draft.plan.rules,
);
const inserted = insertCreated(context, draft, entity(stored, id));
context.io.retain(collection.length * 2 + 16);
draft.createCollections.push(collection);
return {
kind: "applied",
targets: [inserted, collectionTarget(type, collection)],
@@ -32,6 +32,7 @@ export interface EarlierEvidence {
readonly clockOf: (field: string) => JsonValue | undefined;
/** The integer the exemplar showed in an unruled field above every value its type held, where a sequence may start. */
readonly startOf?: (field: string) => number | undefined;
readonly createCollections?: readonly string[];
/** How the plan's accepted calls and the type's held entities spelled `&` in each field's slug; absent where none showed one. */
readonly ampersands?: ReadonlyMap<string, AmpersandSpelling>;
}
@@ -269,6 +270,13 @@ const spellsExemplar = (
}
if (rule.rule !== "sequence") return undefined;
const start = calls.earlier.startOf?.(field);
const collections = calls.earlier.createCollections ?? [];
if (
(rule.scope === "type" && collections.length > 1) ||
(rule.scope === "collection" &&
new Set(collections).size !== collections.length)
)
return undefined;
return start !== undefined && sequenceNumber(value) === start
? { ...rule, start }
: undefined;
@@ -100,6 +100,8 @@ export interface WriteDraft {
readonly sequences: Map<string, number>;
/** Integers the exemplar showed in fields no rule wrote, above every value its type held, where a later call's sequence may start. */
readonly starts: Map<string, number>;
/** Collections of accepted create calls, used to check late sequence adoption. */
readonly createCollections: string[];
/** The collection a create writes into when its own path names no state type: the one its returned id was read under. */
readonly linked?: string | undefined;
/** Fields an item action's call found already at the value it sets them to. */