mirror of
https://github.com/morluto/rea.git
synced 2026-10-27 01:45:32 +00:00
fix(environment): judge writes by the full served response; model only 2xx writes
The write gate confirmed effects the served environment would not show: - a list check let a member the state no longer holds pass, so a filtered list still showing a deleted entity confirmed the delete; it now compares the whole list serving would return; - item and list checks compared only the entity, so a wrapper member that changed after the write (a revision, say) went unnoticed while serving kept the base recording's; reads are now compared as the full response rendered into the item view's or the line's base envelope; - a later update's constant rule kept no value, so serving stored the first observation's; each constant rule now carries the value the call that showed it recorded, and the gate and serving read it alike; - a redirected or unanswered write could become the example or a modelled effect; only a 2xx write is modelled, others taint as unmodelled writes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
82556cd550
commit
138ea4b198
11 files changed
+321
-92
No files matched your search
@@ -617,16 +617,20 @@ minutes of the request in its recorded format, a `sequence` one past the
|
||||
largest value of its collection or type, a `unique` UUID, or a `template` around
|
||||
a generated value; other fields keep the observed value. An update's members
|
||||
echo when stored as sent; a member stored differently is accepted only with its
|
||||
recorded value (`body.fixed`), and a field it changed without sending must be a
|
||||
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.
|
||||
|
||||
The view then replays the timeline on a copy of the initial state. Modelled
|
||||
writes apply their effect; any other write leaves the entity below whose item
|
||||
writes apply their effect; only a `2xx` write is modelled or an example, and
|
||||
any other write (a redirect, an unanswered or unmodelled one) leaves the entity below whose item
|
||||
path it wrote unknown until a read shows it again (a whole collection when the
|
||||
path names no held entity), and taints what it wrote at or above. A read the
|
||||
served environment would answer from the state is compared with its recording
|
||||
as the read plans are, a 400, 404 or 410 (or a `null` body) agreeing with an
|
||||
served environment would answer from the state is compared, as the whole
|
||||
response it would serve (the entity or list rendered into the envelope of the
|
||||
item view's or the line's base recording, with the members the state still
|
||||
holds), with its recording as the read plans are, a 400, 404 or 410 (or a `null` body) agreeing with an
|
||||
entity the copy does not hold. A match confirms the writes that changed its
|
||||
entity or collection since it was last read; a mismatch contradicts them, or,
|
||||
when an unmodelled write came after them, is checked only on the members they
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
timedCaptureEvidence,
|
||||
type TimedCaptureRow,
|
||||
} from "../../../tests/fixtures/analysisView.js";
|
||||
import {
|
||||
BOOK_INIT_ROWS,
|
||||
BOOK_NOT_FOUND,
|
||||
FIRST_BOOK,
|
||||
SECOND_BOOK,
|
||||
THIRD_BOOK,
|
||||
bookClock,
|
||||
environmentStateViewOf,
|
||||
renamedBook,
|
||||
type Book,
|
||||
} from "../../../tests/fixtures/environmentStateWrites.js";
|
||||
import { ok } from "../result.js";
|
||||
import { updatedFields } from "./environmentStateWriteApply.js";
|
||||
|
||||
/** The plan the gate gives `tool` over a capture of `rows`. */
|
||||
const gated = (rows: readonly TimedCaptureRow[], tool: string) => {
|
||||
const { writes } = environmentStateViewOf(timedCaptureEvidence(rows));
|
||||
const plan = writes.find((candidate) => candidate.tool === tool);
|
||||
if (plan === undefined) throw new Error(`The gate planned no ${tool}`);
|
||||
return plan;
|
||||
};
|
||||
|
||||
const RENAMED = renamedBook(FIRST_BOOK, "Renamed Book", 3);
|
||||
|
||||
describe("reads compared as the served environment answers them", () => {
|
||||
/** Item reads in a `{data, revision}` envelope, a rename, then a read recorded at `revision`. */
|
||||
const renamedInEnvelope = (revision: string): TimedCaptureRow[] => [
|
||||
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK]],
|
||||
["GET", "/api/books/1", { data: FIRST_BOOK, revision: "r1" }],
|
||||
["GET", "/api/books/2", { data: SECOND_BOOK, revision: "r1" }],
|
||||
["PATCH", "/api/books/1", RENAMED, 200, { name: "Renamed Book" }],
|
||||
["GET", "/api/books/1", { data: RENAMED, revision }],
|
||||
];
|
||||
|
||||
it("contradicts a write when an item read's envelope differs from the one served", () => {
|
||||
expect(gated(renamedInEnvelope("r2"), "patch_books_by_id")).toMatchObject({
|
||||
served: false,
|
||||
reason: "contradicted",
|
||||
contradicted_by: 4,
|
||||
});
|
||||
expect(gated(renamedInEnvelope("r1"), "patch_books_by_id")).toMatchObject({
|
||||
served: true,
|
||||
checked_record_ordinals: [4],
|
||||
});
|
||||
});
|
||||
|
||||
/** A list in a `{items, revision}` envelope, a create, then the list recorded at `revision`. */
|
||||
const createdInEnvelope = (revision: string): TimedCaptureRow[] => [
|
||||
["GET", "/api/books", { items: [FIRST_BOOK, SECOND_BOOK], revision: "r1" }],
|
||||
["GET", "/api/books/1", FIRST_BOOK],
|
||||
["GET", "/api/books/2", SECOND_BOOK],
|
||||
["POST", "/api/books", THIRD_BOOK, 201, { name: "Third Book" }],
|
||||
[
|
||||
"GET",
|
||||
"/api/books",
|
||||
{ items: [FIRST_BOOK, SECOND_BOOK, THIRD_BOOK], revision },
|
||||
],
|
||||
];
|
||||
|
||||
it("contradicts a write when a list's envelope differs from its served line", () => {
|
||||
expect(gated(createdInEnvelope("r2"), "post_books")).toMatchObject({
|
||||
served: false,
|
||||
reason: "contradicted",
|
||||
contradicted_by: 4,
|
||||
});
|
||||
expect(gated(createdInEnvelope("r1"), "post_books")).toMatchObject({
|
||||
served: true,
|
||||
checked_record_ordinals: [4],
|
||||
});
|
||||
});
|
||||
|
||||
/** A filtered list of both books, a delete of the second, then the filtered list recorded with `listed`. */
|
||||
const deletedFromFiltered = (listed: readonly Book[]): TimedCaptureRow[] => [
|
||||
["GET", "/api/books/1", FIRST_BOOK],
|
||||
["GET", "/api/books/2", SECOND_BOOK],
|
||||
["GET", "/api/books?shelf=a", [FIRST_BOOK, SECOND_BOOK]],
|
||||
["DELETE", "/api/books/2", "", 204],
|
||||
["GET", "/api/books?shelf=a", [...listed]],
|
||||
];
|
||||
|
||||
it("contradicts a delete that a filtered list still shows, and confirms one it drops", () => {
|
||||
expect(
|
||||
gated(
|
||||
deletedFromFiltered([FIRST_BOOK, SECOND_BOOK]),
|
||||
"delete_books_by_id",
|
||||
),
|
||||
).toMatchObject({
|
||||
served: false,
|
||||
reason: "contradicted",
|
||||
contradicted_by: 4,
|
||||
});
|
||||
expect(
|
||||
gated(deletedFromFiltered([FIRST_BOOK]), "delete_books_by_id"),
|
||||
).toMatchObject({ served: true, checked_record_ordinals: [4] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("update constants", () => {
|
||||
const paper = (entry: Book, format = "PAPER"): Book => ({ ...entry, format });
|
||||
const RENAMED_PAPER = paper(RENAMED);
|
||||
const HARDCOVER = paper(
|
||||
{ ...SECOND_BOOK, updated_at: bookClock(5) },
|
||||
"HARDCOVER",
|
||||
);
|
||||
/** A rename that keeps the format, then a format change the application stores in capitals. */
|
||||
const ROWS: TimedCaptureRow[] = [
|
||||
["GET", "/api/books", [paper(FIRST_BOOK), paper(SECOND_BOOK)]],
|
||||
["GET", "/api/books/1", paper(FIRST_BOOK)],
|
||||
["GET", "/api/books/2", paper(SECOND_BOOK)],
|
||||
["PATCH", "/api/books/1", RENAMED_PAPER, 200, { name: "Renamed Book" }],
|
||||
["GET", "/api/books/1", RENAMED_PAPER],
|
||||
["PATCH", "/api/books/2", HARDCOVER, 200, { format: "hardcover" }],
|
||||
["GET", "/api/books/2", HARDCOVER],
|
||||
];
|
||||
|
||||
it("stores the value the call that showed a constant recorded, as serving applies the plan", () => {
|
||||
const plan = gated(ROWS, "patch_books_by_id");
|
||||
expect(plan).toMatchObject({
|
||||
served: true,
|
||||
observation: { ordinal: 3, pointer: "" },
|
||||
body: { fixed: [{ member: "format", sent: "hardcover" }] },
|
||||
checked_record_ordinals: [4, 6],
|
||||
});
|
||||
// Serving reads the plan's observation, the first update's answer, which still shows PAPER.
|
||||
const served = updatedFields(
|
||||
plan,
|
||||
{ before: paper(SECOND_BOOK), observed: RENAMED_PAPER },
|
||||
{
|
||||
path: "/api/books/2",
|
||||
pathValues: new Map([["id", "2"]]),
|
||||
body: { format: "hardcover" },
|
||||
id: "2",
|
||||
},
|
||||
() => bookClock(9),
|
||||
);
|
||||
expect(served).toEqual(
|
||||
ok(paper({ ...SECOND_BOOK, updated_at: bookClock(9) }, "HARDCOVER")),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("write examples", () => {
|
||||
it("takes no modelled effect or exemplar from a redirected write", () => {
|
||||
const rows: TimedCaptureRow[] = [
|
||||
...BOOK_INIT_ROWS,
|
||||
["DELETE", "/api/books/2", "", 302],
|
||||
["GET", "/api/books/2", SECOND_BOOK],
|
||||
["DELETE", "/api/books/2", "", 204],
|
||||
["GET", "/api/books/2", BOOK_NOT_FOUND, 404],
|
||||
];
|
||||
expect(gated(rows, "delete_books_by_id")).toMatchObject({
|
||||
served: true,
|
||||
exemplar_ordinal: 5,
|
||||
checked_record_ordinals: [6],
|
||||
contradicted_by: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,8 +18,11 @@ import {
|
||||
checkDocumentRead,
|
||||
checkItemRead,
|
||||
checkListRead,
|
||||
collectionTarget,
|
||||
entityTarget,
|
||||
isAbsence,
|
||||
succeeded,
|
||||
withBodies,
|
||||
writtenFieldChecks,
|
||||
SimulatedState,
|
||||
type CheckOutcome,
|
||||
@@ -78,7 +81,8 @@ export class EnvironmentStateWriteSimulation {
|
||||
readonly #drafts = new Map<string, WriteDraft>();
|
||||
readonly #toolOf = new Map<number, StateToolFacts>();
|
||||
readonly #types = new Map<string, StateType>();
|
||||
readonly #checkedLines = new Map<string, ReadonlySet<string>>();
|
||||
/** 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 #observations = new Set<number>();
|
||||
readonly #taint: WriteTaint;
|
||||
/** Modelled writes per target since its last check, with the fields each wrote (null: all). */
|
||||
@@ -111,10 +115,12 @@ export class EnvironmentStateWriteSimulation {
|
||||
if (view.shape !== "recorded" && view.shape !== "item")
|
||||
this.#checkedLines.set(
|
||||
view.tool,
|
||||
new Set(
|
||||
new Map(
|
||||
view.checked_record_ordinals.flatMap((ordinal) => {
|
||||
const record = byOrdinal.get(ordinal);
|
||||
return record === undefined ? [] : [requestLine(record)];
|
||||
return record === undefined
|
||||
? []
|
||||
: [[requestLine(record), ordinal] as const];
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -158,7 +164,10 @@ export class EnvironmentStateWriteSimulation {
|
||||
|
||||
#step(record: StateRecordFacts, index: number): void {
|
||||
const tool = this.#toolOf.get(record.ordinal);
|
||||
if (isStateWrite(record)) this.#write(record, index, tool);
|
||||
// A redirected or unanswered write may have changed state, but only a 2xx one shows its effect.
|
||||
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);
|
||||
}
|
||||
@@ -303,32 +312,37 @@ export class EnvironmentStateWriteSimulation {
|
||||
|
||||
#check(view: StateView, record: StateRecordFacts): CheckOutcome | undefined {
|
||||
if (view.shape === "recorded") return undefined;
|
||||
if (
|
||||
view.shape !== "item" &&
|
||||
!this.#checkedLines.get(view.tool)?.has(requestLine(record))
|
||||
)
|
||||
return undefined;
|
||||
if (
|
||||
(view.shape === "item" || view.shape === "list") &&
|
||||
this.#taint.under(collectionPathOf(record, view.shape === "item"))
|
||||
)
|
||||
return undefined;
|
||||
if (view.shape === "list") {
|
||||
const collection = collectionPathOf(record, false);
|
||||
const line = this.#checkedLines.get(view.tool)?.get(requestLine(record));
|
||||
if (view.shape !== "item" && line === undefined) return undefined;
|
||||
if (view.shape === "document")
|
||||
return this.#input.io.withBody(record.ordinal, (body) =>
|
||||
checkDocumentRead(this.#state, view.tool, record, body),
|
||||
);
|
||||
const item = view.shape === "item";
|
||||
const collection = collectionPathOf(record, item);
|
||||
if (this.#taint.under(collection)) return undefined;
|
||||
if (!item) {
|
||||
const ids = this.#state
|
||||
.members(view.type, collection)
|
||||
.map((entity) => entity.id);
|
||||
if (this.#taint.blocksList(view.type, collection, ids)) return undefined;
|
||||
// A recorded-membership list covers no collection target, so a write at the collection is checked here.
|
||||
if (
|
||||
this.#taint.tainted(collectionTarget(view.type, collection)) ||
|
||||
this.#taint.blocksList(view.type, collection, ids)
|
||||
)
|
||||
return undefined;
|
||||
}
|
||||
return this.#input.io.withBody(record.ordinal, (body) => {
|
||||
if (view.shape === "document")
|
||||
return checkDocumentRead(this.#state, view.tool, record, body);
|
||||
// The served answer keeps the envelope of the item view's or the line's base recording.
|
||||
const base = item ? view.envelope_ordinal : line!;
|
||||
const ordinals = { base, read: record.ordinal };
|
||||
return withBodies(this.#input.io.withBody, ordinals, (served, body) => {
|
||||
const check = {
|
||||
body,
|
||||
base: served,
|
||||
idField: this.#types.get(view.type)?.id_field ?? "",
|
||||
volatile: this.#input.volatileOf(view.type),
|
||||
};
|
||||
return view.shape === "item"
|
||||
return item
|
||||
? checkItemRead(this.#state, view, record, check)
|
||||
: checkListRead(this.#state, view, record, check);
|
||||
});
|
||||
|
||||
@@ -120,7 +120,11 @@ const fieldRuleSchema = z.discriminatedUnion("rule", [
|
||||
z.strictObject({ ...ruled, rule: z.literal("slug"), from: z.string() }),
|
||||
z.strictObject({ ...ruled, rule: z.literal("template"), from: z.string() }),
|
||||
z.strictObject({ ...ruled, rule: z.literal("increment") }),
|
||||
z.strictObject({ ...ruled, rule: z.literal("constant") }),
|
||||
z.strictObject({
|
||||
...ruled,
|
||||
rule: z.literal("constant"),
|
||||
value: jsonValueSchema,
|
||||
}),
|
||||
]);
|
||||
const insertRuleSchema = z.union([
|
||||
z.strictObject({ rule: z.enum(["first", "last"]) }),
|
||||
|
||||
@@ -242,7 +242,7 @@ const UPDATE_PLAN: Plan = {
|
||||
rules: [
|
||||
{ field: "title", rule: "echo" },
|
||||
{ field: "notes", rule: "echo" },
|
||||
{ field: "status", rule: "constant" },
|
||||
{ field: "status", rule: "constant", value: "closed" },
|
||||
{ field: "slug", rule: "slug", from: "title" },
|
||||
{ field: "version", rule: "increment" },
|
||||
{ field: "revision", rule: "increment" },
|
||||
|
||||
@@ -191,7 +191,7 @@ export const createdFields = (
|
||||
return ok(written);
|
||||
};
|
||||
|
||||
/** The fields of an updated entity: sent echoed members, constants from the recorded update, generated and derived changes. */
|
||||
/** The fields of an updated entity: sent echoed members, the constants the plan recorded, generated and derived changes. */
|
||||
export const updatedFields = (
|
||||
plan: Pick<StateWritePlan, "rules" | "body">,
|
||||
entity: { readonly before: JsonObject; readonly observed: JsonObject },
|
||||
@@ -218,7 +218,7 @@ export const updatedFields = (
|
||||
if (rule.rule === "echo" || rule.rule === "constant") {
|
||||
if (!Object.hasOwn(sent, rule.field)) continue;
|
||||
written[rule.field] =
|
||||
rule.rule === "echo" ? sent[rule.field]! : observed[rule.field]!;
|
||||
rule.rule === "echo" ? sent[rule.field]! : rule.value;
|
||||
} else if (rule.rule === "increment") {
|
||||
const was = integerOf(before[rule.field]);
|
||||
if (was !== undefined)
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
listItems,
|
||||
renderStateEntity,
|
||||
renderStateList,
|
||||
replaceAt,
|
||||
same,
|
||||
scalarText,
|
||||
type JsonObject,
|
||||
@@ -95,26 +96,44 @@ export class SimulatedState {
|
||||
export const isAbsence = (status: number | null): boolean =>
|
||||
status === 400 || status === 404 || status === 410;
|
||||
|
||||
const succeeded = (record: StateRecordFacts): boolean =>
|
||||
/** A 2xx record: the only status whose response shows what a request did. */
|
||||
export const succeeded = (record: StateRecordFacts): boolean =>
|
||||
record.status !== null && record.status >= 200 && record.status < 300;
|
||||
|
||||
/** Hand a base recording's body and a read's body to `use` together, parsing a recording once when both are the same. */
|
||||
export const withBodies = <T>(
|
||||
withBody: <U>(ordinal: number, use: (body: JsonValue | undefined) => U) => U,
|
||||
ordinals: { readonly base: number; readonly read: number },
|
||||
use: (base: JsonValue | undefined, body: JsonValue | undefined) => T,
|
||||
): T => {
|
||||
const { base, read } = ordinals;
|
||||
return base === read
|
||||
? withBody(read, (body) => use(body, body))
|
||||
: withBody(base, (served) => withBody(read, (body) => use(served, body)));
|
||||
};
|
||||
|
||||
/** A check's outcome and the targets it covered. */
|
||||
export interface CheckOutcome {
|
||||
readonly pass: boolean;
|
||||
readonly targets: readonly string[];
|
||||
/** Entities the recording lists that the state does not hold, by target. */
|
||||
/** Entities the recording, or the served base of a recorded-membership list, lists that the state does not hold, by target. */
|
||||
readonly unheld: readonly string[];
|
||||
}
|
||||
|
||||
/** An item read: the recorded entity object must render from the held entity; a recorded 404 or 410 agrees with none held. */
|
||||
/** What a check compares: the read's recorded body and the served base recording it would answer on. */
|
||||
interface ReadBodies {
|
||||
readonly body: JsonValue | undefined;
|
||||
/** The recording whose envelope the served answer keeps: an item view's envelope, a list line's latest init recording. */
|
||||
readonly base: JsonValue | undefined;
|
||||
readonly volatile: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export const checkItemRead = (
|
||||
state: SimulatedState,
|
||||
view: StateItemView,
|
||||
record: StateRecordFacts,
|
||||
check: {
|
||||
readonly body: JsonValue | undefined;
|
||||
readonly volatile: ReadonlySet<string>;
|
||||
},
|
||||
check: ReadBodies,
|
||||
): CheckOutcome | undefined => {
|
||||
const collection = collectionPathOf(record, true);
|
||||
const id = lastSegmentValue(record.path);
|
||||
@@ -122,103 +141,115 @@ export const checkItemRead = (
|
||||
const targets = [entityTarget(view.type, collection, id)];
|
||||
if (isAbsence(record.status) || (succeeded(record) && check.body === null))
|
||||
return { pass: entity === undefined, targets, unheld: [] };
|
||||
if (record.status === null || record.status < 200 || record.status >= 300)
|
||||
return undefined;
|
||||
if (!succeeded(record) || check.base === undefined) return undefined;
|
||||
const object =
|
||||
check.body === undefined ? undefined : at(check.body, view.pointer);
|
||||
if (!isObject(object)) return undefined;
|
||||
if (entity === undefined) return { pass: false, targets, unheld: targets };
|
||||
const shown = renderStateEntity(entity, view, check.volatile, object);
|
||||
return {
|
||||
pass: same(renderStateEntity(entity, view, check.volatile, object), object),
|
||||
pass: same(replaceAt(check.base, view.pointer, shown), check.body),
|
||||
targets,
|
||||
unheld: [],
|
||||
};
|
||||
};
|
||||
|
||||
/** A list read's bodies with the listed items of the read and of its served base. */
|
||||
interface ListBodies {
|
||||
readonly body: JsonValue;
|
||||
readonly base: JsonValue;
|
||||
readonly items: readonly JsonObject[];
|
||||
readonly baseItems: readonly JsonObject[];
|
||||
readonly idField: string;
|
||||
readonly volatile: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/** The served list rendered from the base with `members`, compared with the read; volatile fields take the read's values. */
|
||||
const listCheck = (
|
||||
view: StateListView,
|
||||
check: ListBodies,
|
||||
members: readonly StateEntity[],
|
||||
): boolean => {
|
||||
const observed = new Map(
|
||||
check.items.map((item) => [scalarText(item[check.idField]) ?? "", item]),
|
||||
);
|
||||
const rendered = members.map((entity) =>
|
||||
renderStateEntity(entity, view, check.volatile, observed.get(entity.id)),
|
||||
);
|
||||
return same(renderStateList(view, check.base, rendered), check.body);
|
||||
};
|
||||
|
||||
/** A list read of every entity of a collection: membership, order, fields and counters must render from the state. */
|
||||
const checkAllList = (
|
||||
state: SimulatedState,
|
||||
view: StateListView,
|
||||
record: StateRecordFacts,
|
||||
check: {
|
||||
readonly body: JsonValue;
|
||||
readonly items: readonly JsonObject[];
|
||||
readonly idField: string;
|
||||
readonly volatile: ReadonlySet<string>;
|
||||
},
|
||||
check: ListBodies,
|
||||
): CheckOutcome => {
|
||||
const collection = collectionPathOf(record, false);
|
||||
const members = state.members(view.type, collection);
|
||||
const observed = new Map(
|
||||
check.items.map((item) => [scalarText(item[check.idField]) ?? "", item]),
|
||||
);
|
||||
const rendered = members.map((entity) =>
|
||||
renderStateEntity(entity, view, check.volatile, observed.get(entity.id)),
|
||||
);
|
||||
const held = new Set(members.map((entity) => entity.id));
|
||||
return {
|
||||
pass: same(renderStateList(view, check.body, rendered), check.body),
|
||||
pass: listCheck(view, check, members),
|
||||
targets: [
|
||||
collectionTarget(view.type, collection),
|
||||
...members.map((entity) =>
|
||||
entityTarget(view.type, collection, entity.id),
|
||||
),
|
||||
],
|
||||
unheld: [...observed.keys()]
|
||||
unheld: check.items
|
||||
.map((item) => scalarText(item[check.idField]) ?? "")
|
||||
.filter((id) => !held.has(id))
|
||||
.map((id) => entityTarget(view.type, collection, id)),
|
||||
};
|
||||
};
|
||||
|
||||
/** A list read of recorded members: every listed entity the state holds must render; others are not compared. */
|
||||
/** A list read of recorded members: the served list holds the base's members the state still holds, so a member it dropped or never listed fails. */
|
||||
const checkRecordedList = (
|
||||
state: SimulatedState,
|
||||
view: StateListView,
|
||||
record: StateRecordFacts,
|
||||
check: {
|
||||
readonly items: readonly JsonObject[];
|
||||
readonly idField: string;
|
||||
readonly volatile: ReadonlySet<string>;
|
||||
},
|
||||
check: ListBodies,
|
||||
): CheckOutcome => {
|
||||
const collection = collectionPathOf(record, false);
|
||||
const idOf = (item: JsonObject) => scalarText(item[check.idField]) ?? "";
|
||||
const members = check.baseItems.flatMap((item) => {
|
||||
const entity = state.entity(view.type, collection, idOf(item));
|
||||
return entity === undefined ? [] : [entity];
|
||||
});
|
||||
const targets: string[] = [];
|
||||
const unheld: string[] = [];
|
||||
let pass = true;
|
||||
for (const item of check.items) {
|
||||
const id = scalarText(item[check.idField]) ?? "";
|
||||
const target = entityTarget(view.type, collection, id);
|
||||
const entity = state.entity(view.type, collection, id);
|
||||
if (entity === undefined) {
|
||||
unheld.push(target);
|
||||
continue;
|
||||
}
|
||||
targets.push(target);
|
||||
if (!same(renderStateEntity(entity, view, check.volatile, item), item))
|
||||
pass = false;
|
||||
}
|
||||
return { pass, targets, unheld };
|
||||
for (const id of new Set([...check.baseItems, ...check.items].map(idOf)))
|
||||
(state.entity(view.type, collection, id) === undefined
|
||||
? unheld
|
||||
: targets
|
||||
).push(entityTarget(view.type, collection, id));
|
||||
return { pass: listCheck(view, check, members), targets, unheld };
|
||||
};
|
||||
|
||||
/** A list read on a checked line, compared as the served environment answers it. */
|
||||
/** A list read on a checked line, compared with the answer the served environment renders on that line's base recording. */
|
||||
export const checkListRead = (
|
||||
state: SimulatedState,
|
||||
view: StateListView,
|
||||
record: StateRecordFacts,
|
||||
check: {
|
||||
readonly body: JsonValue | undefined;
|
||||
readonly idField: string;
|
||||
readonly volatile: ReadonlySet<string>;
|
||||
},
|
||||
check: ReadBodies & { readonly idField: string },
|
||||
): CheckOutcome | undefined => {
|
||||
if (record.status === null || record.status < 200 || record.status >= 300)
|
||||
if (!succeeded(record)) return undefined;
|
||||
const { body, base } = check;
|
||||
const items = body === undefined ? undefined : listItems(body, view.pointer);
|
||||
const baseItems =
|
||||
base === undefined ? undefined : listItems(base, view.pointer);
|
||||
if (
|
||||
body === undefined ||
|
||||
base === undefined ||
|
||||
items === undefined ||
|
||||
baseItems === undefined
|
||||
)
|
||||
return undefined;
|
||||
const items =
|
||||
check.body === undefined ? undefined : listItems(check.body, view.pointer);
|
||||
if (check.body === undefined || items === undefined) return undefined;
|
||||
const bodies = { ...check, body, base, items, baseItems };
|
||||
return view.membership === "all"
|
||||
? checkAllList(state, view, record, { ...check, body: check.body, items })
|
||||
: checkRecordedList(state, view, record, { ...check, items });
|
||||
? checkAllList(state, view, record, bodies)
|
||||
: checkRecordedList(state, view, record, bodies);
|
||||
};
|
||||
|
||||
/** A document read on its checked line must equal the held document. */
|
||||
@@ -228,8 +259,7 @@ export const checkDocumentRead = (
|
||||
record: StateRecordFacts,
|
||||
body: JsonValue | undefined,
|
||||
): CheckOutcome | undefined => {
|
||||
if (record.status === null || record.status < 200 || record.status >= 300)
|
||||
return undefined;
|
||||
if (!succeeded(record)) return undefined;
|
||||
const value = state.document(tool);
|
||||
if (body === undefined || value === undefined) return undefined;
|
||||
return {
|
||||
|
||||
@@ -322,9 +322,10 @@ export const applyUpdate = (
|
||||
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(
|
||||
draft.plan,
|
||||
{ before: entity.fields, observed: after.object },
|
||||
{ before: entity.fields, observed: draft.observed! },
|
||||
{ ...call, id },
|
||||
(rule) => after.object[rule.field] ?? null,
|
||||
);
|
||||
|
||||
@@ -266,7 +266,7 @@ it("explains an update's sent members as echoes, constants or the identity", ()
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{ field: "title", rule: "echo" },
|
||||
{ field: "status", rule: "constant" },
|
||||
{ field: "status", rule: "constant", value: "closed" },
|
||||
],
|
||||
body: {
|
||||
accepted: ["title"],
|
||||
@@ -335,7 +335,9 @@ it("leaves a slug of a transformed member, an untimed clock and a jump unexplain
|
||||
{ startMs: undefined },
|
||||
),
|
||||
);
|
||||
expect(result.rules).toEqual([{ field: "title", rule: "constant" }]);
|
||||
expect(result.rules).toEqual([
|
||||
{ field: "title", rule: "constant", value: "new title" },
|
||||
]);
|
||||
expect(result.unexplained).toEqual(["slug", "at", "n"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -287,7 +287,12 @@ export const explainUpdate = (
|
||||
if (same(evidence.after[member], sent)) {
|
||||
echoed.add(member);
|
||||
rules.push({ field: member, rule: "echo" });
|
||||
} else rules.push({ field: member, rule: "constant" });
|
||||
} else
|
||||
rules.push({
|
||||
field: member,
|
||||
rule: "constant",
|
||||
value: evidence.after[member]!,
|
||||
});
|
||||
}
|
||||
const unexplained: string[] = [];
|
||||
for (const field of Object.keys(evidence.after)) {
|
||||
|
||||
@@ -50,7 +50,12 @@ export type FieldRule =
|
||||
| { readonly field: string; readonly rule: "slug"; readonly from: string }
|
||||
| { readonly field: string; readonly rule: "template"; readonly from: string }
|
||||
| { readonly field: string; readonly rule: "increment" }
|
||||
| { readonly field: string; readonly rule: "constant" };
|
||||
/** An update stores `value` whenever the call sends the member, as the call that showed it did. */
|
||||
| {
|
||||
readonly field: string;
|
||||
readonly rule: "constant";
|
||||
readonly value: JsonValue;
|
||||
};
|
||||
|
||||
/** Where a create places its entity in its collection's order. */
|
||||
export type InsertRule =
|
||||
|
||||
Reference in new issue
Block a user