fix(environment): confirm only the writes a read shows, and name and page reads as serving does

- A passing read confirms only the writes it shows something of: a create's or delete's membership, or a changed field its view shows and does not treat as volatile. A list without `description` no longer confirms a description PATCH.
- Document writes join the effect tracking, a merge by the members it sent and a replacement as the whole document. A PATCH a later PUT replaced is no longer confirmed by the final read.
- A read a write's effect was learned from still contradicts the other writes it exercises, though it checks nothing of the borrowing write.
- An item read names its entity as serving does: the collection and id as written, else a held collection that matches once percent-decoded. A `200 null` read spelled plainly now contradicts an update made under the encoded spelling.
- A line repeating a query name with one value stays checkable, since a call naming it matches both pairs; only repeats with different values are skipped.
- A call naming a page of a whole list the collection has outgrown answers from its recording (`page-exceeded`) instead of returning more items than it asked for, and the gate does not check such a read against the state.

The write verdict moves to environmentStateWriteVerdict.ts (as on the list-lines branch) and the plan-conflict check to environmentStateWriteExplain.ts, keeping files under the line limit. Also merges main d8a9673f. The verdicts over the 50 captures are unchanged plan by plan, and the smoke applies all 31 served write tools.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Edgar ArakelyanandClaude Opus 5.5 committed 2026-10-21 09:17:39 -07:00
1 parent bec65b3ede
commit 3580d85ca1
13 files changed
+480 -111

No files matched your search

