mirror of
https://github.com/morluto/rea.git
synced 2026-10-29 02:45:58 +00:00
fix(environment): make the write gate and serving agree on lines, rules, inserts and documents
Five places where the gate approved a write serving would answer differently: - list and document reads are matched to checked lines as serving matches calls (path and query values decoded, the query in any order), so a reordered query no longer hides a contradiction; - an increment or slug applies to every call, so a plan is unexplained-change when one call changes such a field and another leaves it as it was, in either order; - an item read confirming a create no longer stops a later list from choosing another insert candidate; only a list check fixes the rule; - the gate runs serving's document body check (accepted members, JSON types from the held and initial document), so a recorded write that serving would refuse leaves the plan unserved; - a served create decodes its path values from the collection the call names, as the gate decoded the recorded path, whichever spelling the call used. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
df2bded16e
commit
2086da6cc7
11 files changed
+343
-52
No files matched your search
@@ -65,7 +65,9 @@ identity the client sends is an echo:
|
||||
1. **Echo.** A body member of the same name with an equal value.
|
||||
2. **Copy.** A body string of 8 or more characters, found at a JSON pointer of
|
||||
the body (the shallowest match).
|
||||
3. **Path.** The value of a path parameter of the call.
|
||||
3. **Path.** The value of a path parameter of the call, percent-decoded.
|
||||
Serving decodes it from the collection the call names, whichever spelling
|
||||
the call uses.
|
||||
4. **Slug.** The slug of a body string member: lower case, runs of other
|
||||
characters joined by `-`.
|
||||
5. **Identity.** The type's `id_field` is generated. A sequence when it is an
|
||||
@@ -107,7 +109,9 @@ explained as one of:
|
||||
- an increment by one;
|
||||
- a slug of an echoed member.
|
||||
|
||||
Any other change is `unexplained-change`.
|
||||
Any other change is `unexplained-change`. An increment or a slug applies to
|
||||
every call that could take it, so a call that leaves such a field as it was
|
||||
while another call of the tool changes it is also `unexplained-change`.
|
||||
|
||||
**Deletes.** A delete removes the entity. Its response is the exemplar's
|
||||
response, with the entity re-rendered when the response carries it.
|
||||
@@ -115,7 +119,9 @@ response, with the entity re-rendered when the response carries it.
|
||||
**Documents.** A document write merges the body's members (replaces them, for
|
||||
`PUT`) into the document object that holds most of them: the root or one
|
||||
member. Document reads answer only on their recorded line, so a write is
|
||||
served only at the path the state holds the document at.
|
||||
served only at the path the state holds the document at. A recorded write
|
||||
whose body serving would refuse (a member of another JSON type than the
|
||||
document holds) is `unexplained-change`.
|
||||
|
||||
**Responses.** A write answers with the status and content type of one
|
||||
recorded response:
|
||||
@@ -165,7 +171,9 @@ members they sent.
|
||||
from the state:
|
||||
|
||||
- an item read of a state-backed item view;
|
||||
- a list or document read on one of its view's checked request lines.
|
||||
- a list or document read on one of its view's checked request lines,
|
||||
matched as serving matches a call to a line: path and query values
|
||||
decoded, the query in any order.
|
||||
|
||||
**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,
|
||||
@@ -190,7 +198,8 @@ body, for an entity the copy does not hold agrees with it.
|
||||
checked again.
|
||||
- A list that fails only for where one create placed its entity passes when
|
||||
another of that create's insert candidates reproduces it, which then
|
||||
becomes the plan's rule.
|
||||
becomes the plan's rule, unless an earlier list check relied on the rule
|
||||
(an item read's confirmation does not fix it).
|
||||
- The read that supplied a create's or update's observation does not check
|
||||
it.
|
||||
|
||||
|
||||
@@ -620,8 +620,9 @@ echo when stored as sent; a member stored differently is accepted only with its
|
||||
recorded value (`body.fixed`) and stores the value the call that showed it
|
||||
recorded (the `value` of its `constant` rule), and a field it changed without
|
||||
sending must be a clock, an increment or a slug, or the plan is
|
||||
`unexplained-change`. Every member the first call sent is required, except by
|
||||
`PATCH` and `POST` updates.
|
||||
`unexplained-change`; an increment or slug must hold for every call of the
|
||||
tool. Every member the first call sent is required, except by `PATCH` and
|
||||
`POST` updates.
|
||||
|
||||
The view then replays the timeline on a copy of the initial state. Modelled
|
||||
writes apply their effect; only a `2xx` write is modelled or an example, and
|
||||
|
||||
@@ -17,9 +17,13 @@ import {
|
||||
renamedBook,
|
||||
type Book,
|
||||
} from "../../../tests/fixtures/environmentStateWrites.js";
|
||||
import type { TimedCaptureRow } from "../../../tests/fixtures/analysisView.js";
|
||||
import type { ReplayArguments } from "./environmentReplay.js";
|
||||
import { servedCatalog } from "./environmentState.js";
|
||||
import { environmentToolDefinitions } from "./environmentTools.js";
|
||||
import {
|
||||
environmentToolArguments,
|
||||
environmentToolDefinitions,
|
||||
} from "./environmentTools.js";
|
||||
|
||||
/** The book the first served create of "New Book" makes: the next id, one second past the served clock's start. */
|
||||
const NEW_BOOK = book(3, "New Book", bookClock(13));
|
||||
@@ -355,3 +359,52 @@ describe("paged list lines", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("path values", () => {
|
||||
const orgBook = (id: number, org: string, name: string): Book => ({
|
||||
id,
|
||||
org,
|
||||
name,
|
||||
});
|
||||
const ALPHA = orgBook(1, "my org1", "Alpha");
|
||||
const BETA = orgBook(2, "acme2", "Beta");
|
||||
const GAMMA = orgBook(3, "my org1", "Gamma");
|
||||
const DELTA = orgBook(4, "acme2", "Delta");
|
||||
/** Books under two organisations, one whose name the paths percent-encode, and a create under each. */
|
||||
const ROWS: readonly TimedCaptureRow[] = [
|
||||
["GET", "/api/orgs/my%20org1/books", [ALPHA]],
|
||||
["GET", "/api/orgs/acme2/books", [BETA]],
|
||||
["GET", "/api/orgs/my%20org1/books/1", ALPHA],
|
||||
["GET", "/api/orgs/acme2/books/2", BETA],
|
||||
["POST", "/api/orgs/my%20org1/books", GAMMA, 201, { name: "Gamma" }],
|
||||
["GET", "/api/orgs/my%20org1/books", [ALPHA, GAMMA]],
|
||||
["POST", "/api/orgs/acme2/books", DELTA, 201, { name: "Delta" }],
|
||||
["GET", "/api/orgs/acme2/books", [BETA, DELTA]],
|
||||
];
|
||||
|
||||
it("stores a parent path value decoded, as the gate learned it, whichever spelling the call uses", () => {
|
||||
for (const spelling of ["my%20org1", "my org1"]) {
|
||||
const books = servedFrom(ROWS);
|
||||
const tool = books.source.manifest.tools.find(
|
||||
(candidate) => candidate.method === "POST",
|
||||
)!;
|
||||
expect(books.state.writes.get(tool.name)).toMatchObject({
|
||||
served: true,
|
||||
rules: expect.arrayContaining([
|
||||
expect.objectContaining({ field: "org", rule: "path" }),
|
||||
]),
|
||||
});
|
||||
const [org] = environmentToolArguments(tool).path.values();
|
||||
books.answer(tool.name, { [org!]: spelling, body: { name: "Epsilon" } });
|
||||
const created = books.store
|
||||
.snapshot()
|
||||
.rea_state.entities.find(
|
||||
(entity) => entity.fields["name"] === "Epsilon",
|
||||
);
|
||||
expect(created).toMatchObject({
|
||||
collection: "/api/orgs/my%20org1/books",
|
||||
fields: { org: "my org1" },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
bodyProblem,
|
||||
createdFields,
|
||||
documentBodyProblem,
|
||||
updatedEntity,
|
||||
updatedFields,
|
||||
valueAt,
|
||||
@@ -9,10 +9,11 @@ import {
|
||||
type GenerateValue,
|
||||
} from "../../domain/analysisView/environmentStateWriteApply.js";
|
||||
import { insertIndex } from "../../domain/analysisView/environmentStateWriteExplain.js";
|
||||
import type {
|
||||
GeneratedRule,
|
||||
StateWritePlan,
|
||||
WriteCall,
|
||||
import {
|
||||
pathValuesOf,
|
||||
type GeneratedRule,
|
||||
type StateWritePlan,
|
||||
type WriteCall,
|
||||
} from "../../domain/analysisView/environmentStateWriteRules.js";
|
||||
import {
|
||||
formatClock,
|
||||
@@ -23,7 +24,6 @@ import {
|
||||
spelledLike,
|
||||
} from "../../domain/analysisView/environmentStateWriteValues.js";
|
||||
import {
|
||||
at,
|
||||
isObject,
|
||||
scalarText,
|
||||
type JsonObject,
|
||||
@@ -275,7 +275,12 @@ const answerCreate = (
|
||||
collection: members,
|
||||
observed,
|
||||
});
|
||||
const fields = createdFields(plan, observed, call, values.generate);
|
||||
// The gate learned path rules from the recorded path, decoded; the collection is this call's.
|
||||
const created = {
|
||||
...call,
|
||||
pathValues: pathValuesOf(tool.path_template, collection) ?? call.pathValues,
|
||||
};
|
||||
const fields = createdFields(plan, observed, created, values.generate);
|
||||
if (!fields.ok) return err(refused(tool, fields.error));
|
||||
const field = unloadableMember(fields.value);
|
||||
if (field !== undefined) return err(unloadable(tool, `Field ${field}`));
|
||||
@@ -439,17 +444,12 @@ const documentTarget = (
|
||||
`The state holds this document only at ${held.path}; the environment models writes to it only there.`,
|
||||
),
|
||||
);
|
||||
const pointer = plan.observation?.pointer ?? "";
|
||||
const members = (value: JsonValue | undefined): JsonObject => {
|
||||
const object = value === undefined ? undefined : at(value, pointer);
|
||||
return isObject(object) ? object : {};
|
||||
};
|
||||
const initial = serving.store.state.documents.find(
|
||||
(document) => document.tool === held.tool,
|
||||
);
|
||||
// A member a replacing write dropped keeps the type the capture showed.
|
||||
const problem = bodyProblem(plan.body, call, {
|
||||
entity: { ...members(initial?.value), ...members(held.value) },
|
||||
const problem = documentBodyProblem(plan, call, {
|
||||
initial: initial?.value,
|
||||
current: held.value,
|
||||
});
|
||||
return problem === undefined ? ok(held) : err(refused(tool, problem));
|
||||
};
|
||||
|
||||
@@ -82,6 +82,19 @@ describe("served document writes", () => {
|
||||
expect(settings.answer(READ_SETTINGS, { id: "1" }).body).toEqual(blue);
|
||||
});
|
||||
|
||||
it("leaves a document write unserved when its recording changed a member's JSON type, which serving would refuse", () => {
|
||||
const retyped = { theme: 5, meta: LIGHT.meta };
|
||||
const settings = servedFrom([
|
||||
["GET", "/api/orgs/1/settings", DARK],
|
||||
["PATCH", "/api/orgs/1/settings", retyped, 200, retyped],
|
||||
["GET", "/api/orgs/1/settings", retyped],
|
||||
]);
|
||||
expect(settings.state.writes.get(PATCH_SETTINGS)).toMatchObject({
|
||||
served: false,
|
||||
reason: "unexplained-change",
|
||||
});
|
||||
});
|
||||
|
||||
it("changes the document only at the path the state holds it", () => {
|
||||
const settings = servedFrom(SETTINGS_ROWS);
|
||||
expectRefusedUnchanged(settings, [
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
FIRST_BOOK,
|
||||
SECOND_BOOK,
|
||||
THIRD_BOOK,
|
||||
book,
|
||||
bookClock,
|
||||
environmentStateViewOf,
|
||||
renamedBook,
|
||||
@@ -99,6 +100,29 @@ describe("reads compared as the served environment answers them", () => {
|
||||
gated(deletedFromFiltered([FIRST_BOOK]), "delete_books_by_id"),
|
||||
).toMatchObject({ served: true, checked_record_ordinals: [4] });
|
||||
});
|
||||
|
||||
/** A delete confirmed by an item read, then the filtered list recorded with its query reordered. */
|
||||
const deletedThenReordered = (listed: readonly Book[]): TimedCaptureRow[] => [
|
||||
["GET", "/api/books/1", FIRST_BOOK],
|
||||
["GET", "/api/books/2", SECOND_BOOK],
|
||||
["GET", "/api/books?shelf=a&sort=id", [FIRST_BOOK, SECOND_BOOK]],
|
||||
["DELETE", "/api/books/2", "", 204],
|
||||
["GET", "/api/books/2", BOOK_NOT_FOUND, 404],
|
||||
["GET", "/api/books?sort=id&shelf=a", [...listed]],
|
||||
];
|
||||
|
||||
it("checks a list read on a checked line however its query is ordered, as serving matches it", () => {
|
||||
// The item read already checked the delete, so a list still showing the book marks it a side effect.
|
||||
expect(
|
||||
gated(
|
||||
deletedThenReordered([FIRST_BOOK, SECOND_BOOK]),
|
||||
"delete_books_by_id",
|
||||
),
|
||||
).toMatchObject({ served: false, reason: "side-effect" });
|
||||
expect(
|
||||
gated(deletedThenReordered([FIRST_BOOK]), "delete_books_by_id"),
|
||||
).toMatchObject({ served: true, checked_record_ordinals: [4] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("update constants", () => {
|
||||
@@ -162,3 +186,67 @@ describe("write examples", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("update rules learned from a later call", () => {
|
||||
const versioned = (entry: Book, version: number): Book => ({
|
||||
...entry,
|
||||
version,
|
||||
});
|
||||
/** Two renames, of the first and the second book, each answered at the version given. */
|
||||
const renames = (first: number, second: number): TimedCaptureRow[] => {
|
||||
const firstAfter = versioned(
|
||||
renamedBook(FIRST_BOOK, "Renamed Book", 3),
|
||||
first,
|
||||
);
|
||||
const secondAfter = versioned(
|
||||
renamedBook(SECOND_BOOK, "Second Edition", 5),
|
||||
second,
|
||||
);
|
||||
return [
|
||||
[
|
||||
"GET",
|
||||
"/api/books",
|
||||
[versioned(FIRST_BOOK, 1), versioned(SECOND_BOOK, 1)],
|
||||
],
|
||||
["GET", "/api/books/1", versioned(FIRST_BOOK, 1)],
|
||||
["GET", "/api/books/2", versioned(SECOND_BOOK, 1)],
|
||||
["PATCH", "/api/books/1", firstAfter, 200, { name: "Renamed Book" }],
|
||||
["GET", "/api/books/1", firstAfter],
|
||||
["PATCH", "/api/books/2", secondAfter, 200, { name: "Second Edition" }],
|
||||
["GET", "/api/books/2", secondAfter],
|
||||
];
|
||||
};
|
||||
|
||||
it("leaves an update unserved when one call increments a field another left as it was", () => {
|
||||
for (const [first, second] of [
|
||||
[1, 2],
|
||||
[2, 1],
|
||||
] as const)
|
||||
expect(gated(renames(first, second), "patch_books_by_id")).toMatchObject({
|
||||
served: false,
|
||||
reason: "unexplained-change",
|
||||
});
|
||||
expect(gated(renames(2, 2), "patch_books_by_id")).toMatchObject({
|
||||
served: true,
|
||||
rules: expect.arrayContaining([{ field: "version", rule: "increment" }]),
|
||||
checked_record_ordinals: [4, 6],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("insert rules", () => {
|
||||
it("still learns where a list places a create after an item read confirmed it", () => {
|
||||
const alpha = book(3, "Alpha", bookClock(3));
|
||||
const rows: TimedCaptureRow[] = [
|
||||
...BOOK_INIT_ROWS,
|
||||
["POST", "/api/books", alpha, 201, { name: "Alpha" }],
|
||||
["GET", "/api/books/3", alpha],
|
||||
["GET", "/api/books", [alpha, FIRST_BOOK, SECOND_BOOK]],
|
||||
];
|
||||
expect(gated(rows, "post_books")).toMatchObject({
|
||||
served: true,
|
||||
insert: { rule: "sorted", field: "name", direction: "ascending" },
|
||||
checked_record_ordinals: [4, 5],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,9 +18,11 @@ import {
|
||||
checkDocumentRead,
|
||||
checkItemRead,
|
||||
checkListRead,
|
||||
checkedLines,
|
||||
collectionTarget,
|
||||
entityTarget,
|
||||
isAbsence,
|
||||
requestLine,
|
||||
succeeded,
|
||||
withBodies,
|
||||
writtenFieldChecks,
|
||||
@@ -60,9 +62,6 @@ export interface WriteSimulationInput {
|
||||
readonly io: WriteIo;
|
||||
}
|
||||
|
||||
const requestLine = (record: StateRecordFacts): string =>
|
||||
record.path + (record.query ?? "");
|
||||
|
||||
const APPLY = {
|
||||
create: applyCreate,
|
||||
update: applyUpdate,
|
||||
@@ -82,7 +81,7 @@ export class EnvironmentStateWriteSimulation {
|
||||
readonly #toolOf = new Map<number, StateToolFacts>();
|
||||
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 = new Map<string, ReadonlyMap<string, number>>();
|
||||
readonly #checkedLines: ReadonlyMap<string, ReadonlyMap<string, number>>;
|
||||
readonly #observations = new Set<number>();
|
||||
readonly #taint: WriteTaint;
|
||||
/** Modelled writes per target since its last check, with the fields each wrote (null: all). */
|
||||
@@ -99,6 +98,8 @@ export class EnvironmentStateWriteSimulation {
|
||||
}[] = [];
|
||||
/** Entities a create inserted, by target, with the create's tool. */
|
||||
readonly #inserted = new Map<string, string>();
|
||||
/** Creates whose insert rule a passing list check relied on, so it is no longer changed. */
|
||||
readonly #placed = new Set<string>();
|
||||
|
||||
constructor(input: WriteSimulationInput) {
|
||||
this.#input = input;
|
||||
@@ -108,22 +109,7 @@ export class EnvironmentStateWriteSimulation {
|
||||
for (const tool of input.tools)
|
||||
for (const ordinal of tool.record_ordinals)
|
||||
this.#toolOf.set(ordinal, tool);
|
||||
const byOrdinal = new Map(
|
||||
input.records.map((record) => [record.ordinal, record]),
|
||||
);
|
||||
for (const view of input.views.values())
|
||||
if (view.shape !== "recorded" && view.shape !== "item")
|
||||
this.#checkedLines.set(
|
||||
view.tool,
|
||||
new Map(
|
||||
view.checked_record_ordinals.flatMap((ordinal) => {
|
||||
const record = byOrdinal.get(ordinal);
|
||||
return record === undefined
|
||||
? []
|
||||
: [[requestLine(record), ordinal] as const];
|
||||
}),
|
||||
),
|
||||
);
|
||||
this.#checkedLines = checkedLines(input);
|
||||
this.#taint = new WriteTaint(
|
||||
[...this.#types.keys()],
|
||||
new Map(
|
||||
@@ -156,6 +142,7 @@ export class EnvironmentStateWriteSimulation {
|
||||
contradictedBy: undefined,
|
||||
sideEffect: false,
|
||||
calls: 0,
|
||||
updates: [],
|
||||
});
|
||||
}
|
||||
this.#input.records.forEach((record, index) => this.#step(record, index));
|
||||
@@ -188,6 +175,8 @@ export class EnvironmentStateWriteSimulation {
|
||||
observed: (ordinal) => this.#observations.add(ordinal),
|
||||
unknown: (type, collection, id) =>
|
||||
this.#taint.unknown(type, collection, id),
|
||||
initialDocument: (tool) =>
|
||||
this.#input.documents.find((document) => document.tool === tool)?.value,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -363,8 +352,10 @@ export class EnvironmentStateWriteSimulation {
|
||||
outcome.pass ||
|
||||
(view.shape === "list" && this.#retryInsert(view, record, exercised));
|
||||
if (pass)
|
||||
for (const name of exercised)
|
||||
for (const name of exercised) {
|
||||
this.#drafts.get(name)!.confirmed.push(record.ordinal);
|
||||
if (view.shape === "list") this.#placed.add(name);
|
||||
}
|
||||
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);
|
||||
@@ -488,7 +479,8 @@ export class EnvironmentStateWriteSimulation {
|
||||
if (creates.size !== 1) return false;
|
||||
const [name] = creates;
|
||||
const draft = this.#drafts.get(name!)!;
|
||||
if (!exercised.has(name!) || draft.confirmed.length > 0) return false;
|
||||
// An item read confirms the entity, not where a list places it.
|
||||
if (!exercised.has(name!) || this.#placed.has(name!)) return false;
|
||||
for (const candidate of draft.candidates.slice(1)) {
|
||||
for (const entity of inserted) this.#state.remove(entity);
|
||||
for (const entity of inserted) {
|
||||
|
||||
@@ -204,9 +204,8 @@ export const same = (
|
||||
: canonicalJson(left, "Environment state") ===
|
||||
canonicalJson(right, "Environment state");
|
||||
|
||||
/** The decoded final path segment, which an item request names its entity by. */
|
||||
export const lastSegmentValue = (path: string): string => {
|
||||
const segment = segmentsOf(path).at(-1) ?? "";
|
||||
/** A path segment percent-decoded, or as written when it does not decode. */
|
||||
export const decodedSegment = (segment: string): string => {
|
||||
try {
|
||||
return decodeURIComponent(segment);
|
||||
} catch {
|
||||
@@ -214,6 +213,10 @@ export const lastSegmentValue = (path: string): string => {
|
||||
}
|
||||
};
|
||||
|
||||
/** The decoded final path segment, which an item request names its entity by. */
|
||||
export const lastSegmentValue = (path: string): string =>
|
||||
decodedSegment(segmentsOf(path).at(-1) ?? "");
|
||||
|
||||
/** A path's non-empty segments, so a trailing slash names the same collection. */
|
||||
export const normalizedPath = (path: string): string =>
|
||||
`/${segmentsOf(path).join("/")}`;
|
||||
|
||||
@@ -236,6 +236,26 @@ export const updatedFields = (
|
||||
return ok(written);
|
||||
};
|
||||
|
||||
/** Why a document write's body does not fit what the capture showed: members its plan accepts, as the JSON types the document holds, a member a replacing write dropped keeping the type the initial document showed. */
|
||||
export const documentBodyProblem = (
|
||||
plan: Pick<StateWritePlan, "body" | "observation">,
|
||||
call: WriteCall,
|
||||
documents: {
|
||||
readonly initial: JsonValue | undefined;
|
||||
readonly current: JsonValue;
|
||||
},
|
||||
): string | undefined => {
|
||||
if (plan.body === null) return "The tool has no modelled body.";
|
||||
const pointer = plan.observation?.pointer ?? "";
|
||||
const members = (value: JsonValue | undefined): JsonObject => {
|
||||
const object = value === undefined ? undefined : at(value, pointer);
|
||||
return isObject(object) ? object : {};
|
||||
};
|
||||
return bodyProblem(plan.body, call, {
|
||||
entity: { ...members(documents.initial), ...members(documents.current) },
|
||||
});
|
||||
};
|
||||
|
||||
/** A document after a write: the body's members merged into, or replacing, the object at the pointer. */
|
||||
export const writtenDocument = (
|
||||
plan: Pick<StateWritePlan, "merge" | "observation">,
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { JsonValue } from "../jsonValue.js";
|
||||
import { compositeKey } from "../unicodeCodePointOrder.js";
|
||||
import { iterateUrlQueryParameters } from "../recordedTransportUrl.js";
|
||||
import {
|
||||
compareUnicodeCodePoints as compare,
|
||||
compositeKey,
|
||||
} from "../unicodeCodePointOrder.js";
|
||||
import {
|
||||
at,
|
||||
collectionPathOf,
|
||||
decodedSegment,
|
||||
isObject,
|
||||
lastSegmentValue,
|
||||
listItems,
|
||||
@@ -11,14 +16,57 @@ import {
|
||||
replaceAt,
|
||||
same,
|
||||
scalarText,
|
||||
segmentsOf,
|
||||
type JsonObject,
|
||||
type StateDocument,
|
||||
type StateEntity,
|
||||
type StateItemView,
|
||||
type StateListView,
|
||||
type StateRecordFacts,
|
||||
type StateToolFacts,
|
||||
type StateView,
|
||||
} from "./environmentStateRules.js";
|
||||
|
||||
/**
|
||||
* A read's request line as serving names one: its path segments and query values decoded, the
|
||||
* query in any order. A query repeating a name is no call's arguments, so it keeps its spelling.
|
||||
*/
|
||||
export const requestLine = (record: StateRecordFacts): string => {
|
||||
const query = [...iterateUrlQueryParameters(record.query ?? "")];
|
||||
if (new Set(query.map(([name]) => name)).size !== query.length)
|
||||
return compositeKey([record.path, record.query ?? ""]);
|
||||
return compositeKey([
|
||||
segmentsOf(record.path).map(decodedSegment),
|
||||
query.sort(([left], [right]) => compare(left, right)),
|
||||
]);
|
||||
};
|
||||
|
||||
/** Each list or document view's checked lines, by request line, as serving answers a call: from the first checked line it names, in the tool's recorded order. */
|
||||
export const checkedLines = (input: {
|
||||
readonly tools: readonly StateToolFacts[];
|
||||
readonly records: readonly StateRecordFacts[];
|
||||
readonly views: ReadonlyMap<string, StateView>;
|
||||
}): Map<string, ReadonlyMap<string, number>> => {
|
||||
const byOrdinal = new Map(
|
||||
input.records.map((record) => [record.ordinal, record]),
|
||||
);
|
||||
const tools = new Map(input.tools.map((tool) => [tool.name, tool]));
|
||||
const byView = new Map<string, ReadonlyMap<string, number>>();
|
||||
for (const view of input.views.values()) {
|
||||
if (view.shape === "recorded" || view.shape === "item") continue;
|
||||
const checked = new Set(view.checked_record_ordinals);
|
||||
const lines = new Map<string, number>();
|
||||
for (const ordinal of tools.get(view.tool)?.record_ordinals ?? []) {
|
||||
const record = byOrdinal.get(ordinal);
|
||||
if (!checked.has(ordinal) || record === undefined) continue;
|
||||
const line = requestLine(record);
|
||||
if (!lines.has(line)) lines.set(line, ordinal);
|
||||
}
|
||||
byView.set(view.tool, lines);
|
||||
}
|
||||
return byView;
|
||||
};
|
||||
|
||||
export const entityTarget = (type: string, collection: string, id: string) =>
|
||||
`e${compositeKey([type, collection, id])}`;
|
||||
export const collectionTarget = (type: string, collection: string) =>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type StateView,
|
||||
} from "./environmentStateRules.js";
|
||||
import {
|
||||
documentBodyProblem,
|
||||
updatedEntity,
|
||||
updatedFields,
|
||||
writtenDocument,
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
} from "./environmentStateWriteObserve.js";
|
||||
import {
|
||||
pathValuesOf,
|
||||
type FieldRule,
|
||||
type InsertRule,
|
||||
type StateWritePlan,
|
||||
type WriteCall,
|
||||
@@ -52,6 +54,11 @@ export interface WriteDraft {
|
||||
contradictedBy: number | undefined;
|
||||
sideEffect: boolean;
|
||||
calls: number;
|
||||
/** The fields each modelled update call showed and sent, which later rules must agree with. */
|
||||
readonly updates: {
|
||||
readonly shown: ReadonlySet<string>;
|
||||
readonly sent: ReadonlySet<string>;
|
||||
}[];
|
||||
}
|
||||
|
||||
/** Response bodies, request bodies and budget the effects read. */
|
||||
@@ -77,6 +84,8 @@ export interface EffectContext {
|
||||
readonly observed: (ordinal: number) => void;
|
||||
/** Whether an unmodelled write may have changed the entity since the state last saw it. */
|
||||
readonly unknown: (type: string, collection: string, id: string) => boolean;
|
||||
/** A document's value in the initial state, which serving also reads member types from. */
|
||||
readonly initialDocument: (tool: string) => JsonValue | undefined;
|
||||
}
|
||||
|
||||
/** One recorded write: the record, its timeline index, its tool, and whether its plan only takes the recorded outcome. */
|
||||
@@ -233,6 +242,28 @@ const refineUpdate = (
|
||||
];
|
||||
};
|
||||
|
||||
/** 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";
|
||||
|
||||
/** Whether a rule a later call showed would have changed a field an earlier call showed unchanged without sending. */
|
||||
const contradictsEarlierCalls = (
|
||||
draft: WriteDraft,
|
||||
rules: readonly FieldRule[],
|
||||
): boolean => {
|
||||
const ruled = new Set(draft.plan.rules.map((rule) => rule.field));
|
||||
return rules.some(
|
||||
(rule) =>
|
||||
unsentChange(rule) &&
|
||||
!ruled.has(rule.field) &&
|
||||
draft.updates.some(
|
||||
({ shown, sent }) =>
|
||||
shown.has(rule.field) &&
|
||||
(rule.rule !== "slug" || sent.has(rule.from)),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
/** Build an update plan from its exemplar, or add what a later call showed. */
|
||||
const buildUpdate = (
|
||||
context: EffectContext,
|
||||
@@ -321,6 +352,15 @@ export const applyUpdate = (
|
||||
refuse(draft, step, "unexplained-change");
|
||||
return recorded();
|
||||
}
|
||||
// Rules for unsent changes hold for every call, so each call must agree with those of the others.
|
||||
if (
|
||||
built(draft) &&
|
||||
!unknown &&
|
||||
contradictsEarlierCalls(draft, explained.rules)
|
||||
) {
|
||||
refuse(draft, step, "unexplained-change");
|
||||
return recorded();
|
||||
}
|
||||
buildUpdate(context, draft, step, { after, explained, idField });
|
||||
// Serving reads the plan's observation, so the gate does too; generated values are this call's.
|
||||
const written = updatedFields(
|
||||
@@ -329,8 +369,21 @@ export const applyUpdate = (
|
||||
{ ...call, id },
|
||||
(rule) => after.object[rule.field] ?? null,
|
||||
);
|
||||
if (!written.ok) refuse(draft, step, "unexplained-change");
|
||||
if (!written.ok || unknown) return recorded();
|
||||
const disagrees =
|
||||
written.ok &&
|
||||
draft.plan.rules.some(
|
||||
(rule) =>
|
||||
unsentChange(rule) &&
|
||||
Object.hasOwn(after.object, rule.field) &&
|
||||
!same(written.value[rule.field], after.object[rule.field]),
|
||||
);
|
||||
if (!written.ok || (disagrees && !unknown))
|
||||
refuse(draft, step, "unexplained-change");
|
||||
if (!written.ok || disagrees || unknown) return recorded();
|
||||
const shown = Object.keys(after.object);
|
||||
const sentMembers = Object.keys(body);
|
||||
context.io.retain(64 + [...shown, ...sentMembers].join("").length * 2);
|
||||
draft.updates.push({ shown: new Set(shown), sent: new Set(sentMembers) });
|
||||
context.io.retain(64 + JSON.stringify(written.value).length * 2);
|
||||
context.state.put(updatedEntity(entity, written.value));
|
||||
// A later read that also shows an unmodelled change checks only the members this call sent.
|
||||
@@ -412,7 +465,18 @@ export const applyDocument = (
|
||||
identity: null,
|
||||
},
|
||||
});
|
||||
const written = writtenDocument(draft.plan, value, callOf(step, body));
|
||||
const call = callOf(step, body);
|
||||
// Serving refuses a body its check refuses, so such a call cannot confirm the plan.
|
||||
if (
|
||||
documentBodyProblem(draft.plan, call, {
|
||||
initial: context.initialDocument(tool),
|
||||
current: value,
|
||||
}) !== undefined
|
||||
) {
|
||||
refuse(draft, step, "unexplained-change");
|
||||
return UNKNOWN;
|
||||
}
|
||||
const written = writtenDocument(draft.plan, value, call);
|
||||
if (!written.ok) {
|
||||
refuse(draft, step, "no-observation");
|
||||
return UNKNOWN;
|
||||
|
||||
Reference in new issue
Block a user