mirror of
https://github.com/morluto/rea.git
synced 2026-10-29 10:55:57 +00:00
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>
253 lines
9.0 KiB
TypeScript
253 lines
9.0 KiB
TypeScript
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,
|
|
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] });
|
|
});
|
|
|
|
/** 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", () => {
|
|
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,
|
|
});
|
|
});
|
|
});
|
|
|
|
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],
|
|
});
|
|
});
|
|
});
|