+1 -1
View File
@@ -432,7 +432,7 @@ A list or document call that names no checked line, including a line recorded
after a write or with other query values, answers from the recording. Every
answer has the replay shape plus `state`: `{"source": "state", "view": …}`
naming the entity, collection or document path, or `{"source": "recording",
"reason": …}` with the view's reason, `no-view` or `unrecorded-request`. A
"reason": …}` with the view's reason, `no-view`, `unrecorded-request` or `page-exceeded` (a call naming a page of a whole list that the collection has outgrown). A
state answer carries the base record's status, content type and `replay`
provenance with `truncated: false`; one whose JSON would exceed 256 KiB is
refused as a `resource_constraint` tool error rather than cut. Query `POST`s
@@ -174,18 +174,26 @@ members they sent.
**Reads.** A read is checked when the served environment would answer it
from the state:
- an item read of a state-backed item view;
- an item read of a state-backed item view, of the entity serving would name:
the read's collection and id as written, else a held collection whose path
matches once percent-decoded;
- a list or document read on one of its view's checked request lines,
chosen as replay chooses the line for the read's call: each path parameter
the read's value verbatim or once percent-decoded, preferring verbatim
spellings, then recorded order, and the query values decoded, in any order.
A read with no query on a whole list falls back, as serving does, on a
checked line that only pages it. A line whose query repeats a name is never
chosen, since no call's arguments can name it. A list is compared as served
from the collection of that line.
checked line that only pages it. A line whose query repeats a name with
different values is never chosen, since no call's arguments can name it; one
repeating a name with one value is, as a call naming it matches both. A list
is compared as served from the collection of that line. A read on a page
(`limit`, `size`, `per_page` and the like) of a whole list checks nothing
once the collection holds more entities than the page, which serving then
answers from its recording.
- A read made while a modelled write to its entity or collection was still
running, starting before that write ended, checks nothing: it may show the
state before or after the write.
- A read a write's effect was learned from checks nothing of that write, but
still contradicts the other writes it exercises.
**Comparison.** The read's answer is the full response serving would give from
the copy: an item read's entity rendered into its item view's base recording,
@@ -205,9 +213,13 @@ refuses to answer with.
An update's effect is the fields it sent or changed, derived ones included.
A later update overwrites the fields it sets (an increment adds to its field
instead), a recorded outcome every field, and a create or delete the whole
entity. A write with nothing left on an entity no longer counts there, nor in
a collection where no entity it changed keeps any of its effect. A pass confirms them and a mismatch
contradicts them.
entity. A document write's effect is the members it merged, or the whole
document when it replaces it. A write with nothing left on an entity no
longer counts there, nor in a collection where no entity it changed keeps any
of its effect. A mismatch contradicts them. A pass confirms those it shows
something of: a create's or delete's membership, or a changed field the
read's view shows and does not treat as volatile; a read that shows none of
an update's changes confirms nothing of it.
- A mismatch after an unmodelled write that followed those writes is checked
only on the members they sent: such a write may explain the rest.
- A mismatch on an entity or collection that no write changed contradicts,
@@ -274,3 +274,15 @@ against this branch's code:
Each new test fails on the previous head. The verdicts over the 50 captures
are unchanged plan by plan, the smoke applies all 31 served write tools, and
the gates passed again.
An eleventh round settled six more findings, five raised on this branch and one on the fidelity branch:
- a passing read confirms only the writes it shows something of;
- an item read names its entity as serving does, percent-decoded spellings included;
- a read a write's effect was learned from still contradicts the other writes;
- a line repeating a query name with one value stays checkable;
- document writes join the effect tracking, so a replacement overwrites an earlier merge;
- a call naming a page that the collection has outgrown answers from its recording (`page-exceeded`), and the gate does not check such reads.
Each new test fails on the previous head. The verdicts over the 50 captures are unchanged plan by plan, the smoke
applies all 31 served write tools, and the gates passed again.
@@ -2,6 +2,7 @@ import { stateBodyJson } from "../../domain/analysisView/EnvironmentStateAccumul
import {
collectionPathOf,
listItems,
pageSize,
pagesOnly,
missingRequiredFields,
renderStateEntity,
@@ -48,7 +49,11 @@ import { environmentToolArguments } from "./environmentTools.js";
export type StateProvenance =
| {
readonly source: "recording";
readonly reason: RecordedViewReason | "no-view" | "unrecorded-request";
readonly reason:
| RecordedViewReason
| "no-view"
| "unrecorded-request"
| "page-exceeded";
}
| {
readonly source: "state";
@@ -420,6 +425,14 @@ const answerList = (
return fromRecording(source, tool, args, "body-unusable");
const collection = collectionPathOf(base, false);
const type = store.type(view.type);
// A requested page the collection has outgrown is answered as recorded, never with more items than it asked for.
const size = exact ? pageSize(base.query ?? undefined) : undefined;
if (
view.membership === "all" &&
size !== undefined &&
store.inCollection(view.type, collection).length > size
)
return fromRecording(source, tool, args, "page-exceeded");
const members: readonly StateEntity[] =
view.membership === "all"
? store.inCollection(view.type, collection)
@@ -648,3 +648,26 @@ describe("values the state started with", () => {
expect(create("acme1", "Epsilon")).toBe("/api/orgs/%61cme1/books");
});
});
describe("paged lines", () => {
it("answers a requested page the collection has outgrown from its recording, and a call with no query from the state", () => {
const books = servedFrom([
["GET", "/api/books?limit=2", [FIRST_BOOK, SECOND_BOOK]],
["GET", "/api/books/1", FIRST_BOOK],
["GET", "/api/books/2", SECOND_BOOK],
["POST", "/api/books", THIRD_BOOK, 201, { name: "Third Book" }],
// The application answers the same page with two books; serving would not answer it from the state.
["GET", "/api/books?limit=2", [FIRST_BOOK, SECOND_BOOK]],
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK, THIRD_BOOK]],
]);
const created = books.answer("post_books", CREATE_NEW_BOOK).body as Book;
expect(books.answer("get_books", { limit: "2" })).toMatchObject({
body: [FIRST_BOOK, SECOND_BOOK],
state: { source: "recording", reason: "page-exceeded" },
});
expect(books.answer("get_books")).toMatchObject({
body: [FIRST_BOOK, SECOND_BOOK, created],
state: { source: "state", membership: "all", listed: 3 },
});
});
});
@@ -613,3 +613,142 @@ describe("reads as serving answers them", () => {
});
});
});
describe("what a read shows of a write", () => {
it("confirms no update by a read that shows none of the fields it changed", () => {
const rows: TimedCaptureRow[] = [
[
"GET",
"/api/books",
[
{ id: 1, name: "A" },
{ id: 2, name: "B" },
],
],
["GET", "/api/books/1", { id: 1, name: "A", description: "x" }],
["GET", "/api/books/2", { id: 2, name: "B", description: "y" }],
[
"PATCH",
"/api/books/1",
{ id: 1, name: "A", description: "new" },
200,
{ description: "new" },
],
// The list passes, but it shows no description.
[
"GET",
"/api/books",
[
{ id: 1, name: "A" },
{ id: 2, name: "B" },
],
],
];
expect(gated(rows, "patch_books_by_id")).toMatchObject({
served: false,
reason: "unconfirmed",
checked_record_ordinals: [],
});
});
it("lets a read a create was observed through still contradict another write", () => {
const rows: TimedCaptureRow[] = [
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK]],
["GET", "/api/books/1", FIRST_BOOK],
["GET", "/api/books/2", SECOND_BOOK],
["PATCH", "/api/books/1", RENAMED, 200, { name: "Renamed Book" }],
["GET", "/api/books/1", RENAMED],
// The create answers with its id alone, so the next list shows its entity; that list shows book 1 unrenamed.
["POST", "/api/books", { id: 3 }, 201, { name: "Third Book" }],
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK, THIRD_BOOK]],
];
expect(gated(rows, "patch_books_by_id")).toMatchObject({
served: false,
contradicted_by: 6,
});
});
it("confirms no document write a later replacement overwrote", () => {
const rows: TimedCaptureRow[] = [
["GET", "/api/settings", { theme: "light", lang: "en" }],
[
"PATCH",
"/api/settings",
{ theme: "dark", lang: "en" },
200,
{ theme: "dark" },
],
[
"PUT",
"/api/settings",
{ theme: "blue", lang: "fr" },
200,
{ theme: "blue", lang: "fr" },
],
["GET", "/api/settings", { theme: "blue", lang: "fr" }],
];
expect(gated(rows, "patch_settings")).toMatchObject({
served: false,
checked_record_ordinals: [],
});
expect(gated(rows, "put_settings")).toMatchObject({
served: true,
checked_record_ordinals: [3],
});
});
});
describe("reads named as serving names them", () => {
const orgBook = (id: number, org: string, name: string): Book => ({
id,
org,
name,
});
it("contradicts an update by a read spelled plainly of the encoded collection serving answers it from", () => {
const alpha = orgBook(1, "acme1", "Alpha");
const renamed = orgBook(1, "acme1", "Renamed");
const beta = orgBook(2, "beta2", "Beta");
const renamedBeta = orgBook(2, "beta2", "Renamed Beta");
const rows: TimedCaptureRow[] = [
["GET", "/api/orgs/%61cme1/books", [alpha]],
["GET", "/api/orgs/beta2/books", [beta]],
["GET", "/api/orgs/%61cme1/books/1", alpha],
["GET", "/api/orgs/beta2/books/2", beta],
["PATCH", "/api/orgs/%61cme1/books/1", renamed, 200, { name: "Renamed" }],
[
"PATCH",
"/api/orgs/beta2/books/2",
renamedBeta,
200,
{ name: "Renamed Beta" },
],
// Serving answers this plain spelling with the renamed book, which the recording shows absent.
["GET", "/api/orgs/acme1/books/1", null],
["GET", "/api/orgs/%61cme1/books/1", renamed],
["GET", "/api/orgs/beta2/books/2", renamedBeta],
];
const { writes } = environmentStateViewOf(timedCaptureEvidence(rows), [
"sibling-variance",
]);
expect(writes.find((plan) => plan.effect === "update")).toMatchObject({
served: false,
contradicted_by: 6,
});
});
it("checks a line whose query repeats a name with one value, which a call still names", () => {
const rows: TimedCaptureRow[] = [
["GET", "/api/books?tag=a&tag=a", [FIRST_BOOK]],
["GET", "/api/books/1", FIRST_BOOK],
["GET", "/api/books/2", SECOND_BOOK],
["PATCH", "/api/books/1", RENAMED, 200, { name: "Renamed Book" }],
// The list still shows the old name, though the item read after it shows the rename.
["GET", "/api/books?tag=a&tag=a", [FIRST_BOOK]],
["GET", "/api/books/1", RENAMED],
];
expect(gated(rows, "patch_books_by_id")).toMatchObject({
served: false,
contradicted_by: 4,
});
});
});
@@ -1,10 +1,8 @@
import {
at,
changesState,
collectionPathOf,
isObject,
isStateWrite,
lastSegmentValue,
normalizedPath,
type StateDocument,
type StateEntity,
@@ -22,6 +20,7 @@ import {
collectionTarget,
entityTarget,
isAbsence,
itemTarget,
servedLine,
succeeded,
withBodies,
@@ -32,6 +31,9 @@ import {
type ServedLine,
} from "./environmentStateWriteChecks.js";
import {
exercisedBy,
runningAt,
shownFields,
touchTarget,
WriteEffects,
type PendingTargets,
@@ -56,6 +58,7 @@ import {
type StateWritePlan,
} from "./environmentStateWriteRules.js";
import { WriteTaint } from "./environmentStateWriteTaint.js";
import { writeVerdict } from "./environmentStateWriteVerdict.js";
/** What the write gate reads: the manifest tools, the timeline, the read views and the initial state. */
export interface WriteSimulationInput {
@@ -90,7 +93,8 @@ export class EnvironmentStateWriteSimulation {
readonly #types = new Map<string, StateType>();
/** Each list or document view's checked request lines, with the recording a call of the line is answered on. */
readonly #checkedLines: ReadonlyMap<string, CheckedLines>;
readonly #observations = new Set<number>();
/** Reads a write's effect was learned from, with the tools that borrowed each. */
readonly #observations = new Map<number, Set<string>>();
readonly #taint: WriteTaint;
readonly #touched: TouchedTargets = new Map();
readonly #effects = new WriteEffects();
@@ -154,7 +158,9 @@ export class EnvironmentStateWriteSimulation {
});
}
this.#input.records.forEach((record, index) => this.#step(record, index));
return [...this.#drafts.values()].map((draft) => this.#verdict(draft));
return [...this.#drafts.values()].map((draft) =>
writeVerdict(draft, this.#input),
);
}
#step(record: StateRecordFacts, index: number): void {
@@ -163,11 +169,10 @@ export class EnvironmentStateWriteSimulation {
if (isStateWrite(record) && !succeeded(record))
this.#unmodelled(record.path, index);
else if (isStateWrite(record)) this.#write(record, index, tool);
else if (tool?.method === "GET" && !this.#observations.has(record.ordinal))
this.#read(record, index, tool);
else if (tool?.method === "GET") this.#read(record, index, tool);
}
#context(): EffectContext {
#context(writer: string): EffectContext {
return {
state: this.#state,
io: this.#input.io,
@@ -180,7 +185,11 @@ export class EnvironmentStateWriteSimulation {
: this.#input.views.get(tool.name);
},
idField: (type) => this.#types.get(type)!.id_field,
observed: (ordinal) => this.#observations.add(ordinal),
observed: (ordinal) =>
this.#observations.set(
ordinal,
(this.#observations.get(ordinal) ?? new Set()).add(writer),
),
unknown: (type, collection, id) =>
this.#taint.unknown(type, collection, id),
initialDocument: (tool) =>
@@ -207,7 +216,7 @@ export class EnvironmentStateWriteSimulation {
const observeOnly = draft.plan.reason !== null;
if (!observeOnly) draft.calls += 1;
const step: WriteStep = { record, index, tool, observeOnly };
this.#settle(APPLY[effect](this.#context(), draft, step), {
this.#settle(APPLY[effect](this.#context(tool.name), draft, step), {
index,
tool: tool.name,
path: record.path,
@@ -235,7 +244,7 @@ export class EnvironmentStateWriteSimulation {
result.entity.collection,
result.entity.id,
);
if (result.kind === "applied" && result.entity !== undefined)
if (result.kind === "applied")
this.#effects.supersede(this.#touched, write.tool, result);
if (result.kind === "observed")
this.#effects.observed(this.#touched, result.targets);
@@ -292,8 +301,7 @@ export class EnvironmentStateWriteSimulation {
/** Whether an item read is of an entity left unknown that it shows again rather than checks. */
#resynced(view: StateView, record: StateRecordFacts): boolean {
if (view.shape !== "item") return false;
const collection = collectionPathOf(record, true);
const id = lastSegmentValue(record.path);
const { collection, id } = itemTarget(this.#state, view.type, record);
if (!this.#taint.unknown(view.type, collection, id)) return false;
const entity = this.#state.entity(view.type, collection, id);
// A recorded absence of an entity the state no longer holds is still a check of its delete.
@@ -315,7 +323,9 @@ export class EnvironmentStateWriteSimulation {
checkDocumentRead(this.#state, view.tool, record, body),
);
const item = view.shape === "item";
const collection = item ? collectionPathOf(record, true) : line!.collection;
const collection = item
? itemTarget(this.#state, view.type, record).collection
: line!.collection;
if (this.#taint.under(collection)) return undefined;
if (!item) {
const ids = this.#state
@@ -352,26 +362,29 @@ export class EnvironmentStateWriteSimulation {
#read(record: StateRecordFacts, index: number, tool: StateToolFacts): void {
const view = this.#input.views.get(tool.name);
if (view === undefined || this.#resynced(view, record)) return;
const borrowers = this.#observations.get(record.ordinal);
if (view === undefined || (!borrowers && this.#resynced(view, record)))
return;
const outcome = this.#check(view, record);
if (outcome === undefined) return;
const keys = [...outcome.targets, ...outcome.unheld];
// A read made while a write to its targets was still running may show either state, so it checks nothing.
const running = (key: string) =>
(this.#touchedSince.get(key)?.until ?? -Infinity) > record.start;
if (keys.some((key) => this.#taint.tainted(key) || running(key))) return;
const exercised = new Set<string>();
for (const key of keys)
for (const name of this.#touched.get(key)?.keys() ?? [])
exercised.add(name);
if (
keys.some(
(key) =>
this.#taint.tainted(key) ||
runningAt(this.#touchedSince, key, record.start),
)
)
return;
const exercised = exercisedBy(this.#touched, keys, borrowers);
if (borrowers !== undefined) {
this.#contradictOthers(outcome.pass, keys, exercised, record);
return;
}
const pass =
outcome.pass ||
(view.shape === "list" && this.#retryInsert(view, record, exercised));
if (pass)
for (const name of exercised) {
this.#drafts.get(name)!.confirmed.push(record.ordinal);
if (view.shape === "list") this.#placed.add(name);
}
if (pass) this.#confirm(view, record, keys, exercised);
else if (this.#interfered(keys)) {
// An unmodelled write since these writes may explain the difference: check their own members only.
if (view.shape === "item") this.#checkMembers(view, record);
@@ -386,6 +399,36 @@ export class EnvironmentStateWriteSimulation {
}
}
/** A read a write's effect was learned from checks nothing of that write, yet still contradicts the others. */
#contradictOthers(
pass: boolean,
keys: readonly string[],
exercised: ReadonlySet<string>,
record: StateRecordFacts,
): void {
if (pass || this.#interfered(keys)) return;
for (const name of exercised)
this.#drafts.get(name)!.contradictedBy ??= record.ordinal;
}
/** A passing read confirms the writes it exercised that it shows something of. */
#confirm(
view: StateView,
record: StateRecordFacts,
keys: readonly string[],
exercised: ReadonlySet<string>,
): void {
const shown = shownFields(view, this.#input.volatileOf);
for (const name of exercised) {
const shows = (key: string) =>
this.#touched.get(key)?.has(name) === true &&
this.#effects.shows(name, key, shown);
if (!keys.some(shows)) continue;
this.#drafts.get(name)!.confirmed.push(record.ordinal);
if (view.shape === "list") this.#placed.add(name);
}
}
#unmodelled(path: string, index: number): void {
this.#taint.write(path);
this.#unmodelledAt.push(index);
@@ -404,11 +447,8 @@ export class EnvironmentStateWriteSimulation {
view: Extract<StateView, { shape: "item" }>,
record: StateRecordFacts,
): void {
const entity = this.#state.entity(
view.type,
collectionPathOf(record, true),
lastSegmentValue(record.path),
);
const { collection, id } = itemTarget(this.#state, view.type, record);
const entity = this.#state.entity(view.type, collection, id);
if (entity === undefined) return;
const writes =
this.#touched.get(
@@ -433,8 +473,7 @@ export class EnvironmentStateWriteSimulation {
view: Extract<StateView, { shape: "item" }>,
record: StateRecordFacts,
): void {
const collection = collectionPathOf(record, true);
const id = lastSegmentValue(record.path);
const { collection, id } = itemTarget(this.#state, view.type, record);
const entity = this.#state.entity(view.type, collection, id);
if (entity === undefined) {
this.#taint.fail(entityTarget(view.type, collection, id));
@@ -513,43 +552,4 @@ export class EnvironmentStateWriteSimulation {
}
return false;
}
/** Whether an update or delete that answers with the entity can answer for an initial entity: one holds every field its recorded response shows. */
#renders(plan: StateWritePlan): boolean {
const { response } = plan;
if (
(plan.effect !== "update" && plan.effect !== "delete") ||
response?.shape !== "entity"
)
return true;
const shown = this.#input.io.withBody(response.ordinal, (body) => {
const object =
body === undefined ? undefined : at(body, response.pointer ?? "");
return isObject(object) ? Object.keys(object) : undefined;
});
return (
shown === undefined ||
this.#input.entities.some(
(entity) =>
entity.type === plan.target &&
shown.every((field) => Object.hasOwn(entity.fields, field)),
)
);
}
#verdict(draft: WriteDraft): StateWritePlan {
const plan = draft.plan;
if (plan.reason === null) {
if (draft.calls === 0) plan.reason = "no-call";
else if (draft.contradictedBy !== undefined) {
plan.reason = "contradicted";
plan.contradicted_by = draft.contradictedBy;
} else if (draft.sideEffect) plan.reason = "side-effect";
else if (draft.confirmed.length === 0) plan.reason = "unconfirmed";
else if (!this.#renders(plan)) plan.reason = "incomplete-entities";
}
plan.served = plan.reason === null;
plan.checked_record_ordinals = [...new Set(draft.confirmed)];
return { ...plan };
}
}
@@ -1,5 +1,8 @@
import { canonicalJson } from "../comparisonSemantics.js";
import { parseUrlQueryParameterNames } from "../recordedTransportUrl.js";
import {
iterateUrlQueryParameters,
parseUrlQueryParameterNames,
} from "../recordedTransportUrl.js";
import type { JsonValue } from "../jsonValue.js";
import type { StateWritePlan } from "./environmentStateWriteRules.js";
import { ENTRY_BYTES, textBytes } from "./webNetworkRouteTemplates.js";
@@ -166,6 +169,25 @@ export const pagesOnly = (query: string | undefined): boolean =>
PAGING_PARAMETERS.has(name.toLowerCase()),
);
/** Paging parameters that bound how many items a page holds. */
const PAGE_SIZE_PARAMETERS = new Set([
"limit",
"per_page",
"perpage",
"page_size",
"pagesize",
"size",
]);
/** The most items a query's page holds: its smallest whole-number page-size value, or undefined without one. */
export const pageSize = (query: string | undefined): number | undefined => {
let size: number | undefined;
for (const [name, value] of iterateUrlQueryParameters(query ?? ""))
if (PAGE_SIZE_PARAMETERS.has(name.toLowerCase()) && /^\d+$/u.test(value))
size = Math.min(size ?? Infinity, Number(value));
return size;
};
/** Identity field names tried, in order, when no path parameter proves one. */
export const IDENTITY_NAMES = [
"id",
@@ -1,9 +1,41 @@
import type { StateView } from "./environmentStateRules.js";
/** The fields a read of a view shows of its entities, less volatile ones it does not compare; all of a document's. */
export const shownFields = (
view: StateView,
volatileOf: (type: string) => ReadonlySet<string>,
): ReadonlySet<string> | "all" => {
if (view.shape !== "item" && view.shape !== "list") return "all";
const volatile = volatileOf(view.type);
return new Set(view.fields.filter((field) => !volatile.has(field)));
};
/** Modelled writes per target since its last check, with the fields each wrote (null: all). */
export type TouchedTargets = Map<string, Map<string, readonly string[] | null>>;
/** Per target, the earliest modelled write since its last check and when the latest of them ended. */
export type PendingTargets = Map<string, { since: number; until: number }>;
/** Whether a modelled write to the target was still running when a read began at `start`, so the read may show either state. */
export const runningAt = (
pending: PendingTargets,
key: string,
start: number,
): boolean => (pending.get(key)?.until ?? -Infinity) > start;
/** The tools whose writes a read exercises: those that changed its targets since they were last checked, less those that borrowed the read to learn their effect. */
export const exercisedBy = (
touched: TouchedTargets,
keys: readonly string[],
borrowers: ReadonlySet<string> | undefined,
): Set<string> => {
const exercised = new Set<string>();
for (const key of keys)
for (const name of touched.get(key)?.keys() ?? [])
if (!borrowers?.has(name)) exercised.add(name);
return exercised;
};
/** Record a modelled write on a target: the fields it wrote (undefined: all), when it began and when it ended. */
export const touchTarget = (
touched: TouchedTargets,
@@ -90,6 +122,23 @@ export class WriteEffects {
this.#members.delete(key);
}
/** Whether a read showing `fields` of a target shows anything of a tool's effect there: a create's or delete's always, an update's through a field it changed; unknown claims count as shown. */
shows(
tool: string,
key: string,
fields: ReadonlySet<string> | "all",
): boolean {
const visible = (claim: ChangedFields | undefined): boolean =>
fields === "all" ||
claim === undefined ||
claim === null ||
claim.some((field) => fields.has(field));
const own = this.#changed.get(key);
if (own !== undefined) return visible(own.get(tool));
const members = this.#members.get(key)?.get(tool);
return members === undefined || [...members.values()].some(visible);
}
/** A write's effect replaces what earlier writes left of theirs, then joins them. */
supersede(
touched: TouchedTargets,
@@ -14,6 +14,7 @@ import {
listItems,
missingRequiredFields,
pagesOnly,
pageSize,
renderStateEntity,
renderStateList,
replaceAt,
@@ -30,12 +31,16 @@ import {
type StateView,
} from "./environmentStateRules.js";
/** A query as replay compares a call's: decoded values by name, in any order; undefined when it repeats a name, which no call's arguments can carry. */
/** A query as replay compares a call's: decoded values by name, in any order; undefined when it repeats a name with another value, which no call's arguments can carry. */
const queryKey = (query: string | undefined): string | undefined => {
const pairs = [...iterateUrlQueryParameters(query ?? "")];
return new Set(pairs.map(([name]) => name)).size === pairs.length
? compositeKey(pairs.sort(([left], [right]) => compare(left, right)))
: undefined;
const values = new Map<string, string>();
for (const [name, value] of iterateUrlQueryParameters(query ?? "")) {
if (values.has(name) && values.get(name) !== value) return undefined;
values.set(name, value);
}
return compositeKey(
[...values].sort(([left], [right]) => compare(left, right)),
);
};
interface CheckedLine {
@@ -249,14 +254,44 @@ interface ReadBodies {
}
/** An item read: the answer the held entity renders into the served envelope must equal the recording; a recorded 404 or 410 agrees with none held. */
/**
* The entity an item read names, resolved as serving resolves an item call: its collection and id as written, else a
* held collection whose path matches once percent-decoded and an id tried decoded, then as written.
*/
export const itemTarget = (
state: SimulatedState,
type: string,
record: Pick<StateRecordFacts, "path">,
): { readonly collection: string; readonly id: string } => {
const collection = collectionPathOf(record, true);
const id = lastSegmentValue(record.path);
if (state.entity(type, collection, id) !== undefined)
return { collection, id };
const read = segmentsOf(collection);
const ids = [decodedSegment(id), id];
for (const candidate of ids)
for (const entity of state.ofType(type)) {
const held = segmentsOf(entity.collection);
if (
entity.id === candidate &&
held.length === read.length &&
held.every(
(segment, index) =>
segment === read[index] || decodedSegment(segment) === read[index],
)
)
return { collection: entity.collection, id: entity.id };
}
return { collection, id };
};
export const checkItemRead = (
state: SimulatedState,
view: StateItemView,
record: StateRecordFacts,
check: ReadBodies,
): CheckOutcome | undefined => {
const collection = collectionPathOf(record, true);
const id = lastSegmentValue(record.path);
const { collection, id } = itemTarget(state, view.type, record);
const entity = state.entity(view.type, collection, id);
const targets = [entityTarget(view.type, collection, id)];
if (isAbsence(record.status) || (succeeded(record) && check.body === null))
@@ -369,6 +404,14 @@ export const checkListRead = (
check: ReadBodies & { readonly idField: string; readonly collection: string },
): CheckOutcome | undefined => {
if (!succeeded(record)) return undefined;
// Serving answers a page too small for the collection from its recording, so such a read checks nothing.
const size = pageSize(record.query);
if (
view.membership === "all" &&
size !== undefined &&
state.members(view.type, check.collection).length > size
)
return undefined;
const { body, base } = check;
const baseItems =
base === undefined ? undefined : listItems(base, view.pointer);
@@ -28,6 +28,7 @@ import {
type SimulatedState,
} from "./environmentStateWriteChecks.js";
import {
conflictsWithPlan,
documentPointerOf,
explainUpdate,
responseShapeOf,
@@ -251,26 +252,6 @@ const refineUpdate = (
];
};
/** Whether a later call treats a member or field otherwise than the plan: echoed in one and stored as something else in the other, or ruled differently. */
const conflictsWithPlan = (
plan: WriteDraft["plan"],
explained: ReturnType<typeof explainUpdate>,
): boolean => {
const body = plan.body!;
const fixed = new Set(body.fixed.map((entry) => entry.member));
const rules = new Map(plan.rules.map((rule) => [rule.field, rule]));
return (
explained.body.accepted.some((member) => fixed.has(member)) ||
explained.body.fixed.some((entry) =>
body.accepted.includes(entry.member),
) ||
explained.rules.some((rule) => {
const held = rules.get(rule.field);
return held !== undefined && !same(held, rule);
})
);
};
/** Whether a rule changes its field on calls that do not send it: serving applies it to every call that could. */
const unsentChange = (rule: FieldRule): boolean =>
rule.rule === "increment" || rule.rule === "slug";
@@ -561,5 +542,10 @@ export const applyDocument = (
}));
context.io.retain(64 + JSON.stringify(written.value).length * 2);
context.state.setDocument(tool, written.value);
return { kind: "applied", targets: [documentTarget(tool)] };
// A merge writes the members it sent; a replacement writes the whole document.
return {
kind: "applied",
targets: [documentTarget(tool)],
...(draft.plan.merge === "merge" ? { fields: Object.keys(body) } : {}),
};
};
@@ -11,6 +11,7 @@ import {
import type {
FieldRule,
InsertRule,
StateWritePlan,
WriteBody,
WriteCall,
WriteResponse,
@@ -435,3 +436,23 @@ export const documentPointerOf = (
}
return best?.pointer;
};
/** Whether a later call explains a member or field otherwise than the plan: echoed by one and stored as something else by the other, or ruled differently. */
export const conflictsWithPlan = (
plan: Pick<StateWritePlan, "body" | "rules">,
explained: ReturnType<typeof explainUpdate>,
): boolean => {
const body = plan.body!;
const fixed = new Set(body.fixed.map((entry) => entry.member));
const rules = new Map(plan.rules.map((rule) => [rule.field, rule]));
return (
explained.body.accepted.some((member) => fixed.has(member)) ||
explained.body.fixed.some((entry) =>
body.accepted.includes(entry.member),
) ||
explained.rules.some((rule) => {
const held = rules.get(rule.field);
return held !== undefined && !same(held, rule);
})
);
};
@@ -0,0 +1,49 @@
import { at, isObject, type StateEntity } from "./environmentStateRules.js";
import type { WriteDraft, WriteIo } from "./environmentStateWriteEffects.js";
import type { StateWritePlan } from "./environmentStateWriteRules.js";
/** Whether an update or delete that answers with the entity can answer for an initial entity: one holds every field its recorded response shows. */
const renders = (
plan: StateWritePlan,
input: { readonly io: WriteIo; readonly entities: readonly StateEntity[] },
): boolean => {
const { response } = plan;
if (
(plan.effect !== "update" && plan.effect !== "delete") ||
response?.shape !== "entity"
)
return true;
const shown = input.io.withBody(response.ordinal, (body) => {
const object =
body === undefined ? undefined : at(body, response.pointer ?? "");
return isObject(object) ? Object.keys(object) : undefined;
});
return (
shown === undefined ||
input.entities.some(
(entity) =>
entity.type === plan.target &&
shown.every((field) => Object.hasOwn(entity.fields, field)),
)
);
};
/** A replayed write's plan, served only when it was called, nothing contradicted it, a read confirmed it and its answers can render. */
export const writeVerdict = (
draft: WriteDraft,
input: { readonly io: WriteIo; readonly entities: readonly StateEntity[] },
): StateWritePlan => {
const plan = draft.plan;
if (plan.reason === null) {
if (draft.calls === 0) plan.reason = "no-call";
else if (draft.contradictedBy !== undefined) {
plan.reason = "contradicted";
plan.contradicted_by = draft.contradictedBy;
} else if (draft.sideEffect) plan.reason = "side-effect";
else if (draft.confirmed.length === 0) plan.reason = "unconfirmed";
else if (!renders(plan, input)) plan.reason = "incomplete-entities";
}
plan.served = plan.reason === null;
plan.checked_record_ordinals = [...new Set(draft.confirmed)];
return { ...plan };
};