mirror of
https://github.com/morluto/rea.git
synced 2026-11-03 05:16:16 +00:00
Share clock rendering between analysis and local serving. Preserve exact sub-millisecond facts, bound learned format state per field, and charge retained output to the projection budget. Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
1262 lines
44 KiB
TypeScript
1262 lines
44 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import type { JsonValue } from "../jsonValue.js";
|
|
import {
|
|
timedCaptureEvidence,
|
|
type TimedCaptureRow,
|
|
} from "../../../tests/fixtures/analysisView.js";
|
|
import {
|
|
ASSET_ROWS,
|
|
BOOK_INIT_ROWS,
|
|
BOOK_NOT_FOUND,
|
|
BOOK_ROWS,
|
|
BOOK_WRITE_CLOCK,
|
|
DETAILED_LIST_ROWS,
|
|
FIRST_BOOK,
|
|
OMITTED_FIELD_ROWS,
|
|
PROJECTED_ROWS,
|
|
RENAMED_BOOK,
|
|
HANDLE_ROWS,
|
|
LIST_LINE_ROWS,
|
|
QUERY_ROWS,
|
|
OFFSET_LIST_ROWS,
|
|
SECOND_BOOK,
|
|
THIRD_BOOK,
|
|
assetBook,
|
|
VERSIONED_ROWS,
|
|
book,
|
|
numberedUuid,
|
|
versionedBook,
|
|
offsetListed,
|
|
bookClock,
|
|
detailedBook,
|
|
environmentStateViewOf,
|
|
rawCaptureEvidence,
|
|
renamedBook,
|
|
type Book,
|
|
} from "../../../tests/fixtures/environmentStateWrites.js";
|
|
|
|
type StateView = ReturnType<typeof environmentStateViewOf>;
|
|
|
|
const ISO_SECONDS = {
|
|
kind: "iso",
|
|
separator: "T",
|
|
fraction: 0,
|
|
zone: "Z",
|
|
} as const;
|
|
|
|
const stateOf = (rows: readonly TimedCaptureRow[]): StateView =>
|
|
environmentStateViewOf(timedCaptureEvidence(rows));
|
|
|
|
const planOf = (state: StateView, tool: string) => {
|
|
const plan = state.writes.find((candidate) => candidate.tool === tool);
|
|
if (plan === undefined) throw new Error(`No write plan for ${tool}`);
|
|
return plan;
|
|
};
|
|
|
|
/** Each write tool's verdict: "served", else the reason it is not. */
|
|
const verdicts = (rows: readonly TimedCaptureRow[]) =>
|
|
Object.fromEntries(
|
|
stateOf(rows).writes.map((plan) => [
|
|
plan.tool,
|
|
plan.served ? "served" : plan.reason,
|
|
]),
|
|
);
|
|
|
|
/** A create of the third book whose response is `response`, then the given later exchanges. */
|
|
const created = (
|
|
response: JsonValue,
|
|
...later: TimedCaptureRow[]
|
|
): TimedCaptureRow[] => [
|
|
...BOOK_INIT_ROWS,
|
|
["POST", "/api/books", response, 201, { name: "Third Book" }],
|
|
...later,
|
|
];
|
|
|
|
const RENAMED_AT_3 = renamedBook(FIRST_BOOK, "Renamed Book", 3);
|
|
/** An update renaming the first book whose response is `response`, then the given later exchanges. */
|
|
const rename = (
|
|
response: Book = RENAMED_AT_3,
|
|
...later: TimedCaptureRow[]
|
|
): TimedCaptureRow[] => [
|
|
...BOOK_INIT_ROWS,
|
|
["PATCH", "/api/books/1", response, 200, { name: "Renamed Book" }],
|
|
...later,
|
|
];
|
|
|
|
describe("served write plans", () => {
|
|
it("serves the create, update and delete that later reads confirm, citing those reads", () => {
|
|
const state = stateOf(BOOK_ROWS);
|
|
expect(state.write_clock).toBe(BOOK_WRITE_CLOCK);
|
|
expect(planOf(state, "post_books")).toEqual({
|
|
tool: "post_books",
|
|
effect: "create",
|
|
target: "/api/books",
|
|
served: true,
|
|
reason: null,
|
|
exemplar_ordinal: 3,
|
|
observation: { ordinal: 3, pointer: "" },
|
|
response: { ordinal: 3, shape: "entity", pointer: "" },
|
|
body: {
|
|
accepted: ["name"],
|
|
fixed: [],
|
|
required: ["name"],
|
|
identity: null,
|
|
kinds: [],
|
|
},
|
|
rules: [
|
|
{ field: "id", rule: "identity", generator: "sequence" },
|
|
{ field: "name", rule: "echo" },
|
|
{ field: "slug", rule: "slug", from: "name" },
|
|
{ field: "created_at", rule: "clock", format: ISO_SECONDS },
|
|
{ field: "updated_at", rule: "clock", format: ISO_SECONDS },
|
|
],
|
|
collection: null,
|
|
insert: { rule: "sorted", field: "id", direction: "ascending" },
|
|
merge: null,
|
|
// The final list follows the delete of book 3, so it shows nothing of the create.
|
|
checked_record_ordinals: [4],
|
|
contradicted_by: null,
|
|
});
|
|
// PATCH requires no member; the slug follows the sent name and the update stamps its clock.
|
|
expect(planOf(state, "patch_books_by_id")).toMatchObject({
|
|
effect: "update",
|
|
served: true,
|
|
exemplar_ordinal: 5,
|
|
body: { accepted: ["name"], required: [] },
|
|
rules: [
|
|
{ field: "name", rule: "echo" },
|
|
{ field: "slug", rule: "slug", from: "name" },
|
|
{ field: "updated_at", rule: "clock", format: ISO_SECONDS },
|
|
],
|
|
checked_record_ordinals: [6, 9],
|
|
});
|
|
expect(planOf(state, "delete_books_by_id")).toMatchObject({
|
|
effect: "delete",
|
|
served: true,
|
|
exemplar_ordinal: 7,
|
|
response: { ordinal: 7, shape: "empty", pointer: null },
|
|
checked_record_ordinals: [8, 9],
|
|
});
|
|
expect(planOf(state, "post_exports")).toMatchObject({
|
|
effect: "none",
|
|
target: null,
|
|
served: false,
|
|
reason: "no-state-target",
|
|
});
|
|
});
|
|
|
|
it("starts the clock from the records a date can hold when one lies past its range", () => {
|
|
const state = stateOf([
|
|
["GET", "/api/books", [FIRST_BOOK]],
|
|
["GET", "/api/books/1", FIRST_BOOK],
|
|
[
|
|
"PATCH",
|
|
"/api/books/1",
|
|
RENAMED_AT_3,
|
|
200,
|
|
{ name: "Renamed Book" },
|
|
9_000_000_000_000_000,
|
|
],
|
|
]);
|
|
// The second read ends at 18:00:01.005, so the clock starts at the next whole second.
|
|
expect(state.write_clock).toBe("2026-10-17T18:00:02.000Z");
|
|
});
|
|
|
|
it("starts no clock on a record-order timeline, where no value is generated by a clock", () => {
|
|
const state = environmentStateViewOf(
|
|
rawCaptureEvidence(
|
|
created(THIRD_BOOK, ["GET", "/api/books/3", THIRD_BOOK]),
|
|
{
|
|
mimeType: "application/json",
|
|
timed: false,
|
|
},
|
|
),
|
|
);
|
|
expect(state.timeline).toBe("record-order");
|
|
expect(state.write_clock).toBeNull();
|
|
const plan = planOf(state, "post_books");
|
|
expect(plan).toMatchObject({ served: true, checked_record_ordinals: [4] });
|
|
// Without start times the clock fields keep the recorded values as constants.
|
|
expect(plan.rules.map(({ field }) => field)).toEqual([
|
|
"id",
|
|
"name",
|
|
"slug",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("wider write coverage", () => {
|
|
it("drops a field from an item view's required ones once a read shows the application leaves it out", () => {
|
|
const state = stateOf(OMITTED_FIELD_ROWS);
|
|
expect(planOf(state, "post_books")).toMatchObject({ served: true });
|
|
expect(
|
|
state.views.find((view) => view.tool === "get_books_by_id"),
|
|
).toMatchObject({
|
|
shape: "item",
|
|
fields: expect.arrayContaining(["detail"]),
|
|
});
|
|
expect(
|
|
state.views.find((view) => view.tool === "get_books_by_id"),
|
|
).not.toMatchObject({
|
|
required_fields: expect.arrayContaining(["detail"]),
|
|
});
|
|
});
|
|
|
|
it("proves an item's identity past a read that asked for some fields only, leaving that read to replay", () => {
|
|
expect(
|
|
stateOf(PROJECTED_ROWS).views.find(
|
|
(view) => view.tool === "get_books_by_id",
|
|
),
|
|
).toMatchObject({
|
|
shape: "item",
|
|
checked_record_ordinals: [1, 2],
|
|
projected_record_ordinals: [3],
|
|
});
|
|
});
|
|
|
|
it("serves creates whose number follows the largest held one in its digit groups", () => {
|
|
expect(planOf(stateOf(ASSET_ROWS), "post_books")).toMatchObject({
|
|
served: true,
|
|
rules: expect.arrayContaining([
|
|
{ field: "asset", rule: "sequence", scope: "collection" },
|
|
]),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("values the application assigns", () => {
|
|
it("serves updates that renew a sent version and the ids of rewritten child rows", () => {
|
|
expect(planOf(stateOf(VERSIONED_ROWS), "patch_books_by_id")).toMatchObject({
|
|
served: true,
|
|
body: { accepted: ["name", "version_id"], fixed: [] },
|
|
rules: expect.arrayContaining([
|
|
{ field: "version_id", rule: "unique" },
|
|
{ field: "chapters", rule: "renewed", pointers: ["/*/id"] },
|
|
]),
|
|
});
|
|
});
|
|
|
|
it("serves no update whose renewed values another recorded call kept, before or after the calls that renewed them", () => {
|
|
const kept = renamedBook(
|
|
versionedBook(FIRST_BOOK, 7, 8),
|
|
"Kept Version",
|
|
7,
|
|
);
|
|
const keeping: TimedCaptureRow[] = [
|
|
["PATCH", "/api/books/1", kept, 200, { name: "Kept Version" }],
|
|
["GET", "/api/books/1", kept],
|
|
];
|
|
expect(
|
|
planOf(stateOf([...VERSIONED_ROWS, ...keeping]), "patch_books_by_id"),
|
|
).toMatchObject({ served: false, reason: "unexplained-change" });
|
|
const first = renamedBook(
|
|
versionedBook(FIRST_BOOK, 1, 2),
|
|
"Kept Version",
|
|
3,
|
|
);
|
|
const renamed = versionedBook(
|
|
renamedBook(FIRST_BOOK, "Renamed Book", 5),
|
|
5,
|
|
6,
|
|
);
|
|
expect(
|
|
planOf(
|
|
stateOf([
|
|
...VERSIONED_ROWS.slice(0, 3),
|
|
["PATCH", "/api/books/1", first, 200, { name: "Kept Version" }],
|
|
["GET", "/api/books/1", first],
|
|
[
|
|
"PATCH",
|
|
"/api/books/1",
|
|
renamed,
|
|
200,
|
|
{ name: "Renamed Book", version_id: numberedUuid(1) },
|
|
],
|
|
["GET", "/api/books/1", renamed],
|
|
]),
|
|
"patch_books_by_id",
|
|
),
|
|
).toMatchObject({ served: false, reason: "unexplained-change" });
|
|
});
|
|
|
|
it("serves creates that store a sent member under another name and embed it in a link", () => {
|
|
expect(planOf(stateOf(HANDLE_ROWS), "post_books")).toMatchObject({
|
|
served: true,
|
|
body: { accepted: ["name", "handle"], fixed: [] },
|
|
rules: expect.arrayContaining([
|
|
{ field: "code", rule: "copy", from: "/handle" },
|
|
{ field: "link", rule: "template", from: "code" },
|
|
]),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("write coverage limits", () => {
|
|
const WHOLE_BOOK = ["slug", "created_at", "updated_at"];
|
|
const itemView = (rows: readonly TimedCaptureRow[]) =>
|
|
stateOf(rows).views.find((view) => view.tool === "get_books_by_id");
|
|
|
|
it("notes no omission from a read that asked for some fields, nor from one of a book the state no longer holds", () => {
|
|
const projected: TimedCaptureRow[] = [
|
|
...BOOK_INIT_ROWS,
|
|
["PATCH", "/api/books/1", RENAMED_BOOK, 200, { name: "Renamed Book" }],
|
|
["GET", "/api/books/1?fields=id,name", { id: 1, name: "Renamed Book" }],
|
|
];
|
|
const tombstone: TimedCaptureRow[] = [
|
|
...BOOK_INIT_ROWS,
|
|
["POST", "/api/books", THIRD_BOOK, 201, { name: "Third Book" }],
|
|
["GET", "/api/books/3", THIRD_BOOK],
|
|
["DELETE", "/api/books/3", "", 204],
|
|
["GET", "/api/books/3", { id: 3, deleted: true }],
|
|
];
|
|
for (const rows of [projected, tombstone])
|
|
expect(itemView(rows)).toMatchObject({
|
|
required_fields: expect.arrayContaining(WHOLE_BOOK),
|
|
});
|
|
const partial = { id: 3, name: "Third Book" };
|
|
const created: TimedCaptureRow[] = [
|
|
...BOOK_INIT_ROWS,
|
|
["POST", "/api/books", partial, 201, { name: "Third Book" }],
|
|
["GET", "/api/books/3?fields=id,name", partial],
|
|
];
|
|
expect(planOf(stateOf(created), "post_books")).toMatchObject({
|
|
served: false,
|
|
});
|
|
});
|
|
|
|
it("stops requiring a field in every view of the type once a read shows it left out", () => {
|
|
const state = stateOf(DETAILED_LIST_ROWS);
|
|
expect(planOf(state, "post_books")).toMatchObject({ served: true });
|
|
const list = state.views.find((view) => view.tool === "get_books");
|
|
expect(list).toMatchObject({ fields: expect.arrayContaining(["detail"]) });
|
|
expect(list).not.toMatchObject({
|
|
required_fields: expect.arrayContaining(["detail"]),
|
|
});
|
|
});
|
|
|
|
it("leaves a read that asked for some fields only out of the gate, even where it would resynchronise a book", () => {
|
|
const rows: TimedCaptureRow[] = [
|
|
...BOOK_INIT_ROWS,
|
|
["GET", "/api/books/2?fields=name", { name: "Second Book" }],
|
|
[
|
|
"PATCH",
|
|
"/api/books/2",
|
|
renamedBook(SECOND_BOOK, "Renamed", 5),
|
|
200,
|
|
{ name: "Renamed" },
|
|
],
|
|
// A redirected write leaves book 2 unknown.
|
|
["PATCH", "/api/books/2", "", 302, { name: "Renamed" }],
|
|
["GET", "/api/books/2?fields=slug", { slug: "renamed" }],
|
|
];
|
|
expect(planOf(stateOf(rows), "patch_books_by_id")).toMatchObject({
|
|
contradicted_by: null,
|
|
});
|
|
});
|
|
|
|
it("learns no update's effect from a read-back that asked for some fields only", () => {
|
|
const rows: TimedCaptureRow[] = [
|
|
...BOOK_INIT_ROWS,
|
|
["PATCH", "/api/books/1", "", 204, { name: "Renamed Book" }],
|
|
["GET", "/api/books/1?fields=id,name", { id: 1, name: "Renamed Book" }],
|
|
["GET", "/api/books/1", RENAMED_BOOK],
|
|
["GET", "/api/books", [RENAMED_BOOK, SECOND_BOOK]],
|
|
];
|
|
expect(planOf(stateOf(rows), "patch_books_by_id")).toMatchObject({
|
|
served: true,
|
|
observation: { ordinal: 5 },
|
|
});
|
|
});
|
|
|
|
it("confirms a delete from a read that asked for some fields and found no book", () => {
|
|
const rows: TimedCaptureRow[] = [
|
|
...BOOK_INIT_ROWS,
|
|
["DELETE", "/api/books/2", "", 204],
|
|
["GET", "/api/books/2?fields=id,name", BOOK_NOT_FOUND, 404],
|
|
];
|
|
expect(planOf(stateOf(rows), "delete_books_by_id")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [4],
|
|
});
|
|
});
|
|
|
|
it("follows a sequence past its mask's width, as serving spells it", () => {
|
|
const numbered = (id: number, asset: string): Book =>
|
|
assetBook(book(id, `Book ${String(id)}`, bookClock(id + 2)), asset);
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books", [numbered(1, "99-97"), numbered(2, "99-98")]],
|
|
["GET", "/api/books/1", numbered(1, "99-97")],
|
|
["GET", "/api/books/2", numbered(2, "99-98")],
|
|
];
|
|
for (const [id, asset] of [
|
|
[3, "99-99"],
|
|
[4, "100-00"],
|
|
[5, "100-01"],
|
|
] as const)
|
|
rows.push(
|
|
[
|
|
"POST",
|
|
"/api/books",
|
|
numbered(id, asset),
|
|
201,
|
|
{ name: `Book ${String(id)}` },
|
|
],
|
|
["GET", `/api/books/${String(id)}`, numbered(id, asset)],
|
|
);
|
|
expect(planOf(stateOf(rows), "post_books")).toMatchObject({
|
|
served: true,
|
|
rules: expect.arrayContaining([
|
|
{ field: "asset", rule: "sequence", scope: "collection" },
|
|
]),
|
|
});
|
|
});
|
|
|
|
it("withholds creates whose later call spells its sequence otherwise than serving would", () => {
|
|
const spelled = (asset: JsonValue): TimedCaptureRow[] => {
|
|
const fourth = { ...book(4, "Fourth Book", bookClock(5)), asset };
|
|
return [
|
|
...ASSET_ROWS.slice(0, 5),
|
|
["POST", "/api/books", fourth, 201, { name: "Fourth Book" }],
|
|
["GET", "/api/books/4", fourth],
|
|
];
|
|
};
|
|
for (const asset of ["00-04", 4])
|
|
expect(planOf(stateOf(spelled(asset)), "post_books")).toMatchObject({
|
|
served: false,
|
|
});
|
|
expect(planOf(stateOf(spelled("000-004")), "post_books")).toMatchObject({
|
|
served: true,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("omissions shown by reads the gate ignores", () => {
|
|
it("notes no omission from a read begun while the create still ran, nor from a page the collection outgrew", () => {
|
|
const first = detailedBook(FIRST_BOOK);
|
|
const second = detailedBook(SECOND_BOOK);
|
|
const running: TimedCaptureRow[] = [
|
|
["GET", "/api/books", [first, second]],
|
|
["GET", "/api/books/1", first],
|
|
["GET", "/api/books/2", second],
|
|
// The create runs from second 3 to 4.5, so the read at second 4 may show the book before its detail.
|
|
["POST", "/api/books", THIRD_BOOK, 201, { name: "Third Book" }, 1500],
|
|
["GET", "/api/books/3", THIRD_BOOK],
|
|
];
|
|
const outgrown: TimedCaptureRow[] = [
|
|
["GET", "/api/books?_start=0&_end=2", [second, first]],
|
|
["GET", "/api/books/1", first],
|
|
["GET", "/api/books/2", second],
|
|
["POST", "/api/books", THIRD_BOOK, 201, { name: "Third Book" }],
|
|
// Serving answers the range from its recording once the collection outgrows it.
|
|
["GET", "/api/books?_start=0&_end=2", [THIRD_BOOK, second]],
|
|
];
|
|
const requiresDetail = {
|
|
required_fields: expect.arrayContaining(["detail"]),
|
|
};
|
|
const itemView = (rows: readonly TimedCaptureRow[]) =>
|
|
stateOf(rows).views.find((view) => view.tool === "get_books_by_id");
|
|
expect([running, outgrown].map(itemView)).toMatchObject([
|
|
requiresDetail,
|
|
requiresDetail,
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("clock spellings per view", () => {
|
|
it("serves a create that a list spelling clocks otherwise than item reads confirms", () => {
|
|
const state = stateOf(OFFSET_LIST_ROWS);
|
|
const offset = { ...ISO_SECONDS, zone: "+00:00" };
|
|
expect(state.views.find((view) => view.tool === "get_books")).toMatchObject(
|
|
{
|
|
membership: "all",
|
|
clock_formats: { created_at: offset, updated_at: offset },
|
|
},
|
|
);
|
|
expect(
|
|
state.views.find((view) => view.tool === "get_books_by_id"),
|
|
).not.toHaveProperty("clock_formats");
|
|
expect(planOf(state, "post_books")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [4, 5],
|
|
});
|
|
});
|
|
|
|
it("serves a create whose clock has a fraction where the list spelling was learned from whole seconds", () => {
|
|
const third = book(3, "Third Book", "2026-10-17T18:00:03.250Z");
|
|
const state = stateOf([
|
|
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK].map(offsetListed)],
|
|
["GET", "/api/books/1", FIRST_BOOK],
|
|
["GET", "/api/books/2", SECOND_BOOK],
|
|
["POST", "/api/books", third, 201, { name: "Third Book" }],
|
|
["GET", "/api/books/3", third],
|
|
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK, third].map(offsetListed)],
|
|
]);
|
|
expect(planOf(state, "post_books")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [4, 5],
|
|
});
|
|
});
|
|
|
|
it("learns no spelling for a field another recorded value spells as stored, at another instant", () => {
|
|
const moved = {
|
|
...SECOND_BOOK,
|
|
created_at: "2026-10-01T09:30:00Z",
|
|
updated_at: "2026-10-01T09:30:00Z",
|
|
};
|
|
const state = stateOf([
|
|
["GET", "/api/books", [offsetListed(FIRST_BOOK), moved]],
|
|
["GET", "/api/books/1", FIRST_BOOK],
|
|
["GET", "/api/books/2", SECOND_BOOK],
|
|
]);
|
|
const list = state.views.find((view) => view.tool === "get_books");
|
|
expect(list).toMatchObject({ shape: "list" });
|
|
expect(list).not.toHaveProperty("clock_formats");
|
|
});
|
|
|
|
it("learns no spelling for a field the view spells both ways", () => {
|
|
const state = stateOf([
|
|
["GET", "/api/books", [FIRST_BOOK, offsetListed(SECOND_BOOK)]],
|
|
["GET", "/api/books/1", FIRST_BOOK],
|
|
["GET", "/api/books/2", SECOND_BOOK],
|
|
]);
|
|
expect(
|
|
state.views.find((view) => view.tool === "get_books"),
|
|
).not.toHaveProperty("clock_formats");
|
|
});
|
|
});
|
|
|
|
describe("unserved write plans", () => {
|
|
it("names a write to no state type and a tool with no successful call", () => {
|
|
expect(
|
|
verdicts([
|
|
...BOOK_INIT_ROWS,
|
|
["POST", "/api/session", { token: "t0" }, 200, { user: "ada" }],
|
|
["POST", "/api/books", { errors: ["name"] }, 422, { name: "" }],
|
|
["DELETE", "/api/books/9", BOOK_NOT_FOUND, 404],
|
|
]),
|
|
).toEqual({
|
|
post_session: "no-state-target",
|
|
post_books: "no-call",
|
|
delete_books_by_id: "no-call",
|
|
});
|
|
});
|
|
|
|
it("refuses a create whose request body cannot enter the state", () => {
|
|
/** The create of the third book sending `sent`, or no body, then a read that shows it. */
|
|
const sending = (...sent: [] | [JsonValue]): TimedCaptureRow[] => [
|
|
...BOOK_INIT_ROWS,
|
|
["POST", "/api/books", THIRD_BOOK, 201, ...sent],
|
|
["GET", "/api/books/3", THIRD_BOOK],
|
|
];
|
|
const recorded = (mimeType: string, text: string) =>
|
|
environmentStateViewOf(
|
|
rawCaptureEvidence(sending(text), { mimeType, timed: true }),
|
|
);
|
|
for (const state of [
|
|
stateOf(sending()),
|
|
stateOf(sending([{ name: "Third Book" }])),
|
|
recorded("application/x-www-form-urlencoded", "name=Third+Book"),
|
|
recorded("application/json", '{"__proto__":{},"name":"Third Book"}'),
|
|
recorded(
|
|
"application/json",
|
|
'{"name":"Third Book","copies":12345678901234567890}',
|
|
),
|
|
])
|
|
expect(planOf(state, "post_books")).toMatchObject({
|
|
served: false,
|
|
reason: "body-unusable",
|
|
exemplar_ordinal: null,
|
|
});
|
|
});
|
|
|
|
it("reports no observation when neither the response nor a read before the next write shows the entity", () => {
|
|
expect(verdicts(created({ ok: true }))).toEqual({
|
|
post_books: "no-observation",
|
|
});
|
|
// The read of the created book comes after the next write; the update names a book never held.
|
|
expect(
|
|
verdicts(
|
|
created(
|
|
"",
|
|
[
|
|
"PATCH",
|
|
"/api/books/7",
|
|
{ id: 7, name: "Seventh" },
|
|
200,
|
|
{ name: "Seventh" },
|
|
],
|
|
["GET", "/api/books/3", THIRD_BOOK],
|
|
),
|
|
),
|
|
).toEqual({
|
|
post_books: "no-observation",
|
|
patch_books_by_id: "no-observation",
|
|
});
|
|
});
|
|
|
|
it("reports an update that changed an unsent field no clock, increment or slug explains", () => {
|
|
const changed = { ...RENAMED_AT_3, slug: "first-book-v2" };
|
|
expect(verdicts(rename(changed, ["GET", "/api/books/1", changed]))).toEqual(
|
|
{
|
|
patch_books_by_id: "unexplained-change",
|
|
},
|
|
);
|
|
});
|
|
|
|
it("names the first later read that contradicts a write", () => {
|
|
const other = renamedBook(RENAMED_AT_3, "Other Name", 3);
|
|
const plan = planOf(
|
|
stateOf(
|
|
rename(
|
|
RENAMED_AT_3,
|
|
["GET", "/api/books/1", other],
|
|
["GET", "/api/books/1", other],
|
|
),
|
|
),
|
|
"patch_books_by_id",
|
|
);
|
|
expect(plan).toMatchObject({
|
|
served: false,
|
|
reason: "contradicted",
|
|
contradicted_by: 4,
|
|
checked_record_ordinals: [],
|
|
});
|
|
});
|
|
|
|
it("blames a related write when a book no write touched changed after it", () => {
|
|
const plan = planOf(
|
|
stateOf(
|
|
created(
|
|
THIRD_BOOK,
|
|
["GET", "/api/books/3", THIRD_BOOK],
|
|
[
|
|
"GET",
|
|
"/api/books/2",
|
|
renamedBook(SECOND_BOOK, "Second Edition", 5),
|
|
],
|
|
),
|
|
),
|
|
"post_books",
|
|
);
|
|
// The create's own read confirmed it, but the changed sibling outweighs that.
|
|
expect(plan).toMatchObject({
|
|
served: false,
|
|
reason: "side-effect",
|
|
checked_record_ordinals: [4],
|
|
contradicted_by: null,
|
|
});
|
|
});
|
|
|
|
it("leaves a write that no later read checks unconfirmed", () => {
|
|
expect(planOf(stateOf(created(THIRD_BOOK)), "post_books")).toMatchObject({
|
|
served: false,
|
|
reason: "unconfirmed",
|
|
exemplar_ordinal: 3,
|
|
checked_record_ordinals: [],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("delete confirmation", () => {
|
|
it("confirms a delete by a later 404, 400 or JSON null item read", () => {
|
|
const absences: [body: JsonValue, status: number][] = [
|
|
[BOOK_NOT_FOUND, 404],
|
|
[{ detail: "Invalid book id." }, 400],
|
|
[null, 200],
|
|
];
|
|
for (const [body, status] of absences)
|
|
expect(
|
|
planOf(
|
|
stateOf([
|
|
...BOOK_INIT_ROWS,
|
|
["DELETE", "/api/books/2", "", 204],
|
|
["GET", "/api/books/2", body, status],
|
|
]),
|
|
"delete_books_by_id",
|
|
),
|
|
).toMatchObject({ served: true, checked_record_ordinals: [4] });
|
|
});
|
|
it("withholds a delete whose recorded answer shows fields no initial entity holds", () => {
|
|
const answered = (previous: JsonValue) =>
|
|
planOf(
|
|
stateOf([
|
|
...BOOK_INIT_ROWS,
|
|
["DELETE", "/api/books/2", { deleted: true, previous }, 200],
|
|
["GET", "/api/books/2", BOOK_NOT_FOUND, 404],
|
|
]),
|
|
"delete_books_by_id",
|
|
);
|
|
// An edit view of the book: the read views never showed its draft field.
|
|
expect(answered({ ...SECOND_BOOK, draft: false })).toMatchObject({
|
|
served: false,
|
|
reason: "incomplete-entities",
|
|
checked_record_ordinals: [4],
|
|
});
|
|
expect(answered(SECOND_BOOK)).toMatchObject({
|
|
served: true,
|
|
response: { shape: "entity", pointer: "/previous" },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("update answers", () => {
|
|
it("serves an update whose recorded answer shows a field only the update writes", () => {
|
|
const summary = (name: string) => [
|
|
{ id: 1, name },
|
|
{ id: 2, name: "B" },
|
|
];
|
|
// No read showed a note before the update sent one, which its answer echoes.
|
|
const plan = planOf(
|
|
stateOf([
|
|
["GET", "/api/books", summary("A")],
|
|
["GET", "/api/books/1", { id: 1, name: "A" }],
|
|
["GET", "/api/books/2", { id: 2, name: "B" }],
|
|
[
|
|
"PATCH",
|
|
"/api/books/1",
|
|
{ id: 1, name: "Z", note: "n" },
|
|
200,
|
|
{ name: "Z", note: "n" },
|
|
],
|
|
["GET", "/api/books", summary("Z")],
|
|
]),
|
|
"patch_books_by_id",
|
|
);
|
|
expect(plan).toMatchObject({
|
|
served: true,
|
|
reason: null,
|
|
response: { shape: "entity", pointer: "" },
|
|
checked_record_ordinals: [4],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("insert rules", () => {
|
|
it("learns where a create inserts from the recorded collection order", () => {
|
|
// Listed by name, while neither identities nor clocks are in order.
|
|
const shelf = [
|
|
book(3, "Alpha", "2026-10-01T10:00:00Z"),
|
|
book(1, "Delta", "2026-10-01T09:00:00Z"),
|
|
book(2, "Zulu", "2026-10-01T11:00:00Z"),
|
|
];
|
|
const mike = book(4, "Mike", bookClock(6));
|
|
const state = stateOf([
|
|
["GET", "/api/books", shelf],
|
|
...shelf.map((entry): TimedCaptureRow => [
|
|
"GET",
|
|
`/api/books/${String(entry.id)}`,
|
|
entry,
|
|
]),
|
|
["GET", "/api/books", shelf],
|
|
["GET", "/api/books", shelf],
|
|
["POST", "/api/books", mike, 201, { name: "Mike" }],
|
|
["GET", "/api/books", [shelf[0]!, shelf[1]!, mike, shelf[2]!]],
|
|
]);
|
|
expect(planOf(state, "post_books")).toMatchObject({
|
|
served: true,
|
|
insert: { rule: "sorted", field: "name", direction: "ascending" },
|
|
checked_record_ordinals: [7],
|
|
});
|
|
});
|
|
|
|
it("takes the position a later list shows when the recorded order admits several", () => {
|
|
const zed = book(3, "Zed", bookClock(3));
|
|
const plan = planOf(
|
|
stateOf(
|
|
created(zed, ["GET", "/api/books", [zed, FIRST_BOOK, SECOND_BOOK]]),
|
|
),
|
|
"post_books",
|
|
);
|
|
expect(plan).toMatchObject({
|
|
served: true,
|
|
insert: { rule: "first" },
|
|
checked_record_ordinals: [4],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("unmodelled writes", () => {
|
|
const shelved = (entry: Book, archived = false): Book => ({
|
|
...entry,
|
|
archived,
|
|
});
|
|
/** The rename, an archive the gate does not model when `archive` is set, then a read of both changes. */
|
|
const archivedAfterRename = (archive: boolean) =>
|
|
stateOf([
|
|
["GET", "/api/books", [shelved(FIRST_BOOK), shelved(SECOND_BOOK)]],
|
|
["GET", "/api/books/1", shelved(FIRST_BOOK)],
|
|
["GET", "/api/books/2", shelved(SECOND_BOOK)],
|
|
[
|
|
"PATCH",
|
|
"/api/books/1",
|
|
shelved(RENAMED_AT_3),
|
|
200,
|
|
{ name: "Renamed Book" },
|
|
],
|
|
...(archive
|
|
? [
|
|
[
|
|
"POST",
|
|
"/api/books/1/archive",
|
|
{ archived: true },
|
|
200,
|
|
{},
|
|
] as TimedCaptureRow,
|
|
]
|
|
: []),
|
|
["GET", "/api/books/1", shelved(RENAMED_AT_3, true)],
|
|
]);
|
|
|
|
it("confirms an update on the members it sent when an unmodelled write also changed the entity", () => {
|
|
const state = archivedAfterRename(true);
|
|
expect(planOf(state, "patch_books_by_id")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [5],
|
|
contradicted_by: null,
|
|
});
|
|
expect(planOf(state, "post_books_archive_by_id")).toMatchObject({
|
|
effect: "none",
|
|
reason: "no-state-target",
|
|
});
|
|
// With no unmodelled write the same read contradicts the update.
|
|
expect(
|
|
planOf(archivedAfterRename(false), "patch_books_by_id"),
|
|
).toMatchObject({
|
|
reason: "contradicted",
|
|
contradicted_by: 4,
|
|
});
|
|
});
|
|
|
|
it("does not blame a modelled write for a check that fails after an unmodelled write", () => {
|
|
const changedSibling = renamedBook(SECOND_BOOK, "Second Edition", 4);
|
|
const reindex: TimedCaptureRow = [
|
|
"POST",
|
|
"/api/jobs/reindex",
|
|
{ queued: true },
|
|
202,
|
|
{},
|
|
];
|
|
const list: TimedCaptureRow = [
|
|
"GET",
|
|
"/api/books",
|
|
[RENAMED_AT_3, changedSibling],
|
|
];
|
|
expect(
|
|
planOf(stateOf(rename(RENAMED_AT_3, list)), "patch_books_by_id"),
|
|
).toMatchObject({
|
|
reason: "contradicted",
|
|
contradicted_by: 4,
|
|
});
|
|
expect(
|
|
planOf(stateOf(rename(RENAMED_AT_3, reindex, list)), "patch_books_by_id"),
|
|
).toMatchObject({ reason: "unconfirmed", contradicted_by: null });
|
|
// A create wrote every field, so after an unmodelled write a differing read checks none of them.
|
|
const reslugged = { ...THIRD_BOOK, slug: "third-book-1" };
|
|
const read: TimedCaptureRow = ["GET", "/api/books/3", reslugged];
|
|
expect(
|
|
planOf(stateOf(created(THIRD_BOOK, read)), "post_books"),
|
|
).toMatchObject({
|
|
reason: "contradicted",
|
|
contradicted_by: 4,
|
|
});
|
|
expect(
|
|
planOf(stateOf(created(THIRD_BOOK, reindex, read)), "post_books"),
|
|
).toMatchObject({
|
|
reason: "unconfirmed",
|
|
contradicted_by: null,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("observation from a later read", () => {
|
|
it("observes a created entity in the first later item read when the response names only its identity", () => {
|
|
const later: TimedCaptureRow[] = [
|
|
["GET", "/api/books/3", THIRD_BOOK],
|
|
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK, THIRD_BOOK]],
|
|
];
|
|
const bare = planOf(stateOf(created({ id: 3 }, ...later)), "post_books");
|
|
// The read that showed the entity does not check the create; the later list does.
|
|
expect(bare).toMatchObject({
|
|
served: true,
|
|
exemplar_ordinal: 3,
|
|
observation: { ordinal: 4, pointer: "" },
|
|
response: { ordinal: 3, shape: "entity", pointer: "" },
|
|
checked_record_ordinals: [5],
|
|
});
|
|
const named = planOf(
|
|
stateOf(created({ created_id: 3 }, ...later)),
|
|
"post_books",
|
|
);
|
|
expect(named).toMatchObject({
|
|
served: true,
|
|
observation: { ordinal: 4, pointer: "" },
|
|
response: { ordinal: 3, shape: "identity", pointer: "/created_id" },
|
|
checked_record_ordinals: [5],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("list lines", () => {
|
|
it("confirms the same create when a learned filter repeats identical decoded query values", () => {
|
|
for (const query of [
|
|
"shelf=a&shelf=a",
|
|
"shelf=%61&shelf=a",
|
|
"shelf=a&include=all&shelf=a",
|
|
]) {
|
|
const rows = QUERY_ROWS.map((row, index): TimedCaptureRow => {
|
|
if (index !== QUERY_ROWS.length - 1) return row;
|
|
const [method, , ...response] = row;
|
|
return [method, `/api/books?${query}`, ...response];
|
|
});
|
|
expect(planOf(stateOf(rows), "post_books"), query).toMatchObject({
|
|
served: true,
|
|
contradicted_by: null,
|
|
});
|
|
}
|
|
});
|
|
|
|
it("lists every entity on a line that lists its whole collection, though another line of the tool is filtered", () => {
|
|
const state = stateOf(LIST_LINE_ROWS);
|
|
expect(state.views.find((view) => view.tool === "get_books")).toMatchObject(
|
|
{
|
|
shape: "list",
|
|
pointer: "/content",
|
|
membership: "recorded",
|
|
all_record_ordinals: [0],
|
|
counters: ["numberOfElements", "totalElements"],
|
|
checked_record_ordinals: [0, 1],
|
|
},
|
|
);
|
|
// The whole list after the create shows the created book, which confirms it.
|
|
expect(planOf(state, "post_books")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [5, 6],
|
|
});
|
|
});
|
|
|
|
it("confirms no create by a whole list taken after a read showed its entity gone", () => {
|
|
const rows: TimedCaptureRow[] = [
|
|
...BOOK_INIT_ROWS,
|
|
["POST", "/api/books", THIRD_BOOK, 201, { name: "Third Book" }],
|
|
// A redirected delete is not modelled; the read after it shows the book gone.
|
|
["DELETE", "/api/books/3", "", 302],
|
|
["GET", "/api/books/3", BOOK_NOT_FOUND, 404],
|
|
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK]],
|
|
];
|
|
expect(planOf(stateOf(rows), "post_books")).toMatchObject({
|
|
served: false,
|
|
reason: "unconfirmed",
|
|
});
|
|
});
|
|
|
|
it("confirms no create by a whole list after an item read showed nothing of its entity", () => {
|
|
const rows: TimedCaptureRow[] = [
|
|
...BOOK_INIT_ROWS,
|
|
["POST", "/api/books", THIRD_BOOK, 201, { name: "Third Book" }],
|
|
// An unmodelled write elsewhere may explain the next read, which the state then takes.
|
|
["POST", "/api/exports", { queued: true }, 202, { format: "csv" }],
|
|
["GET", "/api/books/3", null],
|
|
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK]],
|
|
];
|
|
expect(planOf(stateOf(rows), "post_books")).toMatchObject({
|
|
served: false,
|
|
checked_record_ordinals: [],
|
|
});
|
|
});
|
|
|
|
it("names no line when every line lists its whole collection, or none does", () => {
|
|
const listOf = (rows: readonly TimedCaptureRow[]) =>
|
|
stateOf(rows).views.find((view) => view.tool === "get_books");
|
|
const whole = listOf(LIST_LINE_ROWS.filter((_, index) => index !== 1));
|
|
expect(whole).toMatchObject({ membership: "all" });
|
|
expect(whole).not.toHaveProperty("all_record_ordinals");
|
|
expect(listOf(LIST_LINE_ROWS.slice(1))).not.toHaveProperty(
|
|
"all_record_ordinals",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("list queries", () => {
|
|
it("learns a filter, a search, a sort and an inert flag from the lines the whole collection does not explain", () => {
|
|
const state = stateOf(QUERY_ROWS);
|
|
expect(state.views.find((view) => view.tool === "get_books")).toMatchObject(
|
|
{
|
|
shape: "list",
|
|
membership: "recorded",
|
|
all_record_ordinals: [0],
|
|
query: {
|
|
parameters: {
|
|
shelf: { role: "filter", field: "shelf" },
|
|
q: { role: "search", fields: ["name", "slug"] },
|
|
sort: { role: "sort" },
|
|
include: { role: "inert", values: ["all"] },
|
|
},
|
|
record_ordinals: [1, 2, 3, 4],
|
|
totals: [],
|
|
},
|
|
checked_record_ordinals: [0, 1, 2, 3, 4],
|
|
},
|
|
);
|
|
// The shelf filter after the create lists the created book, which confirms it.
|
|
expect(planOf(state, "post_books")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [8],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("list reads with query values no line recorded", () => {
|
|
it("serves no create that a filtered read on a new value omits, as serving would list it", () => {
|
|
const third = { ...book(3, "Third Book", bookClock(7)), shelf: "b" };
|
|
const rows: TimedCaptureRow[] = [
|
|
...QUERY_ROWS.slice(0, 7),
|
|
["POST", "/api/books", third, 201, { name: "Third Book", shelf: "b" }],
|
|
["GET", "/api/books/3", third],
|
|
// Serving evaluates `shelf=b` over the state, which lists the created book; the recording does not.
|
|
["GET", "/api/books?shelf=b", [QUERY_ROWS[6]![2] as Book]],
|
|
];
|
|
// The item read confirmed the book, so the failing filtered read blames the related create as a side effect.
|
|
expect(planOf(stateOf(rows), "post_books")).toMatchObject({
|
|
served: false,
|
|
reason: "side-effect",
|
|
});
|
|
});
|
|
|
|
it("checks what the state answers on a line the read did not name, not the envelope that line's own call recorded", () => {
|
|
const first = { ...FIRST_BOOK, shelf: "a" };
|
|
const second = { ...SECOND_BOOK, shelf: "b" };
|
|
const third = { ...book(3, "Third Book", bookClock(4)), shelf: "c" };
|
|
// Each page links the next under its own query.
|
|
const page = (query: string, books: readonly Book[]) => ({
|
|
data: [...books],
|
|
next: `/api/books?${query}offset=20`,
|
|
});
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books", page("", [first, second])],
|
|
["GET", "/api/books?shelf=a", page("shelf=a&", [first])],
|
|
["GET", "/api/books/1", first],
|
|
["GET", "/api/books/2", second],
|
|
["POST", "/api/books", third, 201, { name: "Third Book", shelf: "c" }],
|
|
// Serving lists `shelf=c` from the state in the `shelf=a` line's envelope, whose link names shelf a.
|
|
["GET", "/api/books?shelf=c", page("shelf=c&", [third])],
|
|
];
|
|
expect(planOf(stateOf(rows), "post_books")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [5],
|
|
});
|
|
});
|
|
|
|
it("checks a recorded-member line with the total it recorded, as serving answers it", () => {
|
|
const envelope = (books: readonly Book[]) => ({
|
|
items: [...books],
|
|
total: 3,
|
|
});
|
|
const renamed = renamedBook(SECOND_BOOK, "Renamed Second", 6);
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books", envelope([FIRST_BOOK, SECOND_BOOK, THIRD_BOOK])],
|
|
["GET", "/api/books?limit=1", envelope([FIRST_BOOK])],
|
|
// A cursor no role explains keeps its recorded member and total.
|
|
["GET", "/api/books?cursor=abc", envelope([FIRST_BOOK])],
|
|
["GET", "/api/books/1", FIRST_BOOK],
|
|
["GET", "/api/books/2", SECOND_BOOK],
|
|
["GET", "/api/books/3", THIRD_BOOK],
|
|
["PATCH", "/api/books/2", renamed, 200, { name: "Renamed Second" }],
|
|
["GET", "/api/books/2", renamed],
|
|
["GET", "/api/books?cursor=abc", envelope([FIRST_BOOK])],
|
|
// Failing that read would taint book 1 and leave its confirmed delete unserved.
|
|
["DELETE", "/api/books/1", "", 204],
|
|
["GET", "/api/books/1", BOOK_NOT_FOUND, 404],
|
|
];
|
|
const state = stateOf(rows);
|
|
expect(planOf(state, "patch_books_by_id")).toMatchObject({ served: true });
|
|
expect(planOf(state, "delete_books_by_id")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [10],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("list reads on a line they did not name", () => {
|
|
const first = { ...FIRST_BOOK, shelf: "a" };
|
|
const second = { ...SECOND_BOOK, shelf: "b" };
|
|
|
|
it("checks a read with no query on a query line by that line's recorded members, as serving lists them", () => {
|
|
const third = { ...book(3, "Third Book", bookClock(3)), shelf: "c" };
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books?shelf=a", [first]],
|
|
["GET", "/api/books/1", first],
|
|
["GET", "/api/books/2", second],
|
|
["POST", "/api/books", third, 201, { name: "Third Book", shelf: "c" }],
|
|
// Serving answers this call on the `shelf=a` line with its recorded book, not the created one.
|
|
["GET", "/api/books", [first, second, third]],
|
|
];
|
|
expect(planOf(stateOf(rows), "post_books")).toMatchObject({
|
|
served: false,
|
|
checked_record_ordinals: [],
|
|
});
|
|
});
|
|
|
|
it("confirms no delete by a filtered read on another value, whose answer never listed the deleted book", () => {
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books", [first, second]],
|
|
["GET", "/api/books?shelf=a", [first]],
|
|
["GET", "/api/books/1", first],
|
|
["GET", "/api/books/2", second],
|
|
["DELETE", "/api/books/1", "", 204],
|
|
["GET", "/api/books?shelf=b", [second]],
|
|
];
|
|
expect(planOf(stateOf(rows), "delete_books_by_id")).toMatchObject({
|
|
served: false,
|
|
reason: "unconfirmed",
|
|
checked_record_ordinals: [],
|
|
});
|
|
});
|
|
|
|
it("lets a sorted read whose values tie fix a create's position, as its order shows where the create put it", () => {
|
|
const shelved = (id: number, name: string, shelf: string, at: number) => ({
|
|
...book(id, name, bookClock(at)),
|
|
shelf,
|
|
});
|
|
const beta = shelved(1, "Beta", "a", 0);
|
|
const mike = shelved(2, "Mike", "b", 1);
|
|
const zulu = shelved(3, "Zulu", "a", 2);
|
|
const alpha = shelved(4, "Alpha", "a", 6);
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books", [beta, mike, zulu]],
|
|
["GET", "/api/books?shelf=a", [beta, zulu]],
|
|
["GET", "/api/books?sort=-id", [zulu, mike, beta]],
|
|
["GET", "/api/books/1", beta],
|
|
["GET", "/api/books/2", mike],
|
|
["GET", "/api/books/3", zulu],
|
|
["POST", "/api/books", alpha, 201, { name: "Alpha", shelf: "a" }],
|
|
// Every match shares the shelf, so the order is the state's: the create went last.
|
|
["GET", "/api/books?shelf=a&sort=shelf", [beta, zulu, alpha]],
|
|
// A name order would put it first, which the read before contradicts.
|
|
["GET", "/api/books", [alpha, beta, mike, zulu]],
|
|
];
|
|
expect(planOf(stateOf(rows), "post_books")).toMatchObject({
|
|
served: false,
|
|
contradicted_by: 8,
|
|
});
|
|
});
|
|
|
|
it("leaves a create's position open after a sorted read that ties other books but not the created one", () => {
|
|
const shelved = (id: number, name: string, shelf: string, at: number) => ({
|
|
...book(id, name, bookClock(at)),
|
|
category: "x",
|
|
shelf,
|
|
});
|
|
const beta = shelved(1, "Beta", "a", 0);
|
|
const mike = { ...shelved(2, "Mike", "b", 1), category: "y" };
|
|
const zulu = shelved(3, "Zulu", "a", 2);
|
|
const alpha = shelved(4, "Alpha", "c", 6);
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books", [beta, mike, zulu]],
|
|
["GET", "/api/books?category=x", [beta, zulu]],
|
|
["GET", "/api/books?sort=-id", [zulu, mike, beta]],
|
|
["GET", "/api/books/1", beta],
|
|
["GET", "/api/books/2", mike],
|
|
["GET", "/api/books/3", zulu],
|
|
[
|
|
"POST",
|
|
"/api/books",
|
|
alpha,
|
|
201,
|
|
{ name: "Alpha", category: "x", shelf: "c" },
|
|
],
|
|
// Beta and Zulu tie on shelf a, but the created book's shelf c sorts it last wherever it was put.
|
|
["GET", "/api/books?category=x&sort=shelf", [beta, zulu, alpha]],
|
|
["GET", "/api/books", [alpha, beta, mike, zulu]],
|
|
];
|
|
expect(planOf(stateOf(rows), "post_books")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [7, 8],
|
|
insert: { rule: "sorted", field: "name", direction: "ascending" },
|
|
});
|
|
});
|
|
|
|
it("leaves a create's position open after a sorted read, for a later whole list to settle", () => {
|
|
const shelved = (id: number, name: string, shelf: string, at: number) => ({
|
|
...book(id, name, bookClock(at)),
|
|
shelf,
|
|
});
|
|
const beta = shelved(1, "Beta", "a", 0);
|
|
const mike = shelved(2, "Mike", "b", 1);
|
|
const zulu = shelved(3, "Zulu", "a", 2);
|
|
const alpha = shelved(4, "Alpha", "a", 6);
|
|
const rows: TimedCaptureRow[] = [
|
|
["GET", "/api/books", [beta, mike, zulu]],
|
|
["GET", "/api/books?shelf=a", [beta, zulu]],
|
|
["GET", "/api/books?sort=-id", [zulu, mike, beta]],
|
|
["GET", "/api/books/1", beta],
|
|
["GET", "/api/books/2", mike],
|
|
["GET", "/api/books/3", zulu],
|
|
["POST", "/api/books", alpha, 201, { name: "Alpha", shelf: "a" }],
|
|
// The sorted shelf passes wherever the create placed its book; the whole list orders by name.
|
|
["GET", "/api/books?shelf=a&sort=-id", [alpha, zulu, beta]],
|
|
["GET", "/api/books", [alpha, beta, mike, zulu]],
|
|
];
|
|
expect(planOf(stateOf(rows), "post_books")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [7, 8],
|
|
insert: { rule: "sorted", field: "name", direction: "ascending" },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("what a list query read shows of writes", () => {
|
|
const shelved = (id: number, name: string, shelf: string, at: number) => ({
|
|
...book(id, name, bookClock(at)),
|
|
shelf,
|
|
});
|
|
const beta = shelved(1, "Beta", "a", 0);
|
|
const mike = shelved(2, "Mike", "b", 1);
|
|
const zulu = shelved(3, "Zulu", "a", 2);
|
|
const INIT: TimedCaptureRow[] = [
|
|
["GET", "/api/books", [beta, mike, zulu]],
|
|
["GET", "/api/books?shelf=a", [beta, zulu]],
|
|
["GET", "/api/books/1", beta],
|
|
["GET", "/api/books/2", mike],
|
|
["GET", "/api/books/3", zulu],
|
|
];
|
|
|
|
it("blames a write that moved a book off a filter whose failed read still lists it", () => {
|
|
const moved = { ...beta, shelf: "b", updated_at: bookClock(5) };
|
|
const renamed = renamedBook(zulu, "Zulu Two", 7);
|
|
const rows: TimedCaptureRow[] = [
|
|
...INIT,
|
|
["PATCH", "/api/books/1", moved, 200, { shelf: "b" }],
|
|
["PUT", "/api/books/3", renamed, 200, { name: "Zulu Two", shelf: "a" }],
|
|
// Serving lists Zulu alone on shelf a; the recording still lists the moved book there.
|
|
["GET", "/api/books?shelf=a", [beta, renamed]],
|
|
["GET", "/api/books/1", moved],
|
|
];
|
|
const state = stateOf(rows);
|
|
expect(planOf(state, "patch_books_by_id")).toMatchObject({
|
|
served: false,
|
|
contradicted_by: 7,
|
|
});
|
|
expect(planOf(state, "put_books_by_id")).toMatchObject({
|
|
served: false,
|
|
contradicted_by: 7,
|
|
});
|
|
});
|
|
|
|
it("leaves a create's position open after a filter that lists only its book, for a later whole list to settle", () => {
|
|
const alpha = shelved(4, "Alpha", "c", 5);
|
|
const rows: TimedCaptureRow[] = [
|
|
...INIT,
|
|
["POST", "/api/books", alpha, 201, { name: "Alpha", shelf: "c" }],
|
|
// A lone match shows nothing of where the create put it among the other books.
|
|
["GET", "/api/books?shelf=c", [alpha]],
|
|
["GET", "/api/books", [alpha, beta, mike, zulu]],
|
|
];
|
|
expect(planOf(stateOf(rows), "post_books")).toMatchObject({
|
|
served: true,
|
|
checked_record_ordinals: [6, 7],
|
|
insert: { rule: "sorted", field: "name", direction: "ascending" },
|
|
});
|
|
});
|
|
});
|