mirror of
https://github.com/morluto/rea.git
synced 2026-11-03 21:36:13 +00:00
Save typed response fields and reuse generated IDs or authentication values across browser scenario requests, UI observations and navigation through the shared CLI/MCP workflow.
Preserve exact numbers, retained Evidence, explicit source/coverage limits and prospective page-value semantics. Bound response decoding, retained spellings and request serialization before allocation, and protect marked response values across source records, follow-up text and planned screenshots.
Integrates Edgar Arakelyan's #7162, including follow-ups through 9f5191ab0. Final local validation: 560 feature-branch tests and 561 current-main integration tests, static/build/docs checks and real PocketBase CLI/MCP privacy workflows. Whole-task comparison and verification limits are recorded in docs/verification/browser-saved-values-2026-10-26.md.
Co-authored-by: Edgar Arakelyan <edgar.arakelyan@scale.com>
3301 lines
112 KiB
TypeScript
3301 lines
112 KiB
TypeScript
import { StateRecordChronology } from "./StateRecordChronology.js";
|
|
import { OmittedFields } from "./environmentStateWriteOmitted.js";
|
|
import { checkedLines, servedLine } from "./environmentStateCheckedLines.js";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
timedCaptureEvidence,
|
|
analysisViewCaptureEvidence,
|
|
environmentManifestCaptureFixture,
|
|
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 type { JsonValue } from "../jsonValue.js";
|
|
import { ok } from "../result.js";
|
|
import {
|
|
checkItemRead,
|
|
itemTarget,
|
|
SimulatedState,
|
|
} from "./environmentStateWriteChecks.js";
|
|
import { UnseenEntities } from "./environmentStateWriteAdoption.js";
|
|
import type { StateItemView } from "./environmentStateRules.js";
|
|
import {
|
|
laterObservation,
|
|
type ObservationSearch,
|
|
} from "./environmentStateWriteObserve.js";
|
|
import { updatedFields } from "./environmentStateWriteApply.js";
|
|
import {
|
|
retainFixed,
|
|
retainCreateExemplar,
|
|
type EffectContext,
|
|
} from "./environmentStateWriteEffects.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);
|
|
|
|
/** A book whose constant names organisation 1, created at exchange `seconds`. */
|
|
const tiedToOrgOne = (id: number, name: string, seconds: number): Book => ({
|
|
...book(id, name, bookClock(seconds)),
|
|
org: "/orgs/1",
|
|
});
|
|
|
|
/**
|
|
* Two organisations, a create tied to the first by a constant that names it, then the same constant
|
|
* recorded under the second. `confirmOwn` also reads the created book back from the first.
|
|
*/
|
|
const tiedCreateRows = (confirmOwn: boolean): TimedCaptureRow[] => {
|
|
const alpha = { ...book(1, "Alpha", "2026-10-01T09:00:00Z"), org: "/orgs/1" };
|
|
const beta = { ...book(10, "Beta", "2026-10-01T10:00:00Z"), org: "/orgs/2" };
|
|
const made = tiedToOrgOne(2, "Made", 4);
|
|
const other = tiedToOrgOne(11, "Other", confirmOwn ? 6 : 5);
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/orgs/1/books", [alpha]],
|
|
["GET", "/api/orgs/2/books", [beta]],
|
|
["GET", "/api/orgs/1/books/1", alpha],
|
|
["GET", "/api/orgs/2/books/10", beta],
|
|
["POST", "/api/orgs/1/books", made, 201, { name: "Made" }],
|
|
];
|
|
if (confirmOwn) rows.push(["GET", "/api/orgs/1/books/2", made]);
|
|
rows.push(
|
|
["POST", "/api/orgs/2/books", other, 201, { name: "Other" }],
|
|
["GET", "/api/orgs/2/books/11", other],
|
|
);
|
|
return rows;
|
|
};
|
|
|
|
/** A book whose constant names organisation `acme1`, created at exchange `seconds`. */
|
|
const tiedToAcme = (id: number, name: string, seconds: number): Book => ({
|
|
...book(id, name, bookClock(seconds)),
|
|
team: "/orgs/uid/acme1",
|
|
});
|
|
|
|
/** The create plan the gate gives a capture of `rows` when the segment after `uid` is a parameter. */
|
|
const acmeCreate = (rows: readonly TimedCaptureRow[]) => {
|
|
const { writes } = environmentStateViewOf(timedCaptureEvidence(rows), [
|
|
"after-uid-literal",
|
|
]);
|
|
const plan = writes.find((candidate) => candidate.effect === "create");
|
|
if (plan === undefined) throw new Error("The gate planned no create");
|
|
return plan;
|
|
};
|
|
|
|
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",
|
|
},
|
|
() => ok(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("slug rules learned from a later call", () => {
|
|
/** A patch of the first book sending `name`, answered with `answered`, then a rename of the second. */
|
|
const renames = (name: string, answered: Book): TimedCaptureRow[] => {
|
|
const second = renamedBook(SECOND_BOOK, "Second Edition", 5);
|
|
return [
|
|
...BOOK_INIT_ROWS,
|
|
["PATCH", "/api/books/1", answered, 200, { name }],
|
|
["GET", "/api/books/1", answered],
|
|
["PATCH", "/api/books/2", second, 200, { name: "Second Edition" }],
|
|
["GET", "/api/books/2", second],
|
|
];
|
|
};
|
|
|
|
it("serves a slug rule an earlier call agrees with, and not one it contradicts", () => {
|
|
const kept = { ...FIRST_BOOK, updated_at: bookClock(3) };
|
|
expect(
|
|
gated(renames("First Book", kept), "patch_books_by_id"),
|
|
).toMatchObject({
|
|
served: true,
|
|
rules: expect.arrayContaining([
|
|
{ field: "slug", rule: "slug", from: "name" },
|
|
]),
|
|
});
|
|
// The application renamed the first book without following its slug.
|
|
expect(
|
|
gated(
|
|
renames("Renamed Book", { ...kept, name: "Renamed Book" }),
|
|
"patch_books_by_id",
|
|
),
|
|
).toMatchObject({ served: false, reason: "unexplained-change" });
|
|
});
|
|
});
|
|
|
|
describe("clock rules learned across calls", () => {
|
|
it("serves a clock only when every recorded call changed that field", () => {
|
|
const oldFirst = "2026-10-01T09:00:00Z";
|
|
const oldSecond = "2026-10-01T10:00:00Z";
|
|
const described = (
|
|
entry: Book,
|
|
description: string,
|
|
publishedAt: string,
|
|
): Book => ({ ...entry, description, published_at: publishedAt });
|
|
const first = described(FIRST_BOOK, "original", oldFirst);
|
|
const second = described(SECOND_BOOK, "other", oldSecond);
|
|
const published = described(
|
|
renamedBook(FIRST_BOOK, "Renamed Book", 3),
|
|
"original",
|
|
bookClock(3),
|
|
);
|
|
/** The first call stamps `published_at`; `later` is the second call's answer. */
|
|
const rows = (later: Book, sent: Book): TimedCaptureRow[] => [
|
|
["GET", "/api/books", [first, second]],
|
|
["GET", "/api/books/1", first],
|
|
["GET", "/api/books/2", second],
|
|
["PATCH", "/api/books/1", published, 200, { name: "Renamed Book" }],
|
|
["GET", "/api/books/1", published],
|
|
["PATCH", "/api/books/2", later, 200, sent],
|
|
["GET", "/api/books/2", later],
|
|
];
|
|
// The description edit stamps `updated_at` and leaves `published_at` as it was.
|
|
const edited = described(
|
|
{ ...SECOND_BOOK, updated_at: bookClock(5) },
|
|
"revised",
|
|
oldSecond,
|
|
);
|
|
expect(
|
|
gated(rows(edited, { description: "revised" }), "patch_books_by_id"),
|
|
).toMatchObject({ served: false, reason: "unexplained-change" });
|
|
const stamped = described(
|
|
renamedBook(SECOND_BOOK, "Second Edition", 5),
|
|
"other",
|
|
bookClock(5),
|
|
);
|
|
const plan = gated(
|
|
rows(stamped, { name: "Second Edition" }),
|
|
"patch_books_by_id",
|
|
);
|
|
expect(plan).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [4, 6],
|
|
});
|
|
expect(plan.rules).toContainEqual({
|
|
field: "published_at",
|
|
rule: "clock",
|
|
format: { kind: "iso", separator: "T", fraction: 0, zone: "Z" },
|
|
});
|
|
});
|
|
|
|
it("leaves a clock unserved when an earlier call kept the field a later call stamped", () => {
|
|
const oldFirst = "2026-10-01T09:00:00Z";
|
|
const oldSecond = "2026-10-01T10:00:00Z";
|
|
const described = (
|
|
entry: Book,
|
|
description: string,
|
|
publishedAt: string,
|
|
): Book => ({ ...entry, description, published_at: publishedAt });
|
|
const first = described(FIRST_BOOK, "original", oldFirst);
|
|
const second = described(SECOND_BOOK, "other", oldSecond);
|
|
const edited = described(
|
|
{ ...SECOND_BOOK, updated_at: bookClock(3) },
|
|
"revised",
|
|
oldSecond,
|
|
);
|
|
const published = described(
|
|
renamedBook(FIRST_BOOK, "Renamed Book", 5),
|
|
"original",
|
|
bookClock(5),
|
|
);
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books", [first, second]],
|
|
["GET", "/api/books/1", first],
|
|
["GET", "/api/books/2", second],
|
|
["PATCH", "/api/books/2", edited, 200, { description: "revised" }],
|
|
["GET", "/api/books/2", edited],
|
|
["PATCH", "/api/books/1", published, 200, { name: "Renamed Book" }],
|
|
["GET", "/api/books/1", published],
|
|
];
|
|
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
|
served: false,
|
|
reason: "unexplained-change",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("checked lines", () => {
|
|
/** A list page that echoes its own request path, as some APIs do. */
|
|
const page = (path: string, items: JsonValue[]): JsonValue => ({
|
|
path,
|
|
items,
|
|
});
|
|
const PLAIN = "/api/orgs/acme1/books";
|
|
const ENCODED = "/api/orgs/%61cme1/books";
|
|
const OTHER = "/api/orgs/beta2/books";
|
|
/** Three organisations' books under one templated tool, two spelling one organisation differently, then deletes each confirmed by its list. */
|
|
const ROWS: TimedCaptureRow[] = [
|
|
["GET", PLAIN, page(PLAIN, [FIRST_BOOK])],
|
|
["GET", ENCODED, page(ENCODED, [SECOND_BOOK])],
|
|
["GET", OTHER, page(OTHER, [THIRD_BOOK])],
|
|
["GET", `${PLAIN}/1`, FIRST_BOOK],
|
|
["GET", `${ENCODED}/2`, SECOND_BOOK],
|
|
["GET", `${OTHER}/3`, THIRD_BOOK],
|
|
["DELETE", `${ENCODED}/2`, "", 204],
|
|
["GET", ENCODED, page(ENCODED, [])],
|
|
["DELETE", `${OTHER}/3`, "", 204],
|
|
["GET", OTHER, page(OTHER, [])],
|
|
];
|
|
|
|
it("keeps a line's path spelling, as replay prefers the verbatim one", () => {
|
|
const view = environmentStateViewOf(timedCaptureEvidence(ROWS), [
|
|
"sibling-variance",
|
|
]);
|
|
expect(view.views).toContainEqual(
|
|
expect.objectContaining({
|
|
shape: "list",
|
|
checked_record_ordinals: [0, 1, 2],
|
|
}),
|
|
);
|
|
expect(
|
|
view.writes.find((candidate) => candidate.effect === "delete"),
|
|
).toMatchObject({ served: true, checked_record_ordinals: [7, 9] });
|
|
});
|
|
/** Organisations listed only under encoded or other spellings, a delete in each, then a list of the first spelled plainly. */
|
|
const deletedThenPlain = (listed: readonly Book[]): TimedCaptureRow[] => {
|
|
const third = "/api/orgs/gamma3/books";
|
|
const fourth = book(4, "Fourth Book", "2026-10-01T11:00:00Z");
|
|
return [
|
|
["GET", ENCODED, [SECOND_BOOK]],
|
|
["GET", OTHER, [THIRD_BOOK]],
|
|
["GET", third, [fourth]],
|
|
["GET", `${ENCODED}/2`, SECOND_BOOK],
|
|
["GET", `${OTHER}/3`, THIRD_BOOK],
|
|
["DELETE", `${ENCODED}/2`, "", 204],
|
|
["GET", `${ENCODED}/2`, BOOK_NOT_FOUND, 404],
|
|
["DELETE", `${OTHER}/3`, "", 204],
|
|
["GET", `${OTHER}/3`, BOOK_NOT_FOUND, 404],
|
|
["GET", PLAIN, [...listed]],
|
|
];
|
|
};
|
|
|
|
it("checks a read spelled plainly against the encoded line serving answers it from", () => {
|
|
const deleteOf = (listed: readonly Book[]) =>
|
|
environmentStateViewOf(timedCaptureEvidence(deletedThenPlain(listed)), [
|
|
"sibling-variance",
|
|
]).writes.find((candidate) => candidate.effect === "delete");
|
|
// Serving lists the encoded collection for the plain argument, which no longer holds the book.
|
|
expect(deleteOf([SECOND_BOOK])).toMatchObject({
|
|
served: false,
|
|
reason: "contradicted",
|
|
contradicted_by: 9,
|
|
});
|
|
expect(deleteOf([])).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [6, 8, 9],
|
|
});
|
|
});
|
|
|
|
const TWICE = "/api/orgs/%61%63me1/books";
|
|
const shelved = (entry: Book, shelf: string): Book => ({ ...entry, shelf });
|
|
const ENCODED_LINE: TimedCaptureRow = [
|
|
"GET",
|
|
ENCODED,
|
|
[shelved(FIRST_BOOK, "a"), shelved(SECOND_BOOK, "b")],
|
|
];
|
|
const TWICE_LINE: TimedCaptureRow = [
|
|
"GET",
|
|
TWICE,
|
|
[shelved(THIRD_BOOK, "a"), shelved(RENAMED, "b")],
|
|
];
|
|
const SHELF_LINE: TimedCaptureRow = [
|
|
"GET",
|
|
`${ENCODED}?shelf=a`,
|
|
[shelved(FIRST_BOOK, "a")],
|
|
];
|
|
/** Lines of one organisation spelled two encoded ways, deletes each whole list confirms, then a plain read of new query values still listing the deleted book. */
|
|
const deletedThenQueried = (
|
|
lines: readonly TimedCaptureRow[],
|
|
read: string,
|
|
): TimedCaptureRow[] => [
|
|
...lines,
|
|
["GET", OTHER, [shelved(SECOND_BOOK, "a")]],
|
|
["DELETE", `${TWICE}/1`, "", 204],
|
|
["DELETE", `${OTHER}/2`, "", 204],
|
|
["GET", TWICE, [shelved(THIRD_BOOK, "a")]],
|
|
["GET", OTHER, []],
|
|
["GET", read, [shelved(RENAMED, "b")]],
|
|
];
|
|
const deleteOf = (rows: readonly TimedCaptureRow[]) =>
|
|
environmentStateViewOf(timedCaptureEvidence(rows), [
|
|
"sibling-variance",
|
|
]).writes.find((candidate) => candidate.effect === "delete");
|
|
|
|
it("checks a read on new query values against the line serving ranks first by the values it shares", () => {
|
|
const rows = deletedThenQueried(
|
|
[
|
|
ENCODED_LINE,
|
|
TWICE_LINE,
|
|
SHELF_LINE,
|
|
["GET", `${TWICE}?shelf=b`, [shelved(RENAMED, "b")]],
|
|
["GET", `${ENCODED}?limit=1`, [shelved(FIRST_BOOK, "a")]],
|
|
],
|
|
`${PLAIN}?shelf=b&limit=5`,
|
|
);
|
|
// Serving lists the twice-encoded collection, whose `shelf=b` line the read shares a value with.
|
|
expect(deleteOf(rows)).toMatchObject({
|
|
served: false,
|
|
contradicted_by: 10,
|
|
});
|
|
});
|
|
|
|
it("checks a read on new query values against the line serving takes first in record order", () => {
|
|
const rows = deletedThenQueried(
|
|
[TWICE_LINE, ENCODED_LINE, SHELF_LINE],
|
|
`${PLAIN}?shelf=b`,
|
|
);
|
|
// No line shares a value, so serving lists the collection of the line recorded first.
|
|
expect(deleteOf(rows)).toMatchObject({
|
|
served: false,
|
|
contradicted_by: 8,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("insert rules", () => {
|
|
it.each([false, true])(
|
|
"uses the successful retry's page coverage, keeping an unseen update pending (later read: %s)",
|
|
(laterRead) => {
|
|
const beta = { id: 1, name: "Beta", note: "first" };
|
|
const zulu = { id: 2, name: "Zulu", note: "original" };
|
|
const patched = { ...zulu, note: "updated" };
|
|
const alpha = { id: 3, name: "Alpha", note: "created" };
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books?limit=5", [beta, zulu]],
|
|
["GET", "/api/books?limit=1", [beta]],
|
|
["GET", "/api/books/1", beta],
|
|
["GET", "/api/books/2", zulu],
|
|
["PATCH", "/api/books/2", patched, 200, { note: "updated" }],
|
|
["POST", "/api/books", alpha, 201, { name: "Alpha", note: "created" }],
|
|
// The initial page includes Zulu. Retrying the create in name order
|
|
// replaces it with Alpha, so the passing page proves nothing of Zulu.
|
|
["GET", "/api/books?limit=2", [alpha, beta]],
|
|
];
|
|
if (laterRead) rows.push(["GET", "/api/books/2", patched]);
|
|
expect(gated(rows, "post_books")).toMatchObject({
|
|
served: true,
|
|
insert: { rule: "sorted", field: "name", direction: "ascending" },
|
|
checked_record_ordinals: [6],
|
|
});
|
|
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
|
served: laterRead,
|
|
...(laterRead ? {} : { reason: "unconfirmed" }),
|
|
checked_record_ordinals: laterRead ? [7] : [],
|
|
});
|
|
},
|
|
);
|
|
|
|
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],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("reads the served answer no longer matches", () => {
|
|
it("contradicts an update when a later read's body no longer holds the entity", () => {
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK]],
|
|
["GET", "/api/books/1", { data: FIRST_BOOK }],
|
|
["GET", "/api/books/2", { data: SECOND_BOOK }],
|
|
["PATCH", "/api/books/1", RENAMED, 200, { name: "Renamed Book" }],
|
|
["GET", "/api/books/1", { data: null }],
|
|
["GET", "/api/books/1", { data: RENAMED }],
|
|
];
|
|
// Serving still answers the renamed book, so the read that shows none is a check it fails, not one it skips.
|
|
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
|
served: false,
|
|
reason: "contradicted",
|
|
contradicted_by: 4,
|
|
});
|
|
});
|
|
|
|
it("confirms no create by the absence of the entity a later delete removed", () => {
|
|
const rows: TimedCaptureRow[] = [
|
|
...BOOK_INIT_ROWS,
|
|
["POST", "/api/books", THIRD_BOOK, 201, { name: "Third Book" }],
|
|
["DELETE", "/api/books/3", "", 204],
|
|
["GET", "/api/books/3", BOOK_NOT_FOUND, 404],
|
|
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK]],
|
|
];
|
|
expect(gated(rows, "post_books")).toMatchObject({
|
|
served: false,
|
|
reason: "unconfirmed",
|
|
checked_record_ordinals: [],
|
|
});
|
|
expect(gated(rows, "delete_books_by_id")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [5, 6],
|
|
});
|
|
});
|
|
|
|
it("confirms a create that lacks a field every item recording carried only when its own read leaves the field out too", () => {
|
|
const detailed = (entry: Book): Book => ({ ...entry, detail: "x" });
|
|
const rows = (readBack: Book): TimedCaptureRow[] => [
|
|
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK]],
|
|
["GET", "/api/books/1", detailed(FIRST_BOOK)],
|
|
["GET", "/api/books/2", detailed(SECOND_BOOK)],
|
|
["POST", "/api/books", THIRD_BOOK, 201, { name: "Third Book" }],
|
|
["GET", "/api/books/3", readBack],
|
|
];
|
|
// The application omits the field for this book, so serving need not invent it.
|
|
expect(gated(rows(THIRD_BOOK), "post_books")).toMatchObject({
|
|
served: true,
|
|
});
|
|
// The read shows the field the create's own answer left out: the state cannot render it.
|
|
expect(gated(rows(detailed(THIRD_BOOK)), "post_books")).toMatchObject({
|
|
served: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("updates whose effect later writes erase", () => {
|
|
const versioned = (entry: Book, version: number): Book => ({
|
|
...entry,
|
|
version,
|
|
});
|
|
|
|
it("keeps an update confirmed when a later one rewrites only part of what it changed", () => {
|
|
const again = versioned(renamedBook(FIRST_BOOK, "Again Book", 4), 2);
|
|
const rows: TimedCaptureRow[] = [
|
|
[
|
|
"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",
|
|
versioned(RENAMED, 2),
|
|
200,
|
|
{ name: "Renamed Book" },
|
|
],
|
|
["PUT", "/api/books/1", again, 200, { name: "Again Book" }],
|
|
["GET", "/api/books/1", again],
|
|
];
|
|
// The PUT keeps the version the PATCH incremented, so the read still shows the PATCH's effect.
|
|
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [5],
|
|
});
|
|
});
|
|
|
|
it("confirms no update whose changes later writes overwrote between them", () => {
|
|
const renamed = versioned(renamedBook(FIRST_BOOK, "Again Book", 4), 2);
|
|
const final = versioned(renamedBook(FIRST_BOOK, "Again Book", 5), 9);
|
|
const rows: TimedCaptureRow[] = [
|
|
[
|
|
"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",
|
|
versioned(RENAMED, 2),
|
|
200,
|
|
{ name: "Renamed Book" },
|
|
],
|
|
["PUT", "/api/books/1", renamed, 200, { name: "Again Book" }],
|
|
["POST", "/api/books/1", final, 200, { version: 9 }],
|
|
["GET", "/api/books/1", final],
|
|
];
|
|
// The PUT overwrote the name, slug and clock, the POST the version: nothing of the PATCH is left to read.
|
|
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
|
served: false,
|
|
checked_record_ordinals: [],
|
|
});
|
|
expect(gated(rows, "put_books_by_id")).toMatchObject({
|
|
checked_record_ordinals: [6],
|
|
});
|
|
});
|
|
|
|
it("confirms no update whose changes a recorded outcome replaced", () => {
|
|
const described = (
|
|
entry: Book,
|
|
name: string,
|
|
at: number,
|
|
text: string,
|
|
): Book => ({
|
|
...renamedBook(entry, name, at),
|
|
description: text,
|
|
});
|
|
const final = described(FIRST_BOOK, "Other Book", 7, "z");
|
|
const rows: TimedCaptureRow[] = [
|
|
[
|
|
"GET",
|
|
"/api/books",
|
|
[
|
|
{ ...FIRST_BOOK, description: "d" },
|
|
{ ...SECOND_BOOK, description: "d" },
|
|
],
|
|
],
|
|
["GET", "/api/books/1", { ...FIRST_BOOK, description: "d" }],
|
|
["GET", "/api/books/2", { ...SECOND_BOOK, description: "d" }],
|
|
[
|
|
"PUT",
|
|
"/api/books/2",
|
|
described(SECOND_BOOK, "Second Book", 3, "x"),
|
|
200,
|
|
{ description: "x" },
|
|
],
|
|
[
|
|
"PATCH",
|
|
"/api/books/1",
|
|
described(FIRST_BOOK, "Renamed Book", 4, "d"),
|
|
200,
|
|
{ name: "Renamed Book" },
|
|
],
|
|
// Without a body the PUT is not modelled: its recorded outcome becomes the book's values.
|
|
["PUT", "/api/books/1", described(FIRST_BOOK, "Other Book", 5, "d")],
|
|
[
|
|
"PATCH",
|
|
"/api/books/1",
|
|
described(FIRST_BOOK, "Other Book", 6, "y"),
|
|
200,
|
|
{ description: "y" },
|
|
],
|
|
["PUT", "/api/books/1", final, 200, { description: "z" }],
|
|
["GET", "/api/books/1", final],
|
|
];
|
|
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
|
served: false,
|
|
checked_record_ordinals: [],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("reads as serving answers them", () => {
|
|
it("checks a read with no query on the line serving answers it from, a whole list's paging line", () => {
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books?limit=50", [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" }],
|
|
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK, THIRD_BOOK]],
|
|
];
|
|
expect(gated(rows, "post_books")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [4],
|
|
});
|
|
});
|
|
|
|
it("checks nothing by a line whose query repeats a name, which no call can name", () => {
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books?tag=a&tag=b", [FIRST_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?tag=a&tag=b", [RENAMED]],
|
|
];
|
|
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
|
checked_record_ordinals: [],
|
|
});
|
|
});
|
|
|
|
it("checks nothing by a read made while a write to its targets was still running", () => {
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK]],
|
|
["GET", "/api/books/1", FIRST_BOOK],
|
|
["GET", "/api/books/2", SECOND_BOOK],
|
|
// The create runs from second 3 to 4.5; the list at second 4 still shows the books before it.
|
|
["POST", "/api/books", THIRD_BOOK, 201, { name: "Third Book" }, 1500],
|
|
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK]],
|
|
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK, THIRD_BOOK]],
|
|
];
|
|
expect(gated(rows, "post_books")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [5],
|
|
contradicted_by: null,
|
|
});
|
|
});
|
|
|
|
it("refuses an update a later call stores otherwise than the plan echoed", () => {
|
|
const formatted = (at: number, format: string): Book => ({
|
|
...renamedBook(FIRST_BOOK, "First Book", at),
|
|
format,
|
|
});
|
|
const rows: TimedCaptureRow[] = [
|
|
[
|
|
"GET",
|
|
"/api/books",
|
|
[
|
|
{ ...FIRST_BOOK, format: "EBOOK" },
|
|
{ ...SECOND_BOOK, format: "EBOOK" },
|
|
],
|
|
],
|
|
["GET", "/api/books/1", { ...FIRST_BOOK, format: "EBOOK" }],
|
|
["GET", "/api/books/2", { ...SECOND_BOOK, format: "EBOOK" }],
|
|
[
|
|
"PATCH",
|
|
"/api/books/1",
|
|
formatted(3, "PAPER"),
|
|
200,
|
|
{ format: "PAPER" },
|
|
],
|
|
["GET", "/api/books/1", formatted(3, "PAPER")],
|
|
[
|
|
"PATCH",
|
|
"/api/books/1",
|
|
formatted(5, "HARDCOVER"),
|
|
200,
|
|
{ format: "hardcover" },
|
|
],
|
|
["GET", "/api/books/1", formatted(5, "HARDCOVER")],
|
|
];
|
|
// One call echoed the format, the other stored another value: no one plan serves both.
|
|
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
|
served: false,
|
|
reason: "unexplained-change",
|
|
});
|
|
});
|
|
|
|
it("learns no update's effect from a read made while the update was still running", () => {
|
|
const full = (description: string) => ({
|
|
id: 1,
|
|
name: "First",
|
|
description,
|
|
});
|
|
const summary = [
|
|
{ id: 1, name: "First" },
|
|
{ id: 2, name: "Second" },
|
|
];
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books", summary],
|
|
["GET", "/api/books/1", full("Old desc")],
|
|
["GET", "/api/books/2", { id: 2, name: "Second", description: "Other" }],
|
|
// The update runs from second 3 to 4.5; the read at second 4 still shows the old description.
|
|
[
|
|
"PATCH",
|
|
"/api/books/1",
|
|
"",
|
|
204,
|
|
{ name: "First", description: "New desc" },
|
|
1500,
|
|
],
|
|
["GET", "/api/books/1", full("Old desc")],
|
|
["GET", "/api/books", summary],
|
|
];
|
|
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
|
served: false,
|
|
observation: null,
|
|
});
|
|
});
|
|
|
|
it("explains a field an update adds that the observing view had shown the entity without", () => {
|
|
const rows: TimedCaptureRow[] = [
|
|
[
|
|
"GET",
|
|
"/api/books",
|
|
[
|
|
{ id: 1, name: "A" },
|
|
{ id: 2, name: "B" },
|
|
],
|
|
],
|
|
["GET", "/api/books/1", { id: 1, name: "A" }],
|
|
["GET", "/api/books/2", { id: 2, name: "B", edited_at: bookClock(0) }],
|
|
["PATCH", "/api/books/1", "", 204, { name: "Z" }],
|
|
["GET", "/api/books/1", { id: 1, name: "Z", edited_at: bookClock(3) }],
|
|
[
|
|
"GET",
|
|
"/api/books",
|
|
[
|
|
{ id: 1, name: "Z" },
|
|
{ id: 2, name: "B" },
|
|
],
|
|
],
|
|
];
|
|
const plan = gated(rows, "patch_books_by_id");
|
|
expect(plan).toMatchObject({ served: true });
|
|
expect(plan.rules).toContainEqual(
|
|
expect.objectContaining({ field: "edited_at", rule: "clock" }),
|
|
);
|
|
});
|
|
});
|
|
|
|
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],
|
|
});
|
|
});
|
|
|
|
it("confirms a document write whose path encodes a parent the plain read holds", () => {
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/orgs/uid/acme1/settings", { theme: "light", lang: "en" }],
|
|
[
|
|
"PATCH",
|
|
"/api/orgs/uid/%61cme1/settings",
|
|
{ theme: "dark", lang: "en" },
|
|
200,
|
|
{ theme: "dark" },
|
|
],
|
|
["GET", "/api/orgs/uid/acme1/settings", { theme: "dark", lang: "en" }],
|
|
];
|
|
const { writes } = environmentStateViewOf(timedCaptureEvidence(rows), [
|
|
"after-uid-literal",
|
|
]);
|
|
expect(writes.find((plan) => plan.effect === "document")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [2],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("document writes later writes overwrite", () => {
|
|
it("confirms no document replacement a later merge overwrote", () => {
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/settings", { theme: "light", lang: "en" }],
|
|
[
|
|
"PUT",
|
|
"/api/settings",
|
|
{ theme: "dark", lang: "fr" },
|
|
200,
|
|
{ theme: "dark", lang: "fr" },
|
|
],
|
|
[
|
|
"PATCH",
|
|
"/api/settings",
|
|
{ theme: "blue", lang: "de" },
|
|
200,
|
|
{ theme: "blue", lang: "de" },
|
|
],
|
|
["GET", "/api/settings", { theme: "blue", lang: "de" }],
|
|
];
|
|
expect(gated(rows, "put_settings")).toMatchObject({
|
|
served: false,
|
|
checked_record_ordinals: [],
|
|
});
|
|
expect(gated(rows, "patch_settings")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [3],
|
|
});
|
|
});
|
|
|
|
it("confirms no document merge a later replacement of its object overwrote", () => {
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/settings", { prefs: { lang: "en" }, theme: "light" }],
|
|
[
|
|
"PATCH",
|
|
"/api/settings",
|
|
{ prefs: { lang: "fr" }, theme: "light" },
|
|
200,
|
|
{ prefs: { lang: "fr" } },
|
|
],
|
|
// The replacement holds `lang`, so it replaces all of `prefs`, which the merge wrote.
|
|
[
|
|
"PUT",
|
|
"/api/settings",
|
|
{ prefs: { lang: "de" }, theme: "light" },
|
|
200,
|
|
{ lang: "de" },
|
|
],
|
|
["GET", "/api/settings", { prefs: { lang: "de" }, theme: "light" }],
|
|
];
|
|
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("confirms a delete by a plain absence of an entity held under the encoded collection", () => {
|
|
const alpha = orgBook(1, "acme1", "Alpha");
|
|
const beta = orgBook(2, "beta2", "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],
|
|
["DELETE", "/api/orgs/acme1/books/1", "", 204],
|
|
["GET", "/api/orgs/acme1/books/1", BOOK_NOT_FOUND, 404],
|
|
["DELETE", "/api/orgs/beta2/books/2", "", 204],
|
|
["GET", "/api/orgs/beta2/books/2", BOOK_NOT_FOUND, 404],
|
|
];
|
|
const { writes } = environmentStateViewOf(timedCaptureEvidence(rows), [
|
|
"sibling-variance",
|
|
]);
|
|
expect(writes.find((plan) => plan.effect === "delete")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: expect.arrayContaining([5]),
|
|
});
|
|
});
|
|
|
|
it("learns a plain-path update from a later read under the encoded collection", () => {
|
|
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/acme1/books/1", "", 204, { name: "Renamed" }],
|
|
["GET", "/api/orgs/%61cme1/books/1", renamed],
|
|
["GET", "/api/orgs/%61cme1/books/1", renamed],
|
|
[
|
|
"PATCH",
|
|
"/api/orgs/beta2/books/2",
|
|
renamedBeta,
|
|
200,
|
|
{ name: "Renamed Beta" },
|
|
],
|
|
["GET", "/api/orgs/beta2/books/2", renamedBeta],
|
|
];
|
|
const { writes } = environmentStateViewOf(timedCaptureEvidence(rows), [
|
|
"sibling-variance",
|
|
]);
|
|
expect(writes.find((plan) => plan.effect === "update")).toMatchObject({
|
|
served: true,
|
|
observation: { ordinal: 5 },
|
|
checked_record_ordinals: expect.arrayContaining([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,
|
|
});
|
|
});
|
|
|
|
it("ties a create named plainly to the encoded collection serving creates it in", () => {
|
|
const linked = (id: number, org: string, name: string) => ({
|
|
id,
|
|
name,
|
|
href: `/orgs/${org}/books/${String(id)}`,
|
|
});
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/orgs/%61cme1/books", [linked(1, "acme1", "Alpha")]],
|
|
["GET", "/api/orgs/beta2/books", [linked(2, "beta2", "Beta")]],
|
|
["GET", "/api/orgs/%61cme1/books/1", linked(1, "acme1", "Alpha")],
|
|
["GET", "/api/orgs/beta2/books/2", linked(2, "beta2", "Beta")],
|
|
[
|
|
"POST",
|
|
"/api/orgs/acme1/books",
|
|
linked(3, "acme1", "Gamma"),
|
|
201,
|
|
{ name: "Gamma" },
|
|
],
|
|
["GET", "/api/orgs/acme1/books/3", linked(3, "acme1", "Gamma")],
|
|
[
|
|
"POST",
|
|
"/api/orgs/beta2/books",
|
|
linked(4, "beta2", "Delta"),
|
|
201,
|
|
{ name: "Delta" },
|
|
],
|
|
];
|
|
const { writes } = environmentStateViewOf(timedCaptureEvidence(rows), [
|
|
"sibling-variance",
|
|
]);
|
|
expect(writes.find((plan) => plan.effect === "create")).toMatchObject({
|
|
served: true,
|
|
collection: "/api/orgs/%61cme1/books",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("encoded parent spellings", () => {
|
|
const type = "/api/orgs/uid/{uid}/books";
|
|
const collection = "/api/orgs/uid/acme1/books";
|
|
/** A state holding one book under its parent's plain spelling. */
|
|
const plainlyHeld = () =>
|
|
new SimulatedState(
|
|
[
|
|
{
|
|
type,
|
|
collection,
|
|
id: "1",
|
|
fields: { id: 1, name: "Alpha" },
|
|
view_fields: [],
|
|
record_ordinals: [],
|
|
},
|
|
],
|
|
[],
|
|
);
|
|
|
|
it("names the plainly held entity from a path that encodes its parent", () => {
|
|
expect(
|
|
itemTarget(plainlyHeld(), type, {
|
|
path: "/api/orgs/uid/%61cme1/books/1",
|
|
}),
|
|
).toEqual({ collection, id: "1" });
|
|
});
|
|
|
|
it("lists the type's entities only when the path's own collection holds no such id", () => {
|
|
const state = plainlyHeld();
|
|
const listed = vi.spyOn(state, "ofType");
|
|
expect(
|
|
itemTarget(state, type, { path: "/api/orgs/uid/acme1/books/1" }),
|
|
).toEqual({ collection, id: "1" });
|
|
expect(listed).not.toHaveBeenCalled();
|
|
expect(
|
|
itemTarget(state, type, { path: "/api/orgs/uid/%61cme1/books/1" }),
|
|
).toEqual({ collection, id: "1" });
|
|
expect(listed).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("answers an encoded read from the plain checked line", () => {
|
|
const tool = {
|
|
name: "get_orgs_books_by_uid",
|
|
method: "GET",
|
|
path_template: "/api/orgs/uid/{uid}/books",
|
|
record_ordinals: [0],
|
|
};
|
|
const record = {
|
|
ordinal: 0,
|
|
method: "GET",
|
|
path: "/api/orgs/uid/acme1/books",
|
|
query: undefined,
|
|
status: 200,
|
|
timing: { start: undefined, endMs: undefined },
|
|
};
|
|
const lines = checkedLines({
|
|
tools: [tool],
|
|
records: [record],
|
|
views: new Map([
|
|
[
|
|
tool.name,
|
|
{
|
|
tool: tool.name,
|
|
type: "/api/orgs/uid/{uid}/books",
|
|
shape: "list",
|
|
pointer: "",
|
|
fields: ["id"],
|
|
required_fields: ["id"],
|
|
checked_record_ordinals: [0],
|
|
membership: "all",
|
|
counters: [],
|
|
},
|
|
],
|
|
]),
|
|
});
|
|
expect(
|
|
servedLine(lines.get(tool.name), {
|
|
...record,
|
|
ordinal: 1,
|
|
path: "/api/orgs/uid/%61cme1/books",
|
|
}),
|
|
).toEqual({
|
|
ordinal: 0,
|
|
collection: "/api/orgs/uid/acme1/books",
|
|
named: true,
|
|
});
|
|
});
|
|
|
|
it("confirms an update whose path encodes a parent the plain reads hold", () => {
|
|
const alpha = { id: 1, org: "acme1", name: "Alpha" };
|
|
const renamed = { id: 1, org: "acme1", name: "Renamed" };
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/orgs/uid/acme1/books", [alpha]],
|
|
["GET", "/api/orgs/uid/acme1/books/1", alpha],
|
|
[
|
|
"PATCH",
|
|
"/api/orgs/uid/%61cme1/books/1",
|
|
renamed,
|
|
200,
|
|
{ name: "Renamed" },
|
|
],
|
|
["GET", "/api/orgs/uid/acme1/books/1", renamed],
|
|
];
|
|
const { writes } = environmentStateViewOf(timedCaptureEvidence(rows), [
|
|
"after-uid-literal",
|
|
]);
|
|
expect(writes.find((plan) => plan.effect === "update")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [3],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("writes named as serving names them", () => {
|
|
const orgBook = (
|
|
id: number,
|
|
org: string,
|
|
name: string,
|
|
archived = false,
|
|
) => ({
|
|
id,
|
|
org,
|
|
name,
|
|
archived,
|
|
});
|
|
const ALPHA = orgBook(1, "acme1", "Alpha");
|
|
const BETA = orgBook(2, "beta2", "Beta");
|
|
const RENAMED_BETA = orgBook(2, "beta2", "Renamed Beta");
|
|
const INIT_ROWS: readonly 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],
|
|
];
|
|
const RENAME_BETA: readonly TimedCaptureRow[] = [
|
|
[
|
|
"PATCH",
|
|
"/api/orgs/beta2/books/2",
|
|
RENAMED_BETA,
|
|
200,
|
|
{ name: "Renamed Beta" },
|
|
],
|
|
["GET", "/api/orgs/beta2/books/2", RENAMED_BETA],
|
|
];
|
|
const CREATE_DELTA: readonly TimedCaptureRow[] = [
|
|
[
|
|
"POST",
|
|
"/api/orgs/beta2/books",
|
|
orgBook(4, "beta2", "Delta"),
|
|
201,
|
|
{ name: "Delta" },
|
|
],
|
|
];
|
|
const planned = (rows: readonly TimedCaptureRow[], effect: string) =>
|
|
environmentStateViewOf(timedCaptureEvidence(rows), [
|
|
"sibling-variance",
|
|
]).writes.find((plan) => plan.effect === effect);
|
|
|
|
it("contradicts a create named plainly by a list of the encoded collection it went to that omits it", () => {
|
|
const gamma = orgBook(3, "acme1", "Gamma");
|
|
const rows: TimedCaptureRow[] = [
|
|
...INIT_ROWS,
|
|
["POST", "/api/orgs/acme1/books", gamma, 201, { name: "Gamma" }],
|
|
["GET", "/api/orgs/acme1/books/3", gamma],
|
|
// Serving lists the create in the encoded collection, which the recording shows without it.
|
|
["GET", "/api/orgs/%61cme1/books", [ALPHA]],
|
|
...CREATE_DELTA,
|
|
];
|
|
expect(planned(rows, "create")).toMatchObject({
|
|
served: false,
|
|
contradicted_by: 6,
|
|
});
|
|
});
|
|
|
|
it("leaves unknown the entity a plainly named action changed under the encoded collection", () => {
|
|
const archived = orgBook(1, "acme1", "Alpha", true);
|
|
const rows: TimedCaptureRow[] = [
|
|
...INIT_ROWS,
|
|
["POST", "/api/orgs/acme1/books/1/archive", archived, 200],
|
|
// The rename's answer also shows the archive, which no rule of the rename explains.
|
|
[
|
|
"PATCH",
|
|
"/api/orgs/acme1/books/1",
|
|
orgBook(1, "acme1", "Renamed", true),
|
|
200,
|
|
{ name: "Renamed" },
|
|
],
|
|
...RENAME_BETA,
|
|
];
|
|
expect(planned(rows, "update")).toMatchObject({
|
|
served: true,
|
|
reason: null,
|
|
});
|
|
});
|
|
|
|
it("checks no list of the encoded collection after a plainly named write at the collection", () => {
|
|
const gamma = orgBook(3, "acme1", "Gamma");
|
|
const rows: TimedCaptureRow[] = [
|
|
...INIT_ROWS,
|
|
["DELETE", "/api/orgs/acme1/books", "", 204],
|
|
["POST", "/api/orgs/acme1/books", gamma, 201, { name: "Gamma" }],
|
|
["GET", "/api/orgs/acme1/books/3", gamma],
|
|
// The cleared collection now lists only the create, which the state alone cannot show.
|
|
["GET", "/api/orgs/%61cme1/books", [gamma]],
|
|
...CREATE_DELTA,
|
|
];
|
|
expect(planned(rows, "create")).toMatchObject({
|
|
served: true,
|
|
contradicted_by: null,
|
|
});
|
|
});
|
|
|
|
it("checks no read under the encoded collection after a plainly named write above it", () => {
|
|
const rows: TimedCaptureRow[] = [
|
|
...INIT_ROWS,
|
|
[
|
|
"PATCH",
|
|
"/api/orgs/%61cme1/books/1",
|
|
orgBook(1, "acme1", "Renamed"),
|
|
200,
|
|
{ name: "Renamed" },
|
|
],
|
|
["DELETE", "/api/orgs/acme1", "", 204],
|
|
// The organisation's books went with it, which the state alone cannot show.
|
|
["GET", "/api/orgs/%61cme1/books/1", BOOK_NOT_FOUND, 404],
|
|
...RENAME_BETA,
|
|
];
|
|
expect(planned(rows, "update")).toMatchObject({
|
|
served: true,
|
|
contradicted_by: null,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("resolved encoded item observations", () => {
|
|
it("matches the resolved raw ID when the collection alias and response ID are encoded", () => {
|
|
const collection = "/api/orgs/%61cme1/books";
|
|
const state = new SimulatedState(
|
|
[
|
|
{
|
|
type: "/api/orgs/{org_id}/books",
|
|
collection,
|
|
id: "%61",
|
|
fields: { id: "%61", name: "Before" },
|
|
view_fields: [],
|
|
record_ordinals: [],
|
|
},
|
|
],
|
|
[],
|
|
);
|
|
const write = {
|
|
ordinal: 0,
|
|
method: "PATCH",
|
|
path: "/api/orgs/acme1/books/%61",
|
|
query: undefined,
|
|
status: 204,
|
|
timing: { start: undefined, endMs: undefined },
|
|
};
|
|
const target = itemTarget(state, "/api/orgs/{org_id}/books", write);
|
|
const itemView = {
|
|
tool: "get_books_by_id",
|
|
type: "/api/orgs/{org_id}/books",
|
|
shape: "item" as const,
|
|
pointer: "",
|
|
fields: ["id", "name"],
|
|
required_fields: ["id"],
|
|
checked_record_ordinals: [1],
|
|
envelope_ordinal: 1,
|
|
};
|
|
const records = [
|
|
write,
|
|
{
|
|
ordinal: 1,
|
|
method: "GET",
|
|
path: "/api/orgs/acme1/books/%61",
|
|
query: undefined,
|
|
status: 200,
|
|
timing: { start: undefined, endMs: undefined },
|
|
},
|
|
];
|
|
const search: ObservationSearch = {
|
|
records,
|
|
chronology: new StateRecordChronology(records, "record-order"),
|
|
from: 0,
|
|
viewOf: () => itemView,
|
|
withBody: (_ordinal, use) => use({ id: "%61", name: "After" }),
|
|
queryOf: () => undefined,
|
|
};
|
|
|
|
expect(target).toEqual({ collection, id: "%61" });
|
|
expect(
|
|
laterObservation(search, {
|
|
type: itemView.type,
|
|
...target,
|
|
idField: "id",
|
|
held: () => true,
|
|
isWrite: (record) => record.method !== "GET",
|
|
sameItem: (record) => {
|
|
const resolved = itemTarget(state, itemView.type, record);
|
|
return (
|
|
resolved.collection === target.collection &&
|
|
resolved.id === target.id
|
|
);
|
|
},
|
|
}),
|
|
).toEqual({
|
|
ordinal: 1,
|
|
pointer: "",
|
|
object: { id: "%61", name: "After" },
|
|
id: "%61",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("what a write's own answer pins down", () => {
|
|
it("observes a create that answers with its id alone only through a read of that id", () => {
|
|
const fourth = book(4, "Fourth Book", "2026-10-01T11:00:00Z");
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK]],
|
|
["GET", "/api/books/1", FIRST_BOOK],
|
|
["GET", "/api/books/2", SECOND_BOOK],
|
|
["POST", "/api/books", { id: 3 }, 201, { name: "Third Book" }],
|
|
// A book the capture never listed is not the one the create made.
|
|
["GET", "/api/books/4", fourth],
|
|
["GET", "/api/books/4", fourth],
|
|
["GET", "/api/books/3", THIRD_BOOK],
|
|
["GET", "/api/books/3", THIRD_BOOK],
|
|
];
|
|
expect(gated(rows, "post_books")).toMatchObject({
|
|
observation: { ordinal: 6, pointer: "" },
|
|
checked_record_ordinals: [7],
|
|
});
|
|
});
|
|
|
|
it("keeps an update served when a later call is the first to send the path's id in its body", () => {
|
|
const renamedSecond = renamedBook(SECOND_BOOK, "Renamed Second", 5);
|
|
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],
|
|
[
|
|
"PATCH",
|
|
"/api/books/2",
|
|
renamedSecond,
|
|
200,
|
|
{ id: 2, name: "Renamed Second" },
|
|
],
|
|
["GET", "/api/books/2", renamedSecond],
|
|
];
|
|
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
|
served: true,
|
|
body: { identity: "id" },
|
|
});
|
|
});
|
|
});
|
|
|
|
it("counts the sent values a plan keeps for fixed members against the view's allowance", () => {
|
|
let retained = 0;
|
|
const context = {
|
|
io: { retain: (bytes: number) => (retained += bytes) },
|
|
} as unknown as EffectContext;
|
|
retainFixed(context, [{ sent: "x".repeat(10_000) }]);
|
|
expect(retained).toBeGreaterThan(20_000);
|
|
retained = 0;
|
|
retainFixed(context, []);
|
|
expect(retained).toBe(0);
|
|
});
|
|
|
|
describe("what a check leaves pending", () => {
|
|
const summary = (books: readonly Book[]): JsonValue =>
|
|
books.map(({ id, name }) => ({ id: id ?? null, name: name ?? null }));
|
|
const described = (id: number, name: string, description: string) => ({
|
|
id,
|
|
name,
|
|
description,
|
|
});
|
|
|
|
it("keeps an update a passing read showed nothing of for a later read to confirm", () => {
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books", summary([FIRST_BOOK, SECOND_BOOK])],
|
|
["GET", "/api/books/1", described(1, "First Book", "x")],
|
|
["GET", "/api/books/2", described(2, "Second Book", "y")],
|
|
[
|
|
"PATCH",
|
|
"/api/books/1",
|
|
described(1, "First Book", "new"),
|
|
200,
|
|
{ description: "new" },
|
|
],
|
|
// The list passes but shows no description; the item read does.
|
|
["GET", "/api/books", summary([FIRST_BOOK, SECOND_BOOK])],
|
|
["GET", "/api/books/1", described(1, "First Book", "new")],
|
|
];
|
|
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [5],
|
|
});
|
|
});
|
|
|
|
it("checks only a partial update's fields that remain after a later overwrite", () => {
|
|
const patched = described(1, "Patched", "survives");
|
|
const replaced = described(1, "Replaced", "survives");
|
|
const changedByOtherAction = described(1, "Archived", "survives");
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books", summary([FIRST_BOOK, SECOND_BOOK])],
|
|
["GET", "/api/books/1", described(1, "First Book", "original")],
|
|
["GET", "/api/books/2", described(2, "Second Book", "other")],
|
|
[
|
|
"PATCH",
|
|
"/api/books/1",
|
|
patched,
|
|
200,
|
|
{ name: "Patched", description: "survives" },
|
|
],
|
|
["PUT", "/api/books/1", replaced, 200, { name: "Replaced" }],
|
|
["POST", "/api/books/1/archive", changedByOtherAction, 204, {}],
|
|
["GET", "/api/books/1", changedByOtherAction],
|
|
];
|
|
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [6],
|
|
contradicted_by: null,
|
|
});
|
|
});
|
|
|
|
it("checks a surviving sent member after a derived field was bumped by an unmodelled write", () => {
|
|
const entry = (
|
|
name: string,
|
|
description: string,
|
|
version: number,
|
|
): Book => ({ id: 1, name, description, version });
|
|
const other = { id: 2, name: "B", description: "y", version: 1 };
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books", [entry("A", "x", 1), other]],
|
|
["GET", "/api/books/1", entry("A", "x", 1)],
|
|
["GET", "/api/books/2", other],
|
|
[
|
|
"PATCH",
|
|
"/api/books/1",
|
|
entry("A2", "new", 2),
|
|
200,
|
|
{ name: "A2", description: "new" },
|
|
],
|
|
["PUT", "/api/books/1", entry("A3", "new", 2), 200, { name: "A3" }],
|
|
["POST", "/api/books/1/archive", { archived: true }, 204, {}],
|
|
// The read still shows the description the patch sent. The version bump is not that patch.
|
|
["GET", "/api/books/1", entry("A3", "new", 9)],
|
|
];
|
|
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
|
served: true,
|
|
rules: expect.arrayContaining([{ field: "version", rule: "increment" }]),
|
|
checked_record_ordinals: [6],
|
|
contradicted_by: null,
|
|
});
|
|
});
|
|
|
|
it("neither resynchronises nor checks an entity by a read begun while a write to it still ran", () => {
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK]],
|
|
["GET", "/api/books/1", FIRST_BOOK],
|
|
["GET", "/api/books/2", SECOND_BOOK],
|
|
// The rename runs from second 3 to 5.5, and an unmodelled action leaves book 1 unknown.
|
|
["PATCH", "/api/books/1", RENAMED, 200, { name: "Renamed Book" }, 2500],
|
|
["POST", "/api/books/1/archive", null, 204, {}],
|
|
["GET", "/api/books/1", FIRST_BOOK],
|
|
["GET", "/api/books/1", RENAMED],
|
|
];
|
|
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [6],
|
|
contradicted_by: null,
|
|
});
|
|
});
|
|
|
|
it("learns an update from a read begun after the write, not from one that overlapped it", () => {
|
|
const named = (name: string) => ({ id: 1, name });
|
|
const other = { id: 2, name: "B" };
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books", [named("A"), other]],
|
|
["GET", "/api/books/1", named("A")],
|
|
["GET", "/api/books/2", other],
|
|
// The patch runs from second 3 to 5.5 and returns no entity. A read begun while it still runs shows the old name.
|
|
["PATCH", "/api/books/1", "", 204, { name: "Z" }, 2500],
|
|
["GET", "/api/books/1", named("A")],
|
|
["GET", "/api/books/1", named("A")],
|
|
["GET", "/api/books/1", named("Z")],
|
|
["GET", "/api/books", [named("Z"), other]],
|
|
];
|
|
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
|
served: true,
|
|
observation: { ordinal: 6, pointer: "" },
|
|
body: { accepted: ["name"], fixed: [] },
|
|
rules: [{ field: "name", rule: "echo" }],
|
|
checked_record_ordinals: [7],
|
|
contradicted_by: null,
|
|
});
|
|
});
|
|
|
|
it("checks nothing by a range read the collection has outgrown", () => {
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books?_start=0&_end=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 still answers the range with two books.
|
|
["GET", "/api/books?_start=0&_end=2", [FIRST_BOOK, SECOND_BOOK]],
|
|
["GET", "/api/books/3", THIRD_BOOK],
|
|
];
|
|
expect(gated(rows, "post_books")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [5],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("what a later call must follow", () => {
|
|
it("rejects a later create whose unique field repeats a value already held", () => {
|
|
const existing = {
|
|
id: "123e4567-e89b-42d3-a456-426614174000",
|
|
ref: "223e4567-e89b-42d3-a456-426614174000",
|
|
name: "Existing",
|
|
};
|
|
const first = {
|
|
id: "323e4567-e89b-42d3-a456-426614174000",
|
|
ref: "423e4567-e89b-42d3-a456-426614174000",
|
|
name: "First",
|
|
};
|
|
const duplicate = {
|
|
id: "523e4567-e89b-42d3-a456-426614174000",
|
|
ref: first.ref,
|
|
name: "Second",
|
|
};
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/items", [existing]],
|
|
["GET", `/api/items/${existing.id}`, existing],
|
|
["POST", "/api/items", first, 201, { name: "First" }],
|
|
["GET", `/api/items/${first.id}`, first],
|
|
["GET", "/api/items", [existing, first]],
|
|
["POST", "/api/items", duplicate, 201, { name: "Second" }],
|
|
["GET", `/api/items/${duplicate.id}`, duplicate],
|
|
["GET", "/api/items", [existing, first, duplicate]],
|
|
];
|
|
expect(gated(rows, "post_items")).toMatchObject({
|
|
served: false,
|
|
reason: "unexplained-change",
|
|
});
|
|
});
|
|
|
|
it("refuses a create whose later call does not follow the sequence it learned", () => {
|
|
const ranked = (at: Book, rank: number) => ({ ...at, rank });
|
|
const third = ranked(book(3, "Third Book", bookClock(3)), 3);
|
|
const fourth = ranked(book(4, "Fourth Book", bookClock(4)), 3);
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books", [ranked(FIRST_BOOK, 1), ranked(SECOND_BOOK, 2)]],
|
|
["GET", "/api/books/1", ranked(FIRST_BOOK, 1)],
|
|
["GET", "/api/books/2", ranked(SECOND_BOOK, 2)],
|
|
["POST", "/api/books", third, 201, { name: "Third Book" }],
|
|
// Serving ranks the next create 4; the application recorded 3 again.
|
|
["POST", "/api/books", fourth, 201, { name: "Fourth Book" }],
|
|
[
|
|
"GET",
|
|
"/api/books",
|
|
[ranked(FIRST_BOOK, 1), ranked(SECOND_BOOK, 2), third, fourth],
|
|
],
|
|
];
|
|
expect(gated(rows, "post_books")).toMatchObject({
|
|
served: false,
|
|
reason: "unexplained-change",
|
|
});
|
|
});
|
|
|
|
it("keeps a document write a later replacement of another member left in place", () => {
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/settings", { theme: "light", prefs: { lang: "en" } }],
|
|
[
|
|
"PATCH",
|
|
"/api/settings",
|
|
{ theme: "dark", prefs: { lang: "en" } },
|
|
200,
|
|
{ theme: "dark" },
|
|
],
|
|
// The replacement holds `lang`, so it replaces `prefs` alone.
|
|
[
|
|
"PUT",
|
|
"/api/settings",
|
|
{ theme: "dark", prefs: { lang: "fr" } },
|
|
200,
|
|
{ lang: "fr" },
|
|
],
|
|
["GET", "/api/settings", { theme: "dark", prefs: { lang: "fr" } }],
|
|
];
|
|
expect(gated(rows, "patch_settings")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [3],
|
|
});
|
|
expect(gated(rows, "put_settings")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [3],
|
|
});
|
|
});
|
|
|
|
it("applies a later document replacement at the plan's pointer, though it shares no member with the document", () => {
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/settings", { theme: "light", lang: "en" }],
|
|
[
|
|
"PUT",
|
|
"/api/settings",
|
|
{ theme: "dark", lang: "fr" },
|
|
200,
|
|
{ theme: "dark", lang: "fr" },
|
|
],
|
|
["PUT", "/api/settings", { theme: "blue" }, 200, { theme: "blue" }],
|
|
["PUT", "/api/settings", { lang: "de" }, 200, { lang: "de" }],
|
|
["GET", "/api/settings", { lang: "de" }],
|
|
];
|
|
expect(gated(rows, "put_settings")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [4],
|
|
});
|
|
});
|
|
|
|
it("finds an update's entity as serving does when the write spells the collection plainly", () => {
|
|
const alpha = { id: 1, org: "acme1", name: "Alpha" };
|
|
const beta = { id: 2, org: "beta2", name: "Beta" };
|
|
const renamed = { ...alpha, name: "Renamed" };
|
|
const renamedBeta = { ...beta, name: "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/acme1/books/1", renamed, 200, { name: "Renamed" }],
|
|
[
|
|
"PATCH",
|
|
"/api/orgs/beta2/books/2",
|
|
renamedBeta,
|
|
200,
|
|
{ name: "Renamed Beta" },
|
|
],
|
|
["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: true,
|
|
checked_record_ordinals: [6, 7],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("what a write's effect keeps", () => {
|
|
it("keeps an entity member a later write's other member does not cover, though both names look like pointers", () => {
|
|
const flat = (id: number, prefs: string, lang: string) => ({
|
|
id,
|
|
name: `Book ${String(id)}`,
|
|
"/prefs": prefs,
|
|
"/prefs/lang": lang,
|
|
});
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books", [flat(1, "x", "en"), flat(2, "x", "en")]],
|
|
["GET", "/api/books/1", flat(1, "x", "en")],
|
|
["GET", "/api/books/2", flat(2, "x", "en")],
|
|
[
|
|
"PATCH",
|
|
"/api/books/1",
|
|
flat(1, "x", "fr"),
|
|
200,
|
|
{ "/prefs/lang": "fr" },
|
|
],
|
|
["PUT", "/api/books/1", flat(1, "y", "fr"), 200, { "/prefs": "y" }],
|
|
["GET", "/api/books/1", flat(1, "y", "fr")],
|
|
];
|
|
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [5],
|
|
});
|
|
});
|
|
|
|
const subtitled = (id: number, name: string, subtitle?: string) =>
|
|
subtitle === undefined ? { id, name } : { id, name, subtitle };
|
|
const summaries = (first: string): TimedCaptureRow => [
|
|
"GET",
|
|
"/api/books",
|
|
[subtitled(1, first), subtitled(2, "B")],
|
|
];
|
|
|
|
it("refuses an update a later read shows dropped a field serving would keep", () => {
|
|
const rows: TimedCaptureRow[] = [
|
|
summaries("A"),
|
|
["GET", "/api/books/1", subtitled(1, "A", "s")],
|
|
["GET", "/api/books/2", subtitled(2, "B", "t")],
|
|
["PATCH", "/api/books/1", "", 204, { name: "Z" }],
|
|
["GET", "/api/books/1", subtitled(1, "Z")],
|
|
summaries("Z"),
|
|
];
|
|
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
|
served: false,
|
|
reason: "unexplained-change",
|
|
});
|
|
});
|
|
|
|
it("keeps an update whose partial response leaves out a field a later read still shows", () => {
|
|
const rows: TimedCaptureRow[] = [
|
|
summaries("A"),
|
|
["GET", "/api/books/1", subtitled(1, "A", "s")],
|
|
["GET", "/api/books/2", subtitled(2, "B", "t")],
|
|
["PATCH", "/api/books/1", subtitled(1, "Z"), 200, { name: "Z" }],
|
|
["GET", "/api/books/1", subtitled(1, "Z", "s")],
|
|
];
|
|
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [4],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("what an unmodelled write explains", () => {
|
|
it("weighs an unmodelled write against a tool's call on the entity a read checks, not its latest call elsewhere", () => {
|
|
const tagged = (id: number, name: string, tag: string) => ({
|
|
id,
|
|
name,
|
|
tag,
|
|
});
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books", [tagged(1, "A", "x"), tagged(2, "B", "x")]],
|
|
["GET", "/api/books/1", tagged(1, "A", "x")],
|
|
["GET", "/api/books/2", tagged(2, "B", "x")],
|
|
["PATCH", "/api/books/1", tagged(1, "A2", "x"), 200, { name: "A2" }],
|
|
["POST", "/api/exports", { queued: true }, 202, { format: "csv" }],
|
|
["PATCH", "/api/books/2", tagged(2, "B2", "x"), 200, { name: "B2" }],
|
|
// The export came after book 1's rename, so it may explain the changed tag; the rename itself shows.
|
|
["GET", "/api/books/1", tagged(1, "A2", "y")],
|
|
];
|
|
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
|
served: true,
|
|
contradicted_by: null,
|
|
checked_record_ordinals: [6],
|
|
});
|
|
});
|
|
|
|
it("lets an unmodelled write explain a list's change to an entity a tool made before it, whatever the tool did after", () => {
|
|
const fourth = book(4, "Fourth Book", bookClock(5));
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books", [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" }],
|
|
["POST", "/api/exports", { queued: true }, 202, { format: "csv" }],
|
|
["POST", "/api/books", fourth, 201, { name: "Fourth Book" }],
|
|
["GET", "/api/books/4", fourth],
|
|
// The export came after the third book was made, so it may explain the third book's other name.
|
|
[
|
|
"GET",
|
|
"/api/books",
|
|
[FIRST_BOOK, SECOND_BOOK, { ...THIRD_BOOK, name: "Retitled" }, fourth],
|
|
],
|
|
];
|
|
expect(gated(rows, "post_books")).toMatchObject({
|
|
served: true,
|
|
contradicted_by: null,
|
|
});
|
|
});
|
|
|
|
it("weighs no unmodelled write against a call whose effect a later write overwrote", () => {
|
|
const named = (id: number, name: string) => ({
|
|
...book(id, name, bookClock(0)),
|
|
id,
|
|
});
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books", [named(1, "A"), named(2, "B")]],
|
|
["GET", "/api/books/1", named(1, "A")],
|
|
["GET", "/api/books/2", named(2, "B")],
|
|
["PATCH", "/api/books/1", named(1, "A2"), 200, { name: "A2" }],
|
|
["POST", "/api/exports", { queued: true }, 202, { format: "csv" }],
|
|
// The PUT overwrites the patch's only change on book 1.
|
|
["PUT", "/api/books/1", named(1, "A3"), 200, { name: "A3" }],
|
|
["PATCH", "/api/books/2", named(2, "B2"), 200, { name: "B2" }],
|
|
["GET", "/api/books/2", named(2, "B2")],
|
|
// The export came before book 2's patch, which the list shows undone.
|
|
["GET", "/api/books", [named(1, "A3"), named(2, "B")]],
|
|
];
|
|
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
|
served: false,
|
|
contradicted_by: 8,
|
|
});
|
|
});
|
|
|
|
it("contradicts by a failed item read the writes no unmodelled write came after, checking only the others' members", () => {
|
|
const enveloped = (data: Record<string, JsonValue>, revision: string) => ({
|
|
data,
|
|
revision,
|
|
});
|
|
const full = (name: string, description: string) => ({
|
|
id: 1,
|
|
name,
|
|
description,
|
|
});
|
|
const summary = [
|
|
{ id: 1, name: "A" },
|
|
{ id: 2, name: "B" },
|
|
];
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books", summary],
|
|
["GET", "/api/books/1", enveloped(full("A", "x"), "r1")],
|
|
[
|
|
"GET",
|
|
"/api/books/2",
|
|
enveloped({ id: 2, name: "B", description: "y" }, "r1"),
|
|
],
|
|
["PATCH", "/api/books/1", full("A", "new"), 200, { description: "new" }],
|
|
["GET", "/api/books", summary],
|
|
["POST", "/api/exports", { queued: true }, 202, { format: "csv" }],
|
|
// The rename sends the name alone, so the earlier description patch stays pending.
|
|
["PUT", "/api/books/1", full("Z", "new"), 200, { name: "Z" }],
|
|
// The rename shows, but the envelope serving would answer with does not.
|
|
["GET", "/api/books/1", enveloped(full("Z", "new"), "r2")],
|
|
];
|
|
expect(gated(rows, "put_books_by_id")).toMatchObject({
|
|
served: false,
|
|
contradicted_by: 7,
|
|
});
|
|
});
|
|
|
|
it("contradicts by a list a write made after an unmodelled one, though the tool's earlier change elsewhere came before it", () => {
|
|
const named = (id: number, name: string) => ({
|
|
...book(id, name, bookClock(0)),
|
|
id,
|
|
});
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books", [named(1, "A"), named(2, "B")]],
|
|
["GET", "/api/books/1", named(1, "A")],
|
|
["GET", "/api/books/2", named(2, "B")],
|
|
["PATCH", "/api/books/1", named(1, "A2"), 200, { name: "A2" }],
|
|
["POST", "/api/exports", { queued: true }, 202, { format: "csv" }],
|
|
["PATCH", "/api/books/2", named(2, "B2"), 200, { name: "B2" }],
|
|
["GET", "/api/books/2", named(2, "B2")],
|
|
// Only book 2 differs, and its patch came after the export.
|
|
["GET", "/api/books", [named(1, "A2"), named(2, "B")]],
|
|
];
|
|
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
|
served: false,
|
|
contradicted_by: 7,
|
|
});
|
|
});
|
|
|
|
it("contradicts a write made after an unmodelled one, though an earlier pending write came before it", () => {
|
|
const summary = (name: string) => [
|
|
{ id: 1, name },
|
|
{ id: 2, name: "B" },
|
|
];
|
|
const full = (name: string, description: string) => ({
|
|
id: 1,
|
|
name,
|
|
description,
|
|
});
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books", summary("A")],
|
|
["GET", "/api/books/1", full("A", "x")],
|
|
["GET", "/api/books/2", { id: 2, name: "B", description: "y" }],
|
|
["PATCH", "/api/books/1", full("A", "new"), 200, { description: "new" }],
|
|
["GET", "/api/books", summary("A")],
|
|
["POST", "/api/exports", { queued: true }, 202, { format: "csv" }],
|
|
[
|
|
"PUT",
|
|
"/api/books/1",
|
|
full("Z", "new"),
|
|
200,
|
|
{ name: "Z", description: "new" },
|
|
],
|
|
["GET", "/api/books/1", full("Z", "new")],
|
|
// The export came before the rename, so it cannot explain the list showing the old name.
|
|
["GET", "/api/books", summary("A")],
|
|
];
|
|
expect(gated(rows, "put_books_by_id")).toMatchObject({
|
|
served: false,
|
|
contradicted_by: 8,
|
|
});
|
|
});
|
|
});
|
|
|
|
it("leaves the global served clock unknown when any record completion is unknown", () => {
|
|
const rows = (duration: number): TimedCaptureRow[] => [
|
|
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK]],
|
|
// Even an earlier record with an unknown end may finish after the final known record.
|
|
["GET", "/api/books/1", FIRST_BOOK, 200, undefined, duration],
|
|
["GET", "/api/books/2", SECOND_BOOK],
|
|
];
|
|
expect(
|
|
environmentStateViewOf(timedCaptureEvidence(rows(5))).write_clock,
|
|
).toBe(new Date(bookClock(3)).toISOString());
|
|
expect(
|
|
environmentStateViewOf(timedCaptureEvidence(rows(-1))).write_clock,
|
|
).toBeNull();
|
|
});
|
|
|
|
describe("reads and calls on an entity an unmodelled write left unknown", () => {
|
|
it.each([
|
|
{ duration: 5, target: "1", served: true },
|
|
{ duration: -1, target: "1", served: false },
|
|
{ duration: -1, target: "2", served: true },
|
|
])(
|
|
"requires unmodelled completion before resync and later confirmation: $target duration=$duration",
|
|
({ duration, target, served }) => {
|
|
const rows: TimedCaptureRow[] = [
|
|
[
|
|
"GET",
|
|
"/api/books",
|
|
[
|
|
{ id: 1, name: "A" },
|
|
{ id: 2, name: "B" },
|
|
],
|
|
],
|
|
["GET", "/api/books/1", { id: 1, name: "A" }],
|
|
["GET", "/api/books/2", { id: 2, name: "B" }],
|
|
["POST", `/api/books/${target}/archive`, null, 204, {}, duration],
|
|
// This read may re-establish a baseline only after the related action finished.
|
|
["GET", "/api/books/1", { id: 1, name: "A" }],
|
|
["PATCH", "/api/books/1", { id: 1, name: "Z" }, 200, { name: "Z" }],
|
|
["GET", "/api/books/1", { id: 1, name: "Z" }],
|
|
];
|
|
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
|
served,
|
|
checked_record_ordinals: served ? [6] : [],
|
|
contradicted_by: null,
|
|
});
|
|
},
|
|
);
|
|
|
|
const named = (name: string) => ({ id: 1, name });
|
|
const other = { id: 2, name: "B" };
|
|
const ARCHIVE: TimedCaptureRow = [
|
|
"POST",
|
|
"/api/books/1/archive",
|
|
null,
|
|
204,
|
|
{},
|
|
];
|
|
|
|
it("keeps the entity unknown through a failed read that is no absence, for a later read to check", () => {
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books", [named("A"), other]],
|
|
["GET", "/api/books/1", named("A")],
|
|
["GET", "/api/books/2", other],
|
|
["PATCH", "/api/books/1", named("Z"), 200, { name: "Z" }],
|
|
ARCHIVE,
|
|
// The application was briefly unavailable, so this answer shows nothing of the book.
|
|
["GET", "/api/books/1", { error: "unavailable" }, 503],
|
|
["GET", "/api/books/1", named("Z")],
|
|
];
|
|
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [6],
|
|
contradicted_by: null,
|
|
});
|
|
});
|
|
|
|
it("confirms no update of a volatile field by a read that shows the entity again, as no other read does", () => {
|
|
const seen = (id: number, count: number) => ({
|
|
id,
|
|
name: id === 1 ? "A" : "B",
|
|
seen: count,
|
|
});
|
|
const rows = (archive: boolean): TimedCaptureRow[] => [
|
|
["GET", "/api/books", [seen(1, 1), seen(2, 1)]],
|
|
["GET", "/api/books/1", seen(1, 1)],
|
|
// The same read again shows another count, so the count changes with no write.
|
|
["GET", "/api/books/1", seen(1, 2)],
|
|
["GET", "/api/books/2", seen(2, 1)],
|
|
["PATCH", "/api/books/1", seen(1, 5), 200, { seen: 5 }],
|
|
...(archive ? [ARCHIVE] : []),
|
|
["GET", "/api/books/1", seen(1, 9)],
|
|
];
|
|
for (const archive of [false, true])
|
|
expect(gated(rows(archive), "patch_books_by_id")).toMatchObject({
|
|
served: false,
|
|
reason: "unconfirmed",
|
|
checked_record_ordinals: [],
|
|
});
|
|
});
|
|
|
|
it("adds nothing to an update's plan from a call it cannot check", () => {
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books", [named("A"), other]],
|
|
["GET", "/api/books/1", named("A")],
|
|
["GET", "/api/books/2", other],
|
|
["PATCH", "/api/books/1", named("Z"), 200, { name: "Z" }],
|
|
["GET", "/api/books/1", named("Z")],
|
|
ARCHIVE,
|
|
// The application stored this name otherwise than sent; a call on a known book would conflict with the echo.
|
|
["PATCH", "/api/books/1", named("Y!"), 200, { name: "y" }],
|
|
];
|
|
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
|
served: true,
|
|
body: { accepted: ["name"], fixed: [] },
|
|
rules: [{ field: "name", rule: "echo" }],
|
|
checked_record_ordinals: [4],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("volatile fields", () => {
|
|
it("confirms no write through a volatile field a read renders from itself", () => {
|
|
const seen = (id: number, name: string, at: string) => ({
|
|
id,
|
|
name,
|
|
seen: at,
|
|
});
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books", [seen(1, "A", "t1"), seen(2, "B", "t1")]],
|
|
["GET", "/api/books/1", seen(1, "A", "t1")],
|
|
// A repeated read with no write between shows `seen` changing on its own, so it is volatile.
|
|
["GET", "/api/books/1", seen(1, "A", "t2")],
|
|
["GET", "/api/books/2", seen(2, "B", "t1")],
|
|
["PATCH", "/api/books/1", seen(1, "A", "t3"), 200, { seen: "t3" }],
|
|
["POST", "/api/exports", { queued: true }, 202, { format: "csv" }],
|
|
// The export may explain the new name, and `seen` renders from the read, so nothing shows the patch.
|
|
["GET", "/api/books/1", seen(1, "Z", "t9")],
|
|
];
|
|
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
|
served: false,
|
|
checked_record_ordinals: [],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("entities an unmodelled write made", () => {
|
|
const IMPORT: TimedCaptureRow = [
|
|
"POST",
|
|
"/api/imports",
|
|
{ queued: true },
|
|
202,
|
|
{ source: "csv" },
|
|
];
|
|
const RENAME: TimedCaptureRow = [
|
|
"PATCH",
|
|
"/api/books/1",
|
|
renamedBook(FIRST_BOOK, "Renamed Book", 3),
|
|
200,
|
|
{ name: "Renamed Book" },
|
|
];
|
|
const deletedAfterRead = (before: readonly TimedCaptureRow[]) => [
|
|
...BOOK_INIT_ROWS,
|
|
...before,
|
|
["GET", "/api/books/3", THIRD_BOOK] as TimedCaptureRow,
|
|
["DELETE", "/api/books/3", "", 204] as TimedCaptureRow,
|
|
["GET", "/api/books/3", BOOK_NOT_FOUND, 404] as TimedCaptureRow,
|
|
];
|
|
|
|
it("serves a delete of a book an unmodelled import made, which a later item read showed", () => {
|
|
// The rename ends the initial reads, so the third book is no initial one.
|
|
expect(
|
|
gated(deletedAfterRead([RENAME, IMPORT]), "delete_books_by_id"),
|
|
).toMatchObject({ served: true, checked_record_ordinals: [7] });
|
|
});
|
|
|
|
it("takes a book the state never held only when a write the gate does not model came first", () => {
|
|
expect(
|
|
gated(deletedAfterRead([RENAME, IMPORT]), "delete_books_by_id"),
|
|
).toMatchObject({ served: true, checked_record_ordinals: [7] });
|
|
// Only a modelled rename came first, so nothing explains the third book and the delete finds none.
|
|
expect(
|
|
gated(deletedAfterRead([RENAME]), "delete_books_by_id"),
|
|
).toMatchObject({
|
|
served: false,
|
|
checked_record_ordinals: [],
|
|
});
|
|
});
|
|
|
|
it("takes a book an import may have made though it was redirected or went unanswered", () => {
|
|
for (const status of [303, 0]) {
|
|
const unresolved: TimedCaptureRow = [
|
|
"POST",
|
|
"/api/imports",
|
|
{ queued: true },
|
|
status,
|
|
{ source: "csv" },
|
|
];
|
|
expect(
|
|
gated(deletedAfterRead([RENAME, unresolved]), "delete_books_by_id"),
|
|
).toMatchObject({ served: true, checked_record_ordinals: [7] });
|
|
}
|
|
});
|
|
|
|
it("counts an entity it takes against the view's allowance", () => {
|
|
let retained = 0;
|
|
const read = { ...THIRD_BOOK, notes: "x".repeat(10_000) };
|
|
const unseen = new UnseenEntities([], {
|
|
state: new SimulatedState([], []),
|
|
io: {
|
|
withBody: (_ordinal, use) => use(read),
|
|
retain: (bytes) => (retained += bytes),
|
|
},
|
|
lastCheck: new Map(),
|
|
idField: () => "id",
|
|
});
|
|
unseen.unplanned(0);
|
|
retained = 0;
|
|
const view = { shape: "item", type: "/api/books", pointer: "" };
|
|
const record = {
|
|
ordinal: 1,
|
|
method: "GET",
|
|
path: "/api/books/3",
|
|
query: undefined,
|
|
status: 200,
|
|
timing: { start: undefined, endMs: undefined },
|
|
};
|
|
expect(
|
|
unseen.adopt(view as unknown as StateItemView, { record, index: 1 }),
|
|
).toMatchObject({ id: "3", fields: read });
|
|
expect(retained).toBeGreaterThan(20_000);
|
|
});
|
|
|
|
it("finds no read's target among the held entities until an unplanned write came before the read", () => {
|
|
const state = new SimulatedState([], []);
|
|
const targets = vi.spyOn(state, "ofType");
|
|
const unseen = new UnseenEntities([], {
|
|
state,
|
|
io: { withBody: (_ordinal, use) => use(THIRD_BOOK), retain: () => 0 },
|
|
lastCheck: new Map(),
|
|
idField: () => "id",
|
|
});
|
|
const view = { shape: "item", type: "/api/books", pointer: "" };
|
|
const read = (index: number) => ({
|
|
record: {
|
|
ordinal: index,
|
|
method: "GET",
|
|
path: "/api/books/3",
|
|
query: undefined,
|
|
status: 200,
|
|
timing: { start: undefined, endMs: undefined },
|
|
},
|
|
index,
|
|
});
|
|
const adopt = (index: number) =>
|
|
unseen.adopt(view as unknown as StateItemView, read(index));
|
|
expect(adopt(1)).toBeUndefined();
|
|
// A write after the read cannot have made what it shows either.
|
|
unseen.unplanned(2);
|
|
expect(adopt(1)).toBeUndefined();
|
|
expect(targets).not.toHaveBeenCalled();
|
|
expect(adopt(3)).toMatchObject({ id: "3" });
|
|
});
|
|
|
|
it("takes back no book a modelled delete removed, so a list showing it again confirms no delete", () => {
|
|
expect(
|
|
gated(deletedAfterRead([RENAME, IMPORT]), "delete_books_by_id"),
|
|
).toMatchObject({ served: true });
|
|
const rows: TimedCaptureRow[] = [
|
|
...BOOK_INIT_ROWS,
|
|
["POST", "/api/books", THIRD_BOOK, 201, { name: "Third Book" }],
|
|
["GET", "/api/books/3", THIRD_BOOK],
|
|
["DELETE", "/api/books/3", "", 204],
|
|
IMPORT,
|
|
// The import put the deleted book back; the copy must not take it as one the import made.
|
|
["GET", "/api/books/3", THIRD_BOOK],
|
|
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK, THIRD_BOOK]],
|
|
];
|
|
expect(gated(rows, "delete_books_by_id")).toMatchObject({
|
|
served: false,
|
|
checked_record_ordinals: [],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("an unmodelled write under another collection spelling", () => {
|
|
it("keeps a confirmed update when the write spells the held collection plainly", () => {
|
|
const acme = (name: string, status: string) => ({
|
|
id: 1,
|
|
org: "acme1",
|
|
name,
|
|
status,
|
|
});
|
|
const beta = (name: string) => ({
|
|
id: 2,
|
|
org: "beta2",
|
|
name,
|
|
status: "open",
|
|
});
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/orgs/%61cme1/books", [acme("Alpha", "open")]],
|
|
["GET", "/api/orgs/beta2/books", [beta("Beta")]],
|
|
["GET", "/api/orgs/%61cme1/books/1", acme("Alpha", "open")],
|
|
["GET", "/api/orgs/beta2/books/2", beta("Beta")],
|
|
[
|
|
"PATCH",
|
|
"/api/orgs/acme1/books/1",
|
|
acme("Renamed", "open"),
|
|
200,
|
|
{ name: "Renamed" },
|
|
],
|
|
[
|
|
"PATCH",
|
|
"/api/orgs/beta2/books/2",
|
|
beta("Renamed Beta"),
|
|
200,
|
|
{ name: "Renamed Beta" },
|
|
],
|
|
["GET", "/api/orgs/%61cme1/books/1", acme("Renamed", "open")],
|
|
["GET", "/api/orgs/beta2/books/2", beta("Renamed Beta")],
|
|
// Book 1 is held under the encoded collection. This archive spells it plainly.
|
|
["POST", "/api/orgs/acme1/books/1/archive", null, 204, {}],
|
|
// The response shows the status the archive changed. The patch sent only the name.
|
|
[
|
|
"PATCH",
|
|
"/api/orgs/%61cme1/books/1",
|
|
acme("Retitled", "archived"),
|
|
200,
|
|
{ name: "Retitled" },
|
|
],
|
|
];
|
|
const { writes } = environmentStateViewOf(timedCaptureEvidence(rows), [
|
|
"sibling-variance",
|
|
]);
|
|
expect(writes.find((plan) => plan.effect === "update")).toMatchObject({
|
|
served: true,
|
|
reason: null,
|
|
checked_record_ordinals: [6, 7],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("timed write observations and generated clocks", () => {
|
|
it("does not learn an update from a read that began before the write completed", () => {
|
|
const changed = { ...FIRST_BOOK, name: "Changed", slug: "changed" };
|
|
const rows: TimedCaptureRow[] = [
|
|
...BOOK_INIT_ROWS,
|
|
[
|
|
"PATCH",
|
|
"/api/books/1",
|
|
{ name: "Changed" },
|
|
200,
|
|
{ name: "Changed" },
|
|
2_000,
|
|
],
|
|
// This read starts before the PATCH ends and still shows the old value.
|
|
["GET", "/api/books/1", FIRST_BOOK],
|
|
["GET", "/api/books/1", changed],
|
|
["GET", "/api/books/1", changed],
|
|
];
|
|
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
|
served: true,
|
|
observation: { ordinal: 5 },
|
|
checked_record_ordinals: [6],
|
|
});
|
|
});
|
|
|
|
it("refuses a later update that leaves a learned clock unchanged", () => {
|
|
const afterFirst = {
|
|
...FIRST_BOOK,
|
|
name: "First update",
|
|
updated_at: bookClock(4),
|
|
};
|
|
const afterSecond = { ...afterFirst, name: "Second update" };
|
|
const rows: TimedCaptureRow[] = [
|
|
...BOOK_INIT_ROWS,
|
|
["PATCH", "/api/books/1", afterFirst, 200, { name: "First update" }],
|
|
["GET", "/api/books/1", afterFirst],
|
|
[
|
|
"PATCH",
|
|
"/api/books/1",
|
|
{ id: 1, name: "Second update" },
|
|
200,
|
|
{ name: "Second update" },
|
|
],
|
|
["GET", "/api/books/1", afterSecond],
|
|
];
|
|
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
|
served: false,
|
|
reason: "unexplained-change",
|
|
});
|
|
});
|
|
|
|
it("keeps a later update whose regenerated clock advances", () => {
|
|
const afterFirst = {
|
|
...FIRST_BOOK,
|
|
name: "First update",
|
|
updated_at: bookClock(4),
|
|
};
|
|
const afterSecond = {
|
|
...afterFirst,
|
|
name: "Second update",
|
|
updated_at: bookClock(6),
|
|
};
|
|
const rows: TimedCaptureRow[] = [
|
|
...BOOK_INIT_ROWS,
|
|
[
|
|
"PATCH",
|
|
"/api/books/1",
|
|
{ name: "First update" },
|
|
200,
|
|
{ name: "First update" },
|
|
],
|
|
["GET", "/api/books/1", afterFirst],
|
|
[
|
|
"PATCH",
|
|
"/api/books/1",
|
|
{ id: 1, name: "Second update" },
|
|
200,
|
|
{ name: "Second update" },
|
|
],
|
|
["GET", "/api/books/1", afterSecond],
|
|
["GET", "/api/books/1", afterSecond],
|
|
];
|
|
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [7],
|
|
});
|
|
});
|
|
|
|
it("leaves a later update unknown when neither its answer nor a read before the next write shows its learned clock", () => {
|
|
const summary = ({ id, name }: Book): Book => ({ id: id!, name: name! });
|
|
const renamed = renamedBook(FIRST_BOOK, "Renamed Book", 3);
|
|
const retitled = renamedBook(renamed, "Retitled Book", 5);
|
|
const rows = (answer: JsonValue, status: number): TimedCaptureRow[] => [
|
|
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK]],
|
|
["GET", "/api/books/1", summary(FIRST_BOOK)],
|
|
["GET", "/api/books/2", summary(SECOND_BOOK)],
|
|
["PATCH", "/api/books/1", renamed, 200, { name: "Renamed Book" }],
|
|
["GET", "/api/books", [renamed, SECOND_BOOK]],
|
|
["PATCH", "/api/books/1", answer, status, { name: "Retitled Book" }],
|
|
// The item view shows no clock; the list shows the one the second call stamped.
|
|
["GET", "/api/books/1", summary(retitled)],
|
|
["GET", "/api/books", [retitled, SECOND_BOOK]],
|
|
];
|
|
expect(gated(rows("", 204), "patch_books_by_id")).toMatchObject({
|
|
served: true,
|
|
rules: expect.arrayContaining([
|
|
expect.objectContaining({ field: "updated_at", rule: "clock" }),
|
|
]),
|
|
checked_record_ordinals: [4],
|
|
contradicted_by: null,
|
|
});
|
|
// An answer that shows the clock observes the call, so the list checks it.
|
|
expect(gated(rows(retitled, 200), "patch_books_by_id")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [4, 6, 7],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("partial update observations", () => {
|
|
it("keeps write-response fields when the item view omits them", () => {
|
|
const first: Book = { ...FIRST_BOOK, description: "First description" };
|
|
const second: Book = { ...SECOND_BOOK, description: "Second description" };
|
|
const itemSummary = (entry: Book): Book => ({
|
|
id: entry.id!,
|
|
name: entry.name!,
|
|
});
|
|
const renamed = {
|
|
...first,
|
|
name: "Renamed first",
|
|
slug: "renamed-first",
|
|
updated_at: bookClock(3),
|
|
};
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books", [first, second]],
|
|
["GET", "/api/books/1", itemSummary(first)],
|
|
["GET", "/api/books/2", itemSummary(second)],
|
|
["PATCH", "/api/books/1", renamed, 200, { name: "Renamed first" }],
|
|
["GET", "/api/books/1", itemSummary(renamed)],
|
|
[
|
|
"PATCH",
|
|
"/api/books/2",
|
|
{ id: 2, name: second.name!, description: "Revised description" },
|
|
200,
|
|
{ description: "Revised description" },
|
|
],
|
|
// This item view covers only id/name, not description or the learned clock.
|
|
["GET", "/api/books/2", itemSummary(second)],
|
|
];
|
|
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
|
served: true,
|
|
reason: null,
|
|
checked_record_ordinals: [4],
|
|
rules: [
|
|
{ field: "name", rule: "echo" },
|
|
{ field: "slug", rule: "slug", from: "name" },
|
|
{ field: "updated_at", rule: "clock" },
|
|
{ field: "description", rule: "echo" },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("uses read coverage without dropping fields shown by the write response", () => {
|
|
const first: Book = { ...FIRST_BOOK, description: "First description" };
|
|
const second: Book = { ...SECOND_BOOK, description: "Second description" };
|
|
const itemSummary = (entry: Book): Book => ({
|
|
id: entry.id!,
|
|
name: entry.name!,
|
|
updated_at: entry.updated_at!,
|
|
});
|
|
const renamed = {
|
|
...first,
|
|
name: "Renamed first",
|
|
slug: "renamed-first",
|
|
updated_at: bookClock(3),
|
|
};
|
|
const revised = {
|
|
id: 2,
|
|
name: second.name!,
|
|
description: "Revised description",
|
|
};
|
|
const revisedSummary = {
|
|
id: 2,
|
|
name: second.name!,
|
|
updated_at: bookClock(6),
|
|
};
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books", [first, second]],
|
|
["GET", "/api/books/1", itemSummary(first)],
|
|
["GET", "/api/books/2", itemSummary(second)],
|
|
["PATCH", "/api/books/1", renamed, 200, { name: "Renamed first" }],
|
|
["GET", "/api/books/1", itemSummary(renamed)],
|
|
[
|
|
"PATCH",
|
|
"/api/books/2",
|
|
revised,
|
|
200,
|
|
{ description: "Revised description" },
|
|
],
|
|
// The item view covers the clock, but not the description returned by PATCH.
|
|
["GET", "/api/books/2", revisedSummary],
|
|
];
|
|
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
|
served: true,
|
|
reason: null,
|
|
checked_record_ordinals: [4],
|
|
rules: [
|
|
{ field: "name", rule: "echo" },
|
|
{ field: "slug", rule: "slug", from: "name" },
|
|
{ field: "updated_at", rule: "clock" },
|
|
{ field: "description", rule: "echo" },
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("what later calls must keep consistent", () => {
|
|
it("refuses an update one call sends an ignored id with and another the path's id", () => {
|
|
const renamed = (at: Book, name: string) => ({ ...at, name });
|
|
const rows: TimedCaptureRow[] = [
|
|
...BOOK_INIT_ROWS,
|
|
[
|
|
"PATCH",
|
|
"/api/books/1",
|
|
renamed(FIRST_BOOK, "B"),
|
|
200,
|
|
{ id: 999, name: "B" },
|
|
],
|
|
["GET", "/api/books/1", renamed(FIRST_BOOK, "B")],
|
|
[
|
|
"PATCH",
|
|
"/api/books/2",
|
|
renamed(SECOND_BOOK, "C"),
|
|
200,
|
|
{ id: 2, name: "C" },
|
|
],
|
|
["GET", "/api/books/2", renamed(SECOND_BOOK, "C")],
|
|
];
|
|
expect(gated(rows, "patch_books_by_id")).toMatchObject({
|
|
served: false,
|
|
reason: "unexplained-change",
|
|
});
|
|
});
|
|
|
|
it("refuses a create whose later call records a clock in another format", () => {
|
|
const epoch = (iso: string) => Math.floor(Date.parse(iso) / 1000);
|
|
const fourth = {
|
|
...book(4, "Fourth Book", bookClock(5)),
|
|
created_at: epoch(bookClock(5)),
|
|
updated_at: epoch(bookClock(5)),
|
|
};
|
|
const rows: TimedCaptureRow[] = [
|
|
...BOOK_INIT_ROWS,
|
|
["POST", "/api/books", THIRD_BOOK, 201, { name: "Third Book" }],
|
|
["GET", "/api/books/3", THIRD_BOOK],
|
|
["POST", "/api/books", fourth, 201, { name: "Fourth Book" }],
|
|
["GET", "/api/books/4", fourth],
|
|
];
|
|
expect(gated(rows, "post_books")).toMatchObject({
|
|
served: false,
|
|
reason: "unexplained-change",
|
|
});
|
|
});
|
|
|
|
it("names no entity by a delete whose path id decodes to it only twice, as serving would not", () => {
|
|
const uid = (key: string) => ({ uid: key, name: key });
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books", [uid("book1"), uid("book2")]],
|
|
["GET", "/api/books/uid/book1", uid("book1")],
|
|
["GET", "/api/books/uid/book2", uid("book2")],
|
|
["DELETE", "/api/books/uid/book%2531", "", 204],
|
|
["GET", "/api/books/uid/book1", BOOK_NOT_FOUND, 404],
|
|
];
|
|
const { writes } = environmentStateViewOf(timedCaptureEvidence(rows), [
|
|
"after-uid-literal",
|
|
]);
|
|
expect(writes.find((plan) => plan.effect === "delete")).toMatchObject({
|
|
served: false,
|
|
checked_record_ordinals: [],
|
|
});
|
|
});
|
|
|
|
it("does not let a later empty ?page=2 page contradict a create", () => {
|
|
const alpha = { id: 1, name: "Alpha", org: "1" };
|
|
const beta = { id: 2, name: "Beta", org: "2" };
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/orgs/1/books", [alpha]],
|
|
["GET", "/api/orgs/1/books/1", alpha],
|
|
["GET", "/api/orgs/2/books?page=2", []],
|
|
["POST", "/api/orgs/2/books", beta, 201, { name: "Beta" }],
|
|
["GET", "/api/orgs/2/books/2", beta],
|
|
["GET", "/api/orgs/2/books?page=2", []],
|
|
];
|
|
const { writes } = environmentStateViewOf(timedCaptureEvidence(rows));
|
|
expect(writes.find((plan) => plan.effect === "create")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [4],
|
|
});
|
|
});
|
|
|
|
it.each(["offset", "start", "_start"])(
|
|
"does not let a later empty ?%s=1 page contradict a create",
|
|
(name) => {
|
|
const alpha = { id: 1, name: "Alpha", org: "1" };
|
|
const beta = { id: 2, name: "Beta", org: "2" };
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/orgs/1/books", [alpha]],
|
|
["GET", "/api/orgs/1/books/1", alpha],
|
|
["GET", `/api/orgs/2/books?${name}=1`, []],
|
|
["POST", "/api/orgs/2/books", beta, 201, { name: "Beta" }],
|
|
["GET", "/api/orgs/2/books/2", beta],
|
|
["GET", `/api/orgs/2/books?${name}=1`, []],
|
|
];
|
|
const { writes } = environmentStateViewOf(timedCaptureEvidence(rows));
|
|
expect(writes.find((plan) => plan.effect === "create")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [4],
|
|
});
|
|
},
|
|
);
|
|
|
|
it("checks nothing by a whole line recorded empty under a zero limit once a create fills its collection", () => {
|
|
const alpha = { id: 1, name: "Alpha", org: "1" };
|
|
const beta = { id: 2, name: "Beta", org: "2" };
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/orgs/1/books", [alpha]],
|
|
["GET", "/api/orgs/1/books/1", alpha],
|
|
["GET", "/api/orgs/2/books?limit=0", []],
|
|
["POST", "/api/orgs/2/books", beta, 201, { name: "Beta" }],
|
|
["GET", "/api/orgs/2/books/2", beta],
|
|
// An empty recording does not show that zero lists every entity.
|
|
["GET", "/api/orgs/2/books?limit=0", []],
|
|
];
|
|
const { writes } = environmentStateViewOf(timedCaptureEvidence(rows));
|
|
expect(writes.find((plan) => plan.effect === "create")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [4],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("creates tied to one collection", () => {
|
|
it("does not let a later create in another collection confirm the plan", () => {
|
|
expect(gated(tiedCreateRows(false), "post_orgs_books_by_id")).toMatchObject(
|
|
{
|
|
served: false,
|
|
reason: "unconfirmed",
|
|
collection: "/api/orgs/1/books",
|
|
checked_record_ordinals: [],
|
|
},
|
|
);
|
|
});
|
|
|
|
it("still confirms the plan from a read of the collection it is tied to", () => {
|
|
expect(gated(tiedCreateRows(true), "post_orgs_books_by_id")).toMatchObject({
|
|
served: true,
|
|
collection: "/api/orgs/1/books",
|
|
checked_record_ordinals: [5],
|
|
});
|
|
});
|
|
|
|
const acmeRows = (
|
|
laterPath: string,
|
|
heldEncoded: boolean,
|
|
): TimedCaptureRow[] => {
|
|
const alpha = {
|
|
...book(1, "Alpha", "2026-10-01T09:00:00Z"),
|
|
team: "/orgs/uid/acme1",
|
|
};
|
|
const made = tiedToAcme(2, "Made", 2);
|
|
const other = tiedToAcme(3, "Other", heldEncoded ? 5 : 3);
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/orgs/uid/acme1/books", [alpha]],
|
|
["GET", "/api/orgs/uid/acme1/books/1", alpha],
|
|
];
|
|
if (heldEncoded) {
|
|
const encoded = {
|
|
...book(10, "Encoded", "2026-10-01T10:00:00Z"),
|
|
team: "/orgs/uid/acme1",
|
|
};
|
|
rows.push(
|
|
["GET", "/api/orgs/uid/%61cme1/books", [encoded]],
|
|
["GET", "/api/orgs/uid/%61cme1/books/10", encoded],
|
|
);
|
|
}
|
|
rows.push(
|
|
["POST", "/api/orgs/uid/acme1/books", made, 201, { name: "Made" }],
|
|
["POST", laterPath, other, 201, { name: "Other" }],
|
|
["GET", "/api/orgs/uid/acme1/books/3", other],
|
|
);
|
|
return rows;
|
|
};
|
|
|
|
it("confirms a tied create whose later call spells the held collection encoded", () => {
|
|
expect(
|
|
acmeCreate(acmeRows("/api/orgs/uid/%61cme1/books", false)),
|
|
).toMatchObject({
|
|
served: true,
|
|
collection: "/api/orgs/uid/acme1/books",
|
|
checked_record_ordinals: [4],
|
|
});
|
|
});
|
|
|
|
it("does not treat an encoded spelling as the tied collection when that spelling is held itself", () => {
|
|
// Serving keeps the verbatim encoded collection, outside the plain tie. The plain read of the
|
|
// entity that create did not add is related to the exemplar, so the plan is a side effect.
|
|
expect(
|
|
acmeCreate(acmeRows("/api/orgs/uid/%61cme1/books", true)),
|
|
).toMatchObject({
|
|
served: false,
|
|
reason: "side-effect",
|
|
collection: "/api/orgs/uid/acme1/books",
|
|
checked_record_ordinals: [],
|
|
});
|
|
});
|
|
|
|
it("chooses among emptied spellings of a tied collection in the order serving remembers them", () => {
|
|
const first = "/api/orgs/uid/%61cme1/books";
|
|
const second = "/api/orgs/uid/a%63me1/books";
|
|
const alpha = {
|
|
...book(1, "Alpha", "2026-10-01T09:00:00Z"),
|
|
team: "/orgs/uid/acme1",
|
|
};
|
|
const beta = {
|
|
...book(10, "Beta", "2026-10-01T10:00:00Z"),
|
|
team: "/orgs/uid/acme1",
|
|
};
|
|
const made = tiedToAcme(2, "Made", 4);
|
|
const other = tiedToAcme(3, "Other", 8);
|
|
// The second spelling empties before the first, but serving still offers the first spelling first.
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", first, [alpha]],
|
|
["GET", second, [beta]],
|
|
["GET", `${first}/1`, alpha],
|
|
["GET", `${second}/10`, beta],
|
|
["POST", second, made, 201, { name: "Made" }],
|
|
["DELETE", `${second}/10`, "", 204],
|
|
["DELETE", `${second}/2`, "", 204],
|
|
["DELETE", `${first}/1`, "", 204],
|
|
["POST", "/api/orgs/uid/acme1/books", other, 201, { name: "Other" }],
|
|
["GET", "/api/orgs/uid/acme1/books/3", other],
|
|
];
|
|
expect(acmeCreate(rows)).toMatchObject({
|
|
collection: second,
|
|
checked_record_ordinals: [],
|
|
});
|
|
});
|
|
|
|
it("keeps a tied collection ahead of another spelling once its first entity is gone, as serving orders them", () => {
|
|
const first = "/api/orgs/uid/%61cme1/books";
|
|
const second = "/api/orgs/uid/a%63me1/books";
|
|
const alpha = {
|
|
...book(1, "Alpha", "2026-10-01T09:00:00Z"),
|
|
team: "/orgs/uid/acme1",
|
|
};
|
|
const beta = {
|
|
...book(10, "Beta", "2026-10-01T10:00:00Z"),
|
|
team: "/orgs/uid/acme1",
|
|
};
|
|
const made = tiedToAcme(2, "Made", 4);
|
|
const other = tiedToAcme(3, "Other", 6);
|
|
// Serving places the created book beside its collection, so the first spelling still leads after its first book goes.
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", first, [alpha]],
|
|
["GET", second, [beta]],
|
|
["GET", `${first}/1`, alpha],
|
|
["GET", `${second}/10`, beta],
|
|
["POST", first, made, 201, { name: "Made" }],
|
|
["DELETE", `${first}/1`, "", 204],
|
|
["POST", "/api/orgs/uid/acme1/books", other, 201, { name: "Other" }],
|
|
["GET", "/api/orgs/uid/acme1/books/3", other],
|
|
];
|
|
expect(acmeCreate(rows)).toMatchObject({
|
|
served: true,
|
|
collection: first,
|
|
checked_record_ordinals: [7],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("path fields of a create", () => {
|
|
const held = "/api/orgs/uid/%2561cme1/books";
|
|
/** A create held under a doubly encoded parent, then one whose call spells the parent once encoded, read back with `org`. */
|
|
const rowsWith = (org: string): TimedCaptureRow[] => {
|
|
const alpha = {
|
|
...book(1, "Alpha", "2026-10-01T09:00:00Z"),
|
|
org: "%61cme1",
|
|
};
|
|
const made = { ...book(2, "Made", bookClock(2)), org: "%61cme1" };
|
|
const other = { ...book(3, "Other", bookClock(3)), org };
|
|
return [
|
|
["GET", held, [alpha]],
|
|
["GET", `${held}/1`, alpha],
|
|
["POST", held, made, 201, { name: "Made" }],
|
|
["POST", "/api/orgs/uid/%61cme1/books", other, 201, { name: "Other" }],
|
|
["GET", `${held}/3`, other],
|
|
];
|
|
};
|
|
const planOf = (rows: TimedCaptureRow[]) =>
|
|
environmentStateViewOf(timedCaptureEvidence(rows), [
|
|
"after-uid-literal",
|
|
]).writes.find((candidate) => candidate.effect === "create");
|
|
|
|
it("contradicts a later create whose read shows a parent serving would not write", () => {
|
|
expect(planOf(rowsWith("acme1"))).toMatchObject({
|
|
served: false,
|
|
checked_record_ordinals: [],
|
|
contradicted_by: 4,
|
|
});
|
|
});
|
|
|
|
it("confirms a later create whose read shows the parent decoded from the collection serving selects", () => {
|
|
expect(planOf(rowsWith("%61cme1"))).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [4],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("path fields of a create tied to its collection", () => {
|
|
const held = "/api/orgs/uid/%61cme1/books";
|
|
|
|
/** A tied create in the held encoded collection, then one spelled double-encoded whose parent the application stored as `org`. */
|
|
const parentRows = (org: string): TimedCaptureRow[] => {
|
|
const alpha = {
|
|
...book(1, "Alpha", "2026-10-01T09:00:00Z"),
|
|
team: "/orgs/uid/acme1",
|
|
org: "acme1",
|
|
};
|
|
const made = { ...tiedToAcme(2, "Made", 2), org: "acme1" };
|
|
const other = { ...tiedToAcme(3, "Other", 3), org };
|
|
return [
|
|
["GET", held, [alpha]],
|
|
["GET", `${held}/1`, alpha],
|
|
["POST", held, made, 201, { name: "Made" }],
|
|
["POST", "/api/orgs/uid/%2561cme1/books", other, 201, { name: "Other" }],
|
|
["GET", `${held}/3`, other],
|
|
];
|
|
};
|
|
|
|
it("contradicts a later create whose read shows a parent serving would not write", () => {
|
|
// Serving selects the held collection and decodes the parent from it, not from the call's spelling.
|
|
expect(acmeCreate(parentRows("%61cme1"))).toMatchObject({
|
|
served: false,
|
|
collection: held,
|
|
rules: expect.arrayContaining([
|
|
expect.objectContaining({ field: "org", rule: "path" }),
|
|
]),
|
|
checked_record_ordinals: [],
|
|
contradicted_by: 4,
|
|
});
|
|
});
|
|
|
|
it("confirms a later create whose read shows the parent decoded from the selected collection", () => {
|
|
expect(acmeCreate(parentRows("acme1"))).toMatchObject({
|
|
served: true,
|
|
collection: held,
|
|
checked_record_ordinals: [4],
|
|
});
|
|
});
|
|
});
|
|
|
|
it("withholds a create when a sibling read through an encoded collection reveals an extra change", () => {
|
|
const alpha = { id: 1, name: "Alpha" };
|
|
const beta = { id: 2, name: "Beta" };
|
|
const gamma = { id: 3, name: "Gamma" };
|
|
const delta = { id: 4, name: "Delta" };
|
|
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],
|
|
["POST", "/api/orgs/acme1/books", gamma, 201, { name: "Gamma" }],
|
|
["GET", "/api/orgs/acme1/books/3", gamma],
|
|
["GET", "/api/orgs/%61cme1/books/1", { ...alpha, name: "Changed" }],
|
|
["POST", "/api/orgs/beta2/books", delta, 201, { name: "Delta" }],
|
|
];
|
|
const state = environmentStateViewOf(timedCaptureEvidence(rows), [
|
|
"sibling-variance",
|
|
]);
|
|
expect(state.writes.find((plan) => plan.effect === "create")).toMatchObject({
|
|
served: false,
|
|
reason: "side-effect",
|
|
});
|
|
});
|
|
|
|
it("learns omitted fields using the raw ID resolved by the item owner", () => {
|
|
const state = new SimulatedState(
|
|
[
|
|
{
|
|
type: "/api/books",
|
|
collection: "/api/books",
|
|
id: "%63",
|
|
fields: { id: "%63", name: "Created" },
|
|
view_fields: [],
|
|
record_ordinals: [],
|
|
},
|
|
],
|
|
[],
|
|
);
|
|
const view = {
|
|
tool: "get_books_by_id",
|
|
type: "/api/books",
|
|
shape: "item" as const,
|
|
pointer: "",
|
|
fields: ["id", "name", "detail"],
|
|
required_fields: ["id", "name", "detail"],
|
|
checked_record_ordinals: [0],
|
|
envelope_ordinal: 0,
|
|
};
|
|
const record = {
|
|
ordinal: 1,
|
|
method: "GET",
|
|
path: "/api/books/%63",
|
|
query: undefined,
|
|
status: 200,
|
|
timing: { start: undefined, endMs: undefined },
|
|
};
|
|
const target = itemTarget(state, view.type, record);
|
|
expect(target.id).toBe("%63");
|
|
const read = {
|
|
body: { id: "%63", name: "Created" },
|
|
base: { id: "seed", name: "Seed", detail: "x" },
|
|
volatile: new Set<string>(),
|
|
idField: "id",
|
|
collection: target.collection,
|
|
itemId: target.id,
|
|
};
|
|
const omitted = new OmittedFields(() => {});
|
|
const outcome = omitted.check(state, view, record, read, (loosened) =>
|
|
checkItemRead(state, loosened, record, read),
|
|
);
|
|
expect(outcome).toMatchObject({ pass: true, omitted: ["detail"] });
|
|
if (outcome === undefined) throw new Error("Expected a counted read");
|
|
omitted.keep(view, outcome);
|
|
expect(omitted.views([view])[0]).toMatchObject({
|
|
required_fields: ["id", "name"],
|
|
});
|
|
});
|
|
|
|
describe("create exemplar retention", () => {
|
|
const facts = () => {
|
|
const snapshot = { id: "held", payload: "p".repeat(1_000_000) };
|
|
const held = Array.from({ length: 1000 }, () => snapshot);
|
|
return {
|
|
entity: { id: "new", url: "/new" },
|
|
call: {
|
|
path: "/items",
|
|
pathValues: new Map<string, string>(),
|
|
body: { payload: "x".repeat(10_000) },
|
|
},
|
|
idField: "id",
|
|
startMs: undefined,
|
|
collection: held,
|
|
type: held,
|
|
};
|
|
};
|
|
it("charges temporary request facts and shared snapshot references without rebilling owned entity payloads", () => {
|
|
let retained = 0;
|
|
const context = {
|
|
io: {
|
|
retain: (bytes: number) => {
|
|
retained += bytes;
|
|
},
|
|
},
|
|
} as unknown as EffectContext;
|
|
const input = facts();
|
|
expect(retainCreateExemplar(context, input, [])).toBe(input);
|
|
expect(retained).toBeGreaterThan(28_000);
|
|
expect(retained).toBeLessThan(40_000);
|
|
});
|
|
it("retains no exemplar when every string field is already explained", () => {
|
|
const retain = vi.fn();
|
|
const context = { io: { retain } } as unknown as EffectContext;
|
|
expect(
|
|
retainCreateExemplar(context, facts(), [
|
|
{ field: "id", rule: "echo" },
|
|
{ field: "url", rule: "echo" },
|
|
]),
|
|
).toBeUndefined();
|
|
expect(retain).not.toHaveBeenCalled();
|
|
});
|
|
it("does not adopt request facts when the retained budget refuses them", () => {
|
|
const context = {
|
|
io: {
|
|
retain: () => {
|
|
throw new Error("budget exhausted");
|
|
},
|
|
},
|
|
} as unknown as EffectContext;
|
|
expect(() => retainCreateExemplar(context, facts(), [])).toThrow(
|
|
"budget exhausted",
|
|
);
|
|
});
|
|
});
|
|
it.each([
|
|
{
|
|
label: "point read with simultaneous unknown action",
|
|
archiveStart: 4,
|
|
duration: 0,
|
|
archiveDuration: -1,
|
|
target: "1",
|
|
served: false,
|
|
},
|
|
{
|
|
label: "point read with simultaneous point action",
|
|
archiveStart: 4,
|
|
duration: 0,
|
|
archiveDuration: 0,
|
|
target: "1",
|
|
served: true,
|
|
},
|
|
{
|
|
label: "point read with unrelated unknown action",
|
|
archiveStart: 4,
|
|
duration: 0,
|
|
archiveDuration: -1,
|
|
target: "2",
|
|
served: true,
|
|
},
|
|
{
|
|
label: "same-start later ordinal",
|
|
archiveStart: 4,
|
|
duration: 5,
|
|
target: "1",
|
|
served: false,
|
|
},
|
|
{
|
|
label: "later start within response interval",
|
|
archiveStart: 5,
|
|
duration: 1500,
|
|
target: "1",
|
|
served: false,
|
|
},
|
|
{
|
|
label: "unknown response completion",
|
|
archiveStart: 5,
|
|
duration: -1,
|
|
target: "1",
|
|
served: false,
|
|
},
|
|
{
|
|
label: "known disjoint response interval",
|
|
archiveStart: 6,
|
|
duration: 1500,
|
|
target: "1",
|
|
served: true,
|
|
},
|
|
{
|
|
label: "unrelated overlapping write",
|
|
archiveStart: 5,
|
|
duration: 1500,
|
|
target: "2",
|
|
served: true,
|
|
},
|
|
])(
|
|
"requires a read free of captured write overlap: $label",
|
|
({ archiveStart, duration, target, served, ...options }) => {
|
|
const rows: TimedCaptureRow[] = [
|
|
[
|
|
"GET",
|
|
"/api/books",
|
|
[
|
|
{ id: 1, name: "A" },
|
|
{ id: 2, name: "B" },
|
|
],
|
|
],
|
|
["GET", "/api/books/1", { id: 1, name: "A" }],
|
|
["GET", "/api/books/2", { id: 2, name: "B" }],
|
|
["PATCH", "/api/books/1", { id: 1, name: "Z" }, 200, { name: "Z" }],
|
|
["GET", "/api/books/1", { id: 1, name: "Z" }, 200, undefined, duration],
|
|
[
|
|
"POST",
|
|
`/api/books/${target}/archive`,
|
|
null,
|
|
204,
|
|
{},
|
|
"archiveDuration" in options ? options.archiveDuration : 5,
|
|
],
|
|
];
|
|
const entries = rows.map(
|
|
(
|
|
[method, path, body = null, status = 200, request, time = 5],
|
|
index,
|
|
) => ({
|
|
startedDateTime: new Date(
|
|
Date.parse(bookClock(index === 5 ? archiveStart : index)),
|
|
).toISOString(),
|
|
time,
|
|
request: {
|
|
method,
|
|
url: `http://app.example${path}`,
|
|
bodySize:
|
|
request === undefined
|
|
? 0
|
|
: Buffer.byteLength(JSON.stringify(request)),
|
|
...(request === undefined
|
|
? {}
|
|
: {
|
|
postData: {
|
|
mimeType: "application/json",
|
|
text: JSON.stringify(request),
|
|
},
|
|
}),
|
|
},
|
|
response: {
|
|
status,
|
|
content: { mimeType: "application/json", text: JSON.stringify(body) },
|
|
},
|
|
}),
|
|
);
|
|
const capture = analysisViewCaptureEvidence(
|
|
environmentManifestCaptureFixture(entries),
|
|
);
|
|
const plan = environmentStateViewOf(capture).writes.find(
|
|
(write) => write.tool === "patch_books_by_id",
|
|
);
|
|
expect(plan).toMatchObject({
|
|
served,
|
|
checked_record_ordinals: served ? [4] : [],
|
|
contradicted_by: null,
|
|
});
|
|
},
|
|
);
|