mirror of
https://github.com/morluto/rea.git
synced 2026-11-05 22:36:08 +00:00
feat(environment): apply the write effects a capture confirms to the served state
The environment-state view now builds a write plan for every tool whose method may change state (create, update, delete or document, by the state type or document its path names) from its first successful recorded call: the request body members the application stored as sent, values it generated (identities, sequences, UUIDs, clock values in their recorded format), values derived from the call (copies, path values, slugs, strings around a generated id) and the recorded response's shape. It replays the capture's timeline on a copy of the initial state, applying each modelled write and checking every later read the served state would answer; a plan is served only when a later read confirms it and none contradicts it. Writes it does not model leave the entities and collections below their paths unknown until a read shows them again, and failures they may explain are checked only on the members a write sent. Unserved plans carry their reason. Served from the state, the catalog adds the confirmed write tools, described by their effect and body members. A call applies its effect atomically to the live store and answers with a recorded status and a rendered body; refused calls leave the state unchanged. Generated values are deterministic per state Evidence and write count, sequences never reuse a value, and the clock starts at the capture's end: data/reset and data/add restart them. Reads of an entity the state does not hold answer the recorded 400, 404 or 410, and a list line that only pages its collection and listed all of it answers calls without query arguments from the state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
306c3da915
commit
b9fbcd7057
26 files changed
+3944
-76
No files matched your search
@@ -0,0 +1,426 @@
|
||||
import type { JsonValue } from "../jsonValue.js";
|
||||
import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js";
|
||||
import {
|
||||
isObject,
|
||||
locateItem,
|
||||
pointerTo,
|
||||
same,
|
||||
scalarText,
|
||||
type JsonObject,
|
||||
} from "./environmentStateRules.js";
|
||||
import type {
|
||||
FieldRule,
|
||||
InsertRule,
|
||||
WriteBody,
|
||||
WriteCall,
|
||||
WriteResponse,
|
||||
} from "./environmentStateWriteRules.js";
|
||||
import {
|
||||
containsDelimited,
|
||||
delimitedOffsets,
|
||||
integerOf,
|
||||
isUuid,
|
||||
readClock,
|
||||
slugOf,
|
||||
} from "./environmentStateWriteValues.js";
|
||||
|
||||
/** A clock value counts as written by the request when it lies this close to the request's start. */
|
||||
const CLOCK_WINDOW_MS = 10 * 60_000;
|
||||
/** Shorter body strings are too common to show that a field copied one. */
|
||||
const COPY_MIN_LENGTH = 8;
|
||||
|
||||
/** What explaining a create reads: the observed entity, the call that wrote it and the entities it joined. */
|
||||
export interface CreateEvidence {
|
||||
readonly entity: JsonObject;
|
||||
readonly call: WriteCall & { readonly body: JsonObject };
|
||||
readonly idField: string;
|
||||
/** The request's start in epoch milliseconds, when the timeline is timed. */
|
||||
readonly startMs: number | undefined;
|
||||
readonly collection: readonly JsonObject[];
|
||||
readonly type: readonly JsonObject[];
|
||||
}
|
||||
|
||||
/** The JSON pointer of the first string in document order equal to `value`, searched inside the body. */
|
||||
const bodyPointerOf = (body: JsonValue, value: string): string | undefined => {
|
||||
const pending: { readonly value: JsonValue; readonly pointer: string }[] = [
|
||||
{ value: body, pointer: "" },
|
||||
];
|
||||
for (let next = pending.shift(); next !== undefined; next = pending.shift()) {
|
||||
if (next.value === value) return next.pointer;
|
||||
const children: [string, JsonValue][] = Array.isArray(next.value)
|
||||
? next.value.map((item, index) => [String(index), item])
|
||||
: isObject(next.value)
|
||||
? Object.entries(next.value)
|
||||
: [];
|
||||
for (const [key, item] of children)
|
||||
pending.push({
|
||||
value: item,
|
||||
pointer: `${next.pointer}${pointerTo(key)}`,
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const isClockNear = (
|
||||
value: JsonValue,
|
||||
startMs: number | undefined,
|
||||
): boolean => {
|
||||
const clock = readClock(value);
|
||||
return (
|
||||
clock !== undefined &&
|
||||
startMs !== undefined &&
|
||||
Math.abs(clock.ms - startMs) <= CLOCK_WINDOW_MS
|
||||
);
|
||||
};
|
||||
|
||||
/** One more than the largest integer the members hold in the field, or undefined when none holds one. */
|
||||
const nextInteger = (
|
||||
members: readonly JsonObject[],
|
||||
field: string,
|
||||
): number | undefined => {
|
||||
let largest: number | undefined;
|
||||
for (const member of members) {
|
||||
const value = integerOf(member[field]);
|
||||
if (value !== undefined && (largest === undefined || value > largest))
|
||||
largest = value;
|
||||
}
|
||||
return largest === undefined ? undefined : largest + 1;
|
||||
};
|
||||
|
||||
const identityRule = (field: string, value: JsonValue): FieldRule => ({
|
||||
field,
|
||||
rule: "identity",
|
||||
generator:
|
||||
integerOf(value) !== undefined
|
||||
? "sequence"
|
||||
: isUuid(value)
|
||||
? "uuid"
|
||||
: "token",
|
||||
});
|
||||
|
||||
/** The rule a request explains a created field by, before generated values and templates are considered. */
|
||||
const requestRule = (
|
||||
evidence: CreateEvidence,
|
||||
field: string,
|
||||
value: JsonValue,
|
||||
): FieldRule | undefined => {
|
||||
const { body, pathValues } = evidence.call;
|
||||
if (Object.hasOwn(body, field) && same(body[field], value))
|
||||
return { field, rule: "echo" };
|
||||
if (typeof value === "string" && value.length >= COPY_MIN_LENGTH) {
|
||||
const from = bodyPointerOf(body, value);
|
||||
if (from !== undefined) return { field, rule: "copy", from };
|
||||
}
|
||||
const text = scalarText(value);
|
||||
for (const [name, pathValue] of pathValues)
|
||||
if (text !== undefined && text === pathValue)
|
||||
return { field, rule: "path", from: name };
|
||||
if (typeof value === "string" && value.length >= 3)
|
||||
for (const [member, sent] of Object.entries(body))
|
||||
if (typeof sent === "string" && sent !== value && slugOf(sent) === value)
|
||||
return { field, rule: "slug", from: member };
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** The rule a created field's generated value follows, if it follows one. */
|
||||
const generatedRule = (
|
||||
evidence: CreateEvidence,
|
||||
field: string,
|
||||
value: JsonValue,
|
||||
): FieldRule | undefined => {
|
||||
if (field === evidence.idField) return identityRule(field, value);
|
||||
const clock = readClock(value);
|
||||
if (clock !== undefined && isClockNear(value, evidence.startMs))
|
||||
return { field, rule: "clock", format: clock.format };
|
||||
const integer = integerOf(value);
|
||||
if (integer !== undefined) {
|
||||
if (nextInteger(evidence.collection, field) === integer)
|
||||
return { field, rule: "sequence", scope: "collection" };
|
||||
if (nextInteger(evidence.type, field) === integer)
|
||||
return { field, rule: "sequence", scope: "type" };
|
||||
}
|
||||
if (
|
||||
isUuid(value) &&
|
||||
evidence.type.length > 0 &&
|
||||
evidence.type.every((member) => member[field] !== value)
|
||||
)
|
||||
return { field, rule: "unique" };
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** A string that embeds a generated value delimited is rebuilt around the new value. */
|
||||
const templateRule = (
|
||||
entity: JsonObject,
|
||||
field: string,
|
||||
generated: readonly FieldRule[],
|
||||
): FieldRule | undefined => {
|
||||
const value = entity[field];
|
||||
if (typeof value !== "string") return undefined;
|
||||
for (const rule of generated) {
|
||||
const token = scalarText(entity[rule.field]);
|
||||
if (
|
||||
rule.field !== field &&
|
||||
token !== undefined &&
|
||||
token.length > 0 &&
|
||||
delimitedOffsets(value, token).length > 0
|
||||
)
|
||||
return { field, rule: "template", from: rule.field };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Body members the application stored as sent, and the others with what they were sent. */
|
||||
const bodyOf = (
|
||||
body: JsonObject,
|
||||
echoed: ReadonlySet<string>,
|
||||
required: readonly string[],
|
||||
): WriteBody => ({
|
||||
accepted: Object.keys(body).filter((member) => echoed.has(member)),
|
||||
fixed: Object.entries(body)
|
||||
.filter(([member]) => !echoed.has(member))
|
||||
.map(([member, sent]) => ({ member, sent })),
|
||||
required: [...required],
|
||||
identity: null,
|
||||
});
|
||||
|
||||
/** Explain every field of a created entity: echoed, copied, path, slug, generated, template or constant (unlisted). */
|
||||
export const explainCreate = (
|
||||
evidence: CreateEvidence,
|
||||
): {
|
||||
readonly rules: FieldRule[];
|
||||
readonly body: WriteBody;
|
||||
readonly tied: boolean;
|
||||
} => {
|
||||
const rules: FieldRule[] = [];
|
||||
const unexplained: string[] = [];
|
||||
for (const [field, value] of Object.entries(evidence.entity)) {
|
||||
const rule =
|
||||
requestRule(evidence, field, value) ??
|
||||
generatedRule(evidence, field, value);
|
||||
if (rule === undefined) unexplained.push(field);
|
||||
else rules.push(rule);
|
||||
}
|
||||
const generated = rules.filter(
|
||||
(rule) =>
|
||||
rule.rule === "identity" ||
|
||||
rule.rule === "sequence" ||
|
||||
rule.rule === "unique",
|
||||
);
|
||||
const constants: string[] = [];
|
||||
for (const field of unexplained) {
|
||||
const template = templateRule(evidence.entity, field, generated);
|
||||
if (template === undefined) constants.push(field);
|
||||
else rules.push(template);
|
||||
}
|
||||
const echoed = new Set(
|
||||
rules.filter((rule) => rule.rule === "echo").map((rule) => rule.field),
|
||||
);
|
||||
const tokens = [...evidence.call.pathValues.values()].filter(
|
||||
(value) => value.length > 0,
|
||||
);
|
||||
return {
|
||||
rules,
|
||||
body: bodyOf(evidence.call.body, echoed, Object.keys(evidence.call.body)),
|
||||
tied: constants.some((field) =>
|
||||
tokens.some((token) => containsDelimited(evidence.entity[field]!, token)),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
/** What explaining an update reads: the entity before and after, the call and its method. */
|
||||
export interface UpdateEvidence {
|
||||
readonly before: JsonObject;
|
||||
readonly after: JsonObject;
|
||||
readonly call: WriteCall & { readonly body: JsonObject };
|
||||
readonly method: string;
|
||||
readonly idField: string;
|
||||
readonly id: string;
|
||||
readonly startMs: number | undefined;
|
||||
}
|
||||
|
||||
/** The rule a field the update changed without sending follows, if any. */
|
||||
const changeRule = (
|
||||
evidence: UpdateEvidence,
|
||||
field: string,
|
||||
echoed: ReadonlySet<string>,
|
||||
): FieldRule | undefined => {
|
||||
const before = evidence.before[field];
|
||||
const after = evidence.after[field]!;
|
||||
if (readClock(after) !== undefined && isClockNear(after, evidence.startMs))
|
||||
return { field, rule: "clock", format: readClock(after)!.format };
|
||||
const was = integerOf(before);
|
||||
if (was !== undefined && integerOf(after) === was + 1)
|
||||
return { field, rule: "increment" };
|
||||
if (typeof after === "string")
|
||||
for (const member of echoed) {
|
||||
const sent = evidence.call.body[member];
|
||||
if (typeof sent === "string" && slugOf(sent) === after)
|
||||
return { field, rule: "slug", from: member };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Explain an update: echoed members, fixed members whose stored value is constant, and the changes it made unsent. */
|
||||
export const explainUpdate = (
|
||||
evidence: UpdateEvidence,
|
||||
): {
|
||||
readonly rules: FieldRule[];
|
||||
readonly body: WriteBody;
|
||||
readonly unexplained: string[];
|
||||
} => {
|
||||
const { body } = evidence.call;
|
||||
const rules: FieldRule[] = [];
|
||||
const echoed = new Set<string>();
|
||||
let identity: string | null = null;
|
||||
for (const [member, sent] of Object.entries(body)) {
|
||||
if (member === evidence.idField && scalarText(sent) === evidence.id) {
|
||||
identity = member;
|
||||
continue;
|
||||
}
|
||||
if (!Object.hasOwn(evidence.after, member)) continue;
|
||||
if (same(evidence.after[member], sent)) {
|
||||
echoed.add(member);
|
||||
rules.push({ field: member, rule: "echo" });
|
||||
} else rules.push({ field: member, rule: "constant" });
|
||||
}
|
||||
const unexplained: string[] = [];
|
||||
for (const field of Object.keys(evidence.after)) {
|
||||
if (
|
||||
Object.hasOwn(body, field) ||
|
||||
!Object.hasOwn(evidence.before, field) ||
|
||||
same(evidence.before[field], evidence.after[field])
|
||||
)
|
||||
continue;
|
||||
const rule = changeRule(evidence, field, echoed);
|
||||
if (rule === undefined) unexplained.push(field);
|
||||
else rules.push(rule);
|
||||
}
|
||||
const shaped = bodyOf(
|
||||
Object.fromEntries(
|
||||
Object.entries(body).filter(([member]) => member !== identity),
|
||||
),
|
||||
echoed,
|
||||
evidence.method === "PUT"
|
||||
? Object.keys(body).filter((member) => member !== identity)
|
||||
: [],
|
||||
);
|
||||
return { rules, body: { ...shaped, identity }, unexplained };
|
||||
};
|
||||
|
||||
/**
|
||||
* How a write's response carries the entity it wrote: an object with the type's identity field
|
||||
* equal to the id (entity), the id alone at the root or in one member (identity), nothing
|
||||
* (empty), or anything else (recorded).
|
||||
*/
|
||||
export const responseShapeOf = (
|
||||
ordinal: number,
|
||||
body: JsonValue | undefined,
|
||||
empty: boolean,
|
||||
entity: { readonly idField: string; readonly id: string },
|
||||
): WriteResponse => {
|
||||
if (empty) return { ordinal, shape: "empty", pointer: null };
|
||||
if (body === undefined) return { ordinal, shape: "recorded", pointer: null };
|
||||
const located = locateItem(body, entity.id);
|
||||
if (located?.keys.includes(entity.idField) === true)
|
||||
return { ordinal, shape: "entity", pointer: located.pointer };
|
||||
if (scalarText(body) === entity.id)
|
||||
return { ordinal, shape: "identity", pointer: "" };
|
||||
if (isObject(body)) {
|
||||
const members = Object.entries(body).filter(
|
||||
([, value]) => scalarText(value) === entity.id,
|
||||
);
|
||||
if (members.length === 1)
|
||||
return {
|
||||
ordinal,
|
||||
shape: "identity",
|
||||
pointer: pointerTo(members[0]![0]),
|
||||
};
|
||||
}
|
||||
return { ordinal, shape: "recorded", pointer: null };
|
||||
};
|
||||
|
||||
/** Order two field values as numbers, instants or text; undefined when they are not comparable. */
|
||||
export const compareValues = (
|
||||
left: JsonValue | undefined,
|
||||
right: JsonValue | undefined,
|
||||
): number | undefined => {
|
||||
const [a, b] = [integerOf(left), integerOf(right)];
|
||||
if (a !== undefined && b !== undefined) return a - b;
|
||||
const [c, d] = [readClock(left), readClock(right)];
|
||||
if (c !== undefined && d !== undefined) return c.ms - d.ms;
|
||||
return typeof left === "string" && typeof right === "string"
|
||||
? compare(left, right)
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** Whether the members are strictly ordered by the field in the direction. */
|
||||
const orderedBy = (
|
||||
members: readonly JsonObject[],
|
||||
field: string,
|
||||
direction: "ascending" | "descending",
|
||||
): boolean =>
|
||||
members.every((member, index) => {
|
||||
if (index === 0) return true;
|
||||
const order = compareValues(members[index - 1]![field], member[field]);
|
||||
return (
|
||||
order !== undefined && (direction === "ascending" ? order < 0 : order > 0)
|
||||
);
|
||||
});
|
||||
|
||||
/** Insert rules consistent with a collection's recorded order: sorted by identity or clock fields when shown, then last and first. */
|
||||
export const insertCandidates = (
|
||||
members: readonly JsonObject[],
|
||||
fields: readonly string[],
|
||||
): InsertRule[] => {
|
||||
const sorted: InsertRule[] = [];
|
||||
if (members.length >= 2)
|
||||
for (const field of fields)
|
||||
for (const direction of ["ascending", "descending"] as const)
|
||||
if (orderedBy(members, field, direction))
|
||||
sorted.push({ rule: "sorted", field, direction });
|
||||
return [...sorted, { rule: "last" }, { rule: "first" }];
|
||||
};
|
||||
|
||||
/** Where an entity with these fields enters a collection under an insert rule. */
|
||||
export const insertIndex = (
|
||||
rule: InsertRule,
|
||||
members: readonly JsonObject[],
|
||||
fields: JsonObject,
|
||||
): number => {
|
||||
if (rule.rule !== "sorted") return rule.rule === "first" ? 0 : members.length;
|
||||
const index = members.findIndex((member) => {
|
||||
const order = compareValues(fields[rule.field], member[rule.field]);
|
||||
return (
|
||||
order !== undefined &&
|
||||
(rule.direction === "ascending" ? order < 0 : order > 0)
|
||||
);
|
||||
});
|
||||
return index < 0 ? members.length : index;
|
||||
};
|
||||
|
||||
/** The object in a document value, at the root or in one member, that holds most of a body's members. */
|
||||
export const documentPointerOf = (
|
||||
value: JsonValue,
|
||||
body: JsonObject,
|
||||
): string | undefined => {
|
||||
const members = Object.keys(body);
|
||||
const candidates: [string, JsonValue][] = [
|
||||
["", value],
|
||||
...(isObject(value)
|
||||
? Object.entries(value).map(([name, item]): [string, JsonValue] => [
|
||||
pointerTo(name),
|
||||
item,
|
||||
])
|
||||
: []),
|
||||
];
|
||||
let best: { readonly pointer: string; readonly shared: number } | undefined;
|
||||
for (const [pointer, candidate] of candidates) {
|
||||
if (!isObject(candidate)) continue;
|
||||
const shared = members.filter((member) =>
|
||||
Object.hasOwn(candidate, member),
|
||||
).length;
|
||||
if (shared > 0 && (best === undefined || shared > best.shared))
|
||||
best = { pointer, shared };
|
||||
}
|
||||
return best?.pointer;
|
||||
};
|
||||
Reference in new issue
Block a user