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>
104 lines
3.6 KiB
TypeScript
104 lines
3.6 KiB
TypeScript
import {
|
|
isParam,
|
|
lastSegmentValue,
|
|
normalizedPath,
|
|
segmentsOf,
|
|
} from "./environmentStateRules.js";
|
|
import {
|
|
collectionTarget,
|
|
documentTarget,
|
|
entityTarget,
|
|
} from "./environmentStateWriteChecks.js";
|
|
import { prefixRelated } from "./environmentStateWriteRules.js";
|
|
|
|
/** Whether the state holds an entity, so a write below its item path names it. */
|
|
type Held = (type: string, collection: string, id: string) => boolean;
|
|
|
|
/**
|
|
* What writes the gate does not model leave unknown: an entity below whose item path one wrote,
|
|
* or every entity of a collection when the path names none it holds (until a read shows each
|
|
* again); a collection one wrote at; everything under a path one wrote above collections; and
|
|
* targets whose check failed.
|
|
*/
|
|
export class WriteTaint {
|
|
readonly #prefixes: string[] = [];
|
|
readonly #targets = new Set<string>();
|
|
readonly #entities = new Set<string>();
|
|
/** Collections whose entities are all unknown, with the ones a read has shown since. */
|
|
readonly #collections = new Map<string, Set<string>>();
|
|
|
|
constructor(
|
|
private readonly types: readonly string[],
|
|
private readonly documents: ReadonlyMap<string, string>,
|
|
private readonly held: Held,
|
|
) {}
|
|
|
|
/** Record an unmodelled write's path against every related type and document. */
|
|
write(path: string): void {
|
|
const segments = segmentsOf(path);
|
|
for (const type of this.types) {
|
|
if (!prefixRelated(type, path)) continue;
|
|
const depth = segmentsOf(type).length;
|
|
const collection = `/${segments.slice(0, depth).join("/")}`;
|
|
if (segments.length > depth) {
|
|
const id = lastSegmentValue(`/${segments[depth]!}`);
|
|
if (this.held(type, collection, id))
|
|
this.#entities.add(entityTarget(type, collection, id));
|
|
else
|
|
this.#collections.set(collectionTarget(type, collection), new Set());
|
|
} else if (segments.length === depth)
|
|
this.#targets.add(collectionTarget(type, collection));
|
|
else this.#prefixes.push(normalizedPath(path));
|
|
}
|
|
for (const [tool, documentPath] of this.documents)
|
|
if (prefixRelated(documentPath, path))
|
|
this.#targets.add(documentTarget(tool));
|
|
}
|
|
|
|
/** Stop checking a target whose check failed. */
|
|
fail(target: string): void {
|
|
this.#targets.add(target);
|
|
}
|
|
|
|
tainted(target: string): boolean {
|
|
return this.#targets.has(target);
|
|
}
|
|
|
|
/** Whether an unmodelled write above a collection path left everything under it unknown. */
|
|
under(collection: string): boolean {
|
|
const concrete = segmentsOf(collection);
|
|
return this.#prefixes.some((prefix) =>
|
|
segmentsOf(prefix).every(
|
|
(segment, index) => isParam(segment) || segment === concrete[index],
|
|
),
|
|
);
|
|
}
|
|
|
|
unknown(type: string, collection: string, id: string): boolean {
|
|
const target = entityTarget(type, collection, id);
|
|
const seen = this.#collections.get(collectionTarget(type, collection));
|
|
return (
|
|
this.#entities.has(target) || (seen !== undefined && !seen.has(target))
|
|
);
|
|
}
|
|
|
|
/** Whether a list read of a collection lists, or holds, an entity left unknown. */
|
|
blocksList(
|
|
type: string,
|
|
collection: string,
|
|
ids: readonly string[],
|
|
): boolean {
|
|
return (
|
|
this.#collections.has(collectionTarget(type, collection)) ||
|
|
ids.some((id) => this.#entities.has(entityTarget(type, collection, id)))
|
|
);
|
|
}
|
|
|
|
/** The state saw the entity again, or a modelled write replaced it. */
|
|
known(type: string, collection: string, id: string): void {
|
|
const target = entityTarget(type, collection, id);
|
|
this.#entities.delete(target);
|
|
this.#collections.get(collectionTarget(type, collection))?.add(target);
|
|
}
|
|
}
|