feat(environment): model application-assigned write values

Serve renamed create members and evidence-gated UUID regeneration. Preserve every copied text occurrence and withhold cross-field scalar UUID relations the independent generators cannot reproduce.

Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
This commit is contained in:
Edgar Arakelyan authored and GitHub committed 2026-10-22 23:57:36 +08:00
1 parent 2127095e3b
commit 7ec9b6e56e
21 files changed
+1531 -163

No files matched your search

+1 -1
View File
@@ -457,7 +457,7 @@ every MCP session and `data/get` then see:
| Effect | Call |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `create` | Adds an entity to the collection the path names, built from the observed entity with each ruled field taken from the call or generated, at the plan's `insert` position. A plan tied to its exemplar's collection writes only there. |
| `update` | Sets the sent members on the entity the path names, constants for members accepted only with their recorded value, and the clock, increment and slug changes the plan models. |
| `update` | Sets the sent members on the entity the path names, constants for members accepted only with their recorded value, and the clock, increment, slug, unique and renewed changes the plan models. |
| `delete` | Removes the entity the path names. |
| `document` | Merges the body into the plan's document object, or replaces it for `PUT`. |
@@ -87,9 +87,13 @@ identity the client sends is an echo:
groups and zero padding, and digits that outgrow them lead. A later call must
record the value spelled that way.
8. **Unique.** A UUID-shaped string that no other entity of the type shares.
9. **Template.** A string containing a generated value as a delimited token
(for example a URL that ends in the new id). It is rebuilt around the new
value.
9. **Template.** A string containing a generated value, or else a copied
value, as a delimited token (for example a URL that ends in the new id or in
a slug the call sent under another name). It is rebuilt around the new
value. An echoed value is not a token, nor is a copy of one: it may recur in
unrelated text. A copied member whose value also sits in text no template
rebuilds around it (beside another source in one template, or in a constant)
is accepted only with its recorded value.
10. **Constant.** Otherwise the exemplar's value.
When a constant, or the text a template keeps, contains a path value of the
@@ -98,8 +102,10 @@ naming the exemplar's collection.
**Body members of a create.** A member is either accepted or not:
- **Accepted.** An echoed member, or the identity field's value. Every member
the exemplar sent is required.
- **Accepted.** An echoed member, a member copied whole into a field of another
name and stored under none of its own, or the identity field's value. A
member copied whole must have the JSON type of the field it is copied into.
Every member the exemplar sent is required.
- **Not accepted.** A member the application stored with a different value
(transformed), or one it did not show. A call that sends such a member is a
tool error naming the accepted members.
@@ -115,7 +121,9 @@ calls use a member both as the identity and as a fixed member, or fix it at
different values, is `unexplained-change`, as serving could not take every
recorded call. So is an update a later read shows dropped a field the
entity held, since serving keeps every field an update does not write; a write's
own response may leave fields out. A member a later call is the first to store,
own response may leave fields out. A member the application replaced
with a fresh UUID (`unique`, below) is accepted with any value; a UUID stored
as sent in another letter case is the sent value, not a fresh one. A member a later call is the first to store,
which the plan's observation does not show, keeps the JSON type that call stored
it as (the body's `kinds`), as an exemplar's member keeps the observation's.
`PUT` requires every member the exemplar sent; `PATCH` and `POST` require at
@@ -124,14 +132,25 @@ explained as one of:
- clock;
- an increment by one;
- a slug of an echoed member.
- a slug of an echoed member;
- `unique`: a UUID in place of another UUID, that no other entity of the type
holds and the call sent nowhere in its body (a version the application assigns
on every update);
- `renewed`: an array or object whose only changes are UUIDs replaced by ones
that neither it nor another entity of the type held, as child rows an update
rewrites are. The rule names the pointers it renewed at (`*` for any array
index): every UUID at them is new, each old UUID by a single new one, and
UUIDs elsewhere, such as a row's references, are kept. One call renews a
UUID alike in every field and in either letter case, and never two old UUIDs
into one new one.
A field the entity lacked before is a change too when the view that observed
the update had shown the entity, so a field the application fills in on update
follows its rule. Any other change is `unexplained-change`. An increment or a slug applies to
every call that could take it, so the plan is also `unexplained-change` when
such a rule would give a call of the tool another value than the one it
recorded, or when a later call treats a member otherwise than the plan: one
every call that could take it, and `unique` and `renewed` to every call, so the
plan is also `unexplained-change` when such a rule would give a call of the
tool another value than the one it recorded, a recorded call kept a `unique` or
`renewed` field, or a later call treats a member otherwise than the plan: one
call echoes it and another stores something else, or the two rule it
differently.
@@ -364,6 +383,12 @@ their recorded format.
sequences do not reuse values.
- **UUIDs and tokens:** derived from a SHA-256 of the state Evidence id, the
tool, the field and a write counter, skipping values the type already holds.
- **Renewed fields:** the entity's current value with each UUID at the rule's
pointers replaced by one derived from a SHA-256 of the state Evidence id, the
tool, the write counter and the UUID's order in the call, the same
replacement wherever the call renews that UUID in either letter case, spelled
in each place's case; held values are not skipped.
An entity without the field is left without it.
`data/reset` and `data/add` restart the clock, the counter and the
sequences.
@@ -0,0 +1,9 @@
# Implementation plan
1. **Explanation.** Creates accept members a whole-member copy stores and template text around copied values.
Updates rule a fresh UUID `unique`, accepting a sent member so replaced, and child rows with only renewed UUIDs
`renewed`; the update reads the type's other entities to tell a fresh UUID from a reference.
2. **Values.** Helpers find the UUIDs a value renewed and renew the UUIDs of a value.
3. **Apply and serve.** Updates take `unique` and `renewed` from the generator; serving renews the current entity's
UUIDs.
4. **Schema, tests and docs.** The view's rule schema, unit, gate and serving tests, and the write specs.
@@ -0,0 +1,48 @@
# Serve writes whose values the application renames or regenerates
Issue: https://github.com/morluto/rea/issues/4933
## Outcome
Recordings made to show each write's effect, with every write read back at once and two scratch objects per tool,
still left writes unserved where the application stored a value in a way the write rules did not explain:
- a create's member stored under another name: Shlink stores `customSlug` as `shortCode`, and its `shortUrl` ends in
it, so every slug but the recorded one was refused;
- an update that gives a field a fresh UUID each time: n8n's `versionId`, which the client sends back with the
version it read;
- an update that rewrites child rows with new UUIDs: Mealie gives a recipe's instructions new ids on every update.
These writes are now explained, and served where every recorded call agrees with the explanation.
## Rules
- **Creates.** A member copied whole into a field of another name, and stored under none of its own, is accepted with
any value of that field's JSON type. A string that contains a copied value as a delimited token is a template of
the copying field and is rebuilt around the new value; the copied value has the copy rule's 8 characters at least.
Echoed values, and copies of them, make no templates, since an echoed name may recur in unrelated text.
Each text containing a copied value must rebuild that copy; rebuilding it in one text does not make occurrences
in other templates safe. Otherwise the member remains fixed to its recorded value.
- **Updates.**
- `unique`: a field that held a UUID and now holds another, which the call did not send (in any letter case) and
no other entity of the type holds. A sent member replaced this way is accepted with any value.
A value shared with another output field is not independent generation: scalar and nested generators cannot
reproduce that relation, so the update remains unexplained rather than serving disconnected UUIDs.
- `renewed`: an array or object whose only changes are UUIDs replaced by ones neither it nor another entity of the
type held, at the pointers the rule names (`*` for any array index): every UUID there is new, each old UUID by a
single new one, and UUIDs elsewhere are kept. A lone UUID is never renewed: it is either `unique` or unexplained.
- **Serving.** A `unique` field gets a UUID derived as for creates. A `renewed` field keeps the entity's current
value with each UUID at the rule's pointers replaced by one seeded by the call, the same replacement wherever the
call renews that UUID; an entity without the field stays without it.
The gate takes the recorded values for both, as for other generated fields. A later call must rule a field the same
way, and no recorded call may keep a `unique` or `renewed` field, since serving changes it on every call.
## Acceptance
- The pilot recordings' Shlink create and n8n update are served. Mealie's update stays unserved: one recorded call keeps
the ingredient ids the gate's copy of the recipe shows renewed, and no other verdict changes.
- The 50 published captures keep every verdict; only Shlink's unserved create plan changes, accepting
`customSlug` and templating `shortUrl`.
- Every served write tool of the 50 captures still applies.
- A unit test shows a UUID another entity holds is not `unique`, and a child row whose text changed is not `renewed`.
@@ -0,0 +1,9 @@
# Tasks
- [x] Accept a create member stored under another name, and template text around copied values.
- [x] Rule fresh UUIDs on update as `unique`, and rewritten child rows as `renewed`.
- [x] Apply and serve both rules.
- [x] Add unit, gate and serving tests, each failing on the previous head.
- [x] Document the rules.
- [x] Measure the pilot recordings, the 50 published captures and the served smoke.
- [x] Run static, formatting, duplication, documentation and test gates.
@@ -0,0 +1,60 @@
# Verification
Executed on macOS with Node v22.23.1 on this branch with the write-plan branch's head `45a5c8b9` merged in. The pilot recordings
were made against live applications on six disposable Linux VMs, each restored from a snapshot first.
## Gates
- `npm run build:cached`, `npm run check:fast` (typecheck and lint, no warnings in the changed files),
`npm run format:check`, `npx knip`, `npm run scan:todos`, `npm run docs:check`, `npm run verify:test-discovery` and
`npm run mcp-catalog:check`: passed.
- `npm run jscpd`: 3.43% duplicated lines over 3754 files.
- `npx vitest run --project domain --project services`: 7769 passed, 5 pre-existing skips.
- `npm run test:prepare`, then `npm run test:focused --` the environment serve and export CLI boundaries, the serve
and served-writes MCP boundaries, the analysis-view CLI and MCP boundaries, the compact input schema boundary and
tool schema validity: 8 files, 79 passed.
## Tests
- Gate: a renamed book renewing a sent `version_id` and its chapters' ids is served with `unique` and `renewed`
rules; two creates sending a `handle` stored as `code` and embedded in `link` are served with `copy` and
`template` rules.
- Serving: each served update gets a fresh version and new chapter ids that keep their titles; a served create with
a new handle stores it as the code and rebuilds the link.
- Unit: the explanation, including a UUID another entity holds (not `unique`), a child row whose text changed (not
`renewed`), and a member copied only in part (still fixed); the renewal helpers; applying both update rules.
- The four gate and serving tests fail on `43d98ea9`.
- Review round (on `a477b21d`, where each fails): serving renews only the UUIDs at the pointers the recording renewed,
keeps an author id each chapter names, gives one UUID the same replacement in every renewed field, and gives no rows
to an entity that holds none; a recorded call that keeps a renewed value refuses the plan; a renewal that keeps,
moves or borrows a UUID is not one; a copied member is type-checked against its stored field; a member stored
otherwise under its own name is not accepted; an echoed value's copy is no template source; a sent UUID stored in
another letter case is not a generated one.
## Pilot recordings
Each application was reset, then recorded with the capture's reads and, per write target, two scratch objects whose
every write was read back at once (item and list), deletes read back as 404.
| Application | Write | Before | After |
| ----------- | ------------------------------------- | -------------------- | -------------------- |
| Shlink | `post_rest_short_urls` | `unexplained-change` | served |
| n8n | `patch_rest_workflows_by_workflow_id` | `unexplained-change` | served |
| Mealie | `patch_recipes_by_recipe_id` | `unexplained-change` | `unexplained-change` |
No other verdict of the six recordings changed. Mealie's update was served by an earlier version of this branch with
`recipeIngredient` renewed. Each of its two recorded updates keeps the ingredient ids; the gate's copy of the second
recipe held the first recipe's ids, because the read that showed it was the create's observation and was not adopted.
Each renewed field now needs every recorded call to renew it, so the plan is refused rather than served regenerating
ingredient ids. Built on the list-query stack (#5800 with #5699 merged), the held-out sessions agreed on two more
steps each for n8n (127 to 129 of 143) and Shlink (126 to 128 of 137) with the earlier version; Komga, NetBox and
Sentry were unchanged.
## Published captures
- Over the 50 published captures, every write verdict is unchanged: 31 served write tools. The only plan that
changes is Shlink's unserved create, which now accepts `customSlug` and templates `shortUrl`.
- An earlier version also made echoed values template sources. It templated Grafana's folder `url` around `uid`
(the URL also holds a slug of the title) and BookStack's page HTML around its markdown. Echoed values are no
longer sources.
- The served smoke applies all 31 served write tools, as on `43d98ea9`.
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { servedFrom } from "../../../tests/fixtures/environmentStateServing.js";
const uuid = (n: number): string =>
`00000000-0000-4000-8000-${String(n).padStart(12, "0")}`;
describe("assigned values preserve every recorded relation", () => {
it("freezes a copied member when another template would retain its old value", () => {
const item = (id: number, slug: string) => ({
id,
code: slug,
url: `/${slug}`,
mixed: `/${id}/${slug}`,
});
const first = item(1, "first-slug");
const second = item(2, "second-slug");
const third = item(3, "abcdefgh");
const books = servedFrom([
["GET", "/api/books", [first, second]],
["GET", "/api/books/1", first],
["GET", "/api/books/2", second],
["POST", "/api/books", third, 201, { customSlug: "abcdefgh" }],
["GET", "/api/books/3", third],
]);
expect(books.state.writes.get("post_books")).toMatchObject({
served: true,
body: { fixed: [{ member: "customSlug", sent: "abcdefgh" }] },
});
expect(
books.refusal("post_books", { body: { customSlug: "ijklmnop" } }),
).toMatchObject({ code: "invalid_request" });
expect(
books.answer("post_books", { body: { customSlug: "abcdefgh" } }).body,
).toMatchObject({ code: "abcdefgh", url: "/abcdefgh" });
});
it.each(["nested", "scalar"] as const)(
"withholds an update whose generated scalar shares a fresh UUID with a %s field",
(shape) => {
const linked = (id: number, value: string) => ({
id,
name: "Before",
version: value,
link: shape === "nested" ? [{ id: value }] : value,
});
const first = linked(1, uuid(1));
const second = linked(2, uuid(2));
const after = { ...linked(1, uuid(3)), name: "After" };
const books = servedFrom([
["GET", "/api/books", [first, second]],
["GET", "/api/books/1", first],
["GET", "/api/books/2", second],
["PATCH", "/api/books/1", after, 200, { name: "After" }],
["GET", "/api/books/1", after],
]);
expect(books.state.writes.get("patch_books_by_id")).toMatchObject({
served: false,
reason: "unexplained-change",
});
},
);
});
@@ -14,6 +14,10 @@ import {
FIRST_BOOK,
OMITTED_FIELD_ROWS,
PROJECTED_ROWS,
AUTHORED_ROWS,
CHAPTER_AUTHOR,
CONTENTS_ROWS,
HANDLE_ROWS,
LIST_LINE_ROWS,
QUERY_ROWS,
SECOND_BOOK,
@@ -22,6 +26,9 @@ import {
book,
bookClock,
detailedBook,
UNCHAPTERED_ROWS,
VERSIONED_ROWS,
numberedUuid,
bookPage,
renamedBook,
type Book,
@@ -362,6 +369,88 @@ describe("write coverage limits", () => {
});
});
describe("values the application assigns", () => {
it("gives each update a fresh version and new chapter ids, keeping the chapters' other values", () => {
const books = servedFrom(VERSIONED_ROWS);
const rename = (name: string) =>
books.answer("patch_books_by_id", {
id: "2",
body: { name, version_id: "any version" },
}).body as Book;
const first = rename("Second Edition");
const second = rename("Third Edition");
const held = [1, 2, 3, 4, 5, 6, 7, 8].map(numberedUuid);
for (const answer of [first, second]) {
expect(held).not.toContain(answer.version_id);
expect(answer.chapters).toEqual([
{ id: expect.not.stringMatching(numberedUuid(4)), title: "Opening" },
]);
}
expect(second.version_id).not.toBe(first.version_id);
expect(books.answer("get_books_by_id", { id: "2" }).body).toEqual(second);
});
it("renews chapter ids alone, keeping the author each chapter names", () => {
const answer = servedFrom(AUTHORED_ROWS).answer("patch_books_by_id", {
id: "2",
body: { name: "Second Edition", version_id: "any version" },
}).body as Book;
expect(answer.chapters).toEqual([
{
id: expect.not.stringMatching(numberedUuid(4)),
title: "Opening",
author_id: CHAPTER_AUTHOR,
},
]);
});
it("renews a chapter id alike wherever an update renews it", () => {
const answer = servedFrom(CONTENTS_ROWS).answer("patch_books_by_id", {
id: "2",
body: { name: "Second Edition", version_id: "any version" },
}).body as {
chapters: [{ id: string }];
contents: [{ chapter_id: string }];
};
expect(answer.chapters[0].id).not.toBe(numberedUuid(4));
expect(answer.contents[0].chapter_id).toBe(answer.chapters[0].id);
});
it("gives no chapter rows to a book the state holds without them", () => {
const books = servedFrom(UNCHAPTERED_ROWS);
const before = books.snapshot();
expect(
books.refusal("patch_books_by_id", {
id: "2",
body: { name: "Second Edition", version_id: "any version" },
}).message,
).toContain("lacks chapters");
expect(books.snapshot()).toBe(before);
});
it("refuses a handle of another JSON type than the code it is stored as", () => {
const books = servedFrom(HANDLE_ROWS);
for (const handle of [{ not: "a string" }, null])
expect(
books.refusal("post_books", { body: { name: "Fifth Book", handle } })
.message,
).toContain("Member handle must be a JSON string");
});
it("stores a sent handle as the code and rebuilds the link around it", () => {
const books = servedFrom(HANDLE_ROWS);
expect(
books.answer("post_books", {
body: { name: "Fifth Book", handle: "fifth-handle" },
}).body,
).toMatchObject({
id: 3,
code: "fifth-handle",
link: "https://books.example.test/b/fifth-handle",
});
});
});
describe("refusals", () => {
it("refuses a body the capture does not support and leaves the state unchanged", () => {
const books = servedFrom();
@@ -1,6 +1,7 @@
import {
createdFields,
documentBodyProblem,
renewUuids,
updatedEntity,
updatedFields,
valueAt,
@@ -12,6 +13,7 @@ import {
insertIndex,
nextInteger,
} from "../../domain/analysisView/environmentStateWriteExplain.js";
import { callRenewal } from "../../domain/analysisView/environmentStateWriteGenerated.js";
import {
pathValuesOf,
type GeneratedRule,
@@ -152,6 +154,8 @@ const generator = (
readonly name: string;
readonly collection: readonly StateEntity[];
readonly observed: JsonObject;
/** The entity an update writes, whose values a renewed field keeps. */
readonly current?: JsonObject;
},
): {
readonly generate: GenerateValue;
@@ -169,6 +173,9 @@ const generator = (
// A sequence never reuses a value the state started with, even after its entity was deleted.
const started = store.startedWith(request.plan.target ?? "");
const startedIn = started.filter((held) => held.collection === target.name);
const renewed = callRenewal(
`${store.state.evidenceId}:${request.tool.name}:renewed:${String(write.count)}`,
);
const unused = (
field: string,
make: (attempt: number) => string,
@@ -211,6 +218,14 @@ const generator = (
return unused(rule.field, (attempt) =>
seededUuid(seed(attempt), String(exemplar)),
);
case "renewed":
return ok(
renewUuids(
target.current?.[rule.field] ?? null,
rule.pointers,
renewed,
),
);
case "identity":
if (rule.generator === "sequence")
return ok(
@@ -386,6 +401,7 @@ const answerUpdate = (
name: entity.collection,
collection: serving.store.inCollection(entity.type, entity.collection),
observed,
current: entity.fields,
});
const written = updatedFields(
plan,
@@ -191,6 +191,11 @@ const fieldRuleSchema = z.discriminatedUnion("rule", [
scope: z.enum(["collection", "type"]),
}),
z.strictObject({ ...ruled, rule: z.literal("unique") }),
z.strictObject({
...ruled,
rule: z.literal("renewed"),
pointers: z.array(z.string()).min(1),
}),
z.strictObject({
...ruled,
rule: z.literal("clock"),
@@ -6,6 +6,7 @@ import {
bodyProblem,
storedKinds,
createdFields,
renewUuids,
updatedEntity,
updatedFields,
valueAt,
@@ -42,7 +43,13 @@ const callWith = (
/** Generated values the store would supply, recording which rules asked. */
const generator = () => {
const asked: GeneratedRule[] = [];
const values = { identity: "id-9", sequence: 9, unique: "ref-9", clock: NOW };
const values = {
identity: "id-9",
sequence: 9,
unique: "ref-9",
renewed: ["renewed-9"],
clock: NOW,
};
const generate = (rule: GeneratedRule) => {
asked.push(rule);
return ok(values[rule.rule]);
@@ -283,11 +290,15 @@ const AFTER: JsonObject = {
revision: "10",
};
const update = (body: JsonObject | undefined, plan = UPDATE_PLAN) => {
const update = (
body: JsonObject | undefined,
plan = UPDATE_PLAN,
before = BEFORE,
) => {
const { asked, generate } = generator();
const result = updatedFields(
plan,
{ before: BEFORE, observed: AFTER },
{ before, observed: AFTER },
{ ...callWith(body, { item: "42" }), id: "42" },
generate,
);
@@ -331,6 +342,44 @@ it("applies a constant from the recorded update only when the call sends it", ()
);
});
const RENEWING_PLAN: Plan = {
rules: [
...UPDATE_PLAN.rules,
{ field: "versionId", rule: "unique" },
{ field: "steps", rule: "renewed", pointers: ["/*"] },
],
body: { ...UPDATE_PLAN.body!, accepted: ["title", "notes", "versionId"] },
};
it("takes an update's unique and renewed values from the generator, whether or not the call sends them", () => {
const { asked, result } = update(
{ title: "New Title", versionId: "stale" },
RENEWING_PLAN,
{ ...BEFORE, steps: ["step-1"] },
);
expect(result).toEqual(
ok({
...BEFORE,
...ADVANCED,
title: "New Title",
slug: "new-title",
versionId: "ref-9",
steps: ["renewed-9"],
}),
);
expect(asked.map((rule) => rule.rule)).toEqual([
"clock",
"unique",
"renewed",
]);
});
it("renews no child rows on an entity that holds none", () => {
const { asked, result } = update({ title: "New Title" }, RENEWING_PLAN);
expect(result.ok && result.value).not.toHaveProperty("steps");
expect(asked.map((rule) => rule.rule)).toEqual(["clock", "unique"]);
});
it.each<[string, JsonObject | undefined, string]>([
[
"only the identity",
@@ -540,3 +589,58 @@ it("learns own stored JSON member kinds without inheriting prototype members", (
]);
expect(storedKinds(["constructor", "toString"], {}, {})).toEqual([]);
});
const RENEW_ID = (n: number): string =>
`00000000-0000-4000-8000-${String(n).padStart(12, "0")}`;
it("renews the UUIDs at the renewed pointers alone, and keeps every other value", () => {
const renewed = renewUuids(
{
first: RENEW_ID(1),
rows: [
{ id: RENEW_ID(1), author: RENEW_ID(3), text: "Step 0" },
{ id: RENEW_ID(2), author: RENEW_ID(3), text: "Step 1" },
],
note: "kept",
},
["/first", "/rows/*/id"],
(uuid) => `${uuid}:new`,
);
expect(renewed).toEqual({
first: `${RENEW_ID(1)}:new`,
rows: [
{ id: `${RENEW_ID(1)}:new`, author: RENEW_ID(3), text: "Step 0" },
{ id: `${RENEW_ID(2)}:new`, author: RENEW_ID(3), text: "Step 1" },
],
note: "kept",
});
});
it("checks a member stored only under another name against the type of the field it is copied into", () => {
const plan: Plan = {
rules: [
{ field: "title", rule: "echo" },
{ field: "code", rule: "copy", from: "/handle" },
],
body: {
accepted: ["title", "handle"],
fixed: [],
required: [],
identity: null,
kinds: [],
},
};
const observed = { id: "id-1", title: "Old", code: "old-handle" };
const created = (handle: JsonValue) =>
createdFields(
plan,
observed,
callWith({ title: "New", handle }),
generator().generate,
);
expect(created("new-handle")).toEqual(
ok({ id: "id-1", title: "New", code: "new-handle" }),
);
expect(created({ not: "a string" }).ok).toBe(false);
expect(created(null).ok).toBe(false);
});
@@ -3,6 +3,7 @@ import { err, ok, type Result } from "../result.js";
import {
at,
isObject,
pointerTo,
pointerTokens,
replaceAt,
same,
@@ -21,11 +22,35 @@ import type {
} from "./environmentStateWriteRules.js";
import {
integerOf,
isUuid,
replaceDelimited,
slugOf,
spelledLike,
} from "./environmentStateWriteValues.js";
/** The value with each UUID at the pointers (`*` for any array index) replaced by `fresh(uuid)`, others kept. */
export const renewUuids = (
value: JsonValue,
pointers: readonly string[],
fresh: (uuid: string) => string,
): JsonValue => {
const renewed = new Set(pointers);
const renew = (item: JsonValue, pointer: string): JsonValue => {
if (isUuid(item)) return renewed.has(pointer) ? fresh(item) : item;
if (Array.isArray(item))
return item.map((child) => renew(child, `${pointer}/*`));
return isObject(item)
? Object.fromEntries(
Object.entries(item).map(([key, child]) => [
key,
renew(child, `${pointer}${pointerTo(key)}`),
]),
)
: item;
};
return renew(value, "");
};
/** A refusal message naming what the call did wrong; the state is left unchanged. */
type Applied<T> = Result<T, string>;
@@ -156,6 +181,7 @@ const ruledValue = (
case "identity":
case "sequence":
case "unique":
case "renewed":
case "clock":
return generate(rule);
case "echo":
@@ -215,6 +241,20 @@ const derive = (
}
};
/** Members stored only under another name, with the value of the field each is copied whole into, for type checks. */
const copiedShapes = (
rules: readonly FieldRule[],
observed: JsonObject,
): JsonObject =>
Object.fromEntries(
rules.flatMap((rule): [string, JsonValue][] => {
if (rule.rule !== "copy" || !Object.hasOwn(observed, rule.field))
return [];
const tokens = pointerTokens(rule.from);
return tokens.length === 1 ? [[tokens[0]!, observed[rule.field]!]] : [];
}),
);
/** The fields of a created entity: the observed entity with every ruled field taken from the call or generated. */
export const createdFields = (
plan: Pick<StateWritePlan, "rules" | "body">,
@@ -223,7 +263,9 @@ export const createdFields = (
generate: GenerateValue,
): Applied<JsonObject> => {
if (plan.body === null) return err("The tool has no modelled body.");
const problem = bodyProblem(plan.body, call, { entity: observed });
const problem = bodyProblem(plan.body, call, {
entity: { ...copiedShapes(plan.rules, observed), ...observed },
});
if (problem !== undefined) return err(problem);
const written: JsonObject = { ...observed };
for (const rule of plan.rules) {
@@ -268,7 +310,12 @@ export const updatedFields = (
const was = integerOf(before[rule.field]);
if (was !== undefined)
written[rule.field] = spelledLike(before[rule.field]!, was + 1);
} else if (rule.rule === "clock") {
} else if (
rule.rule === "clock" ||
rule.rule === "unique" ||
// Child rows are renewed only on an entity that holds them.
(rule.rule === "renewed" && Object.hasOwn(before, rule.field))
) {
const value = generate(rule);
if (!value.ok) return value;
if (value.value !== undefined) written[rule.field] = value.value;
@@ -0,0 +1,137 @@
import type { JsonValue } from "../jsonValue.js";
import { same, type JsonObject } from "./environmentStateRules.js";
import { storedKinds } from "./environmentStateWriteApply.js";
import type { explainUpdate } from "./environmentStateWriteExplain.js";
import type {
FieldRule,
StateWritePlan,
WriteBody,
} from "./environmentStateWriteRules.js";
import { integerOf, slugOf } from "./environmentStateWriteValues.js";
/** Whether a rule changes its field on calls that do not send it: serving applies it to every call that could. */
export const unsentChange = (rule: FieldRule): boolean =>
rule.rule === "increment" || rule.rule === "slug";
/** Whether serving gives a rule's field a new value on every call, so no call may have kept it. */
export const regenerates = (rule: FieldRule): boolean =>
rule.rule === "unique" || rule.rule === "renewed";
/** The fields a call showed as they were before it. */
export const keptFields = (before: JsonObject, after: JsonObject): string[] =>
Object.keys(after).filter(
(field) =>
Object.hasOwn(before, field) && same(before[field], after[field]),
);
/** Whether a plan's rules give a recorded call another value than it showed, or one of them regenerates a field the call kept. */
export const disagreesWithCall = (
rules: readonly FieldRule[],
call: {
readonly written: JsonObject;
readonly after: JsonObject;
readonly kept: readonly string[];
},
): boolean =>
rules.some(
(rule) =>
(unsentChange(rule) &&
Object.hasOwn(call.after, rule.field) &&
!same(call.written[rule.field], call.after[rule.field])) ||
(regenerates(rule) && call.kept.includes(rule.field)),
);
/** A call's scalar members by name, as much of it as later rules are checked against. */
export const scalars = (object: JsonObject): Map<string, JsonValue> =>
new Map(
Object.entries(object).filter(
([, value]) => value === null || typeof value !== "object",
),
);
/** The scalar values a modelled update call showed and sent, and the fields it kept, which later rules must reproduce. */
export interface UpdateCall {
readonly shown: ReadonlyMap<string, JsonValue>;
readonly sent: ReadonlyMap<string, JsonValue>;
readonly kept: readonly string[];
}
/** Whether a rule a later call showed gives an earlier call a value other than the one it showed for a field it did not send. */
export const contradictsEarlierCalls = (
draft: {
readonly plan: { readonly rules: readonly FieldRule[] };
readonly updates: readonly UpdateCall[];
},
rules: readonly FieldRule[],
): boolean => {
const ruled = new Set(draft.plan.rules.map((rule) => rule.field));
return rules.some(
(rule) =>
(unsentChange(rule) || regenerates(rule) || rule.rule === "clock") &&
!ruled.has(rule.field) &&
draft.updates.some(({ shown, sent, kept }) => {
if (regenerates(rule)) return kept.includes(rule.field);
// An unruled field an earlier call showed is one it left as it was.
const value = shown.get(rule.field);
if (value === undefined) return false;
if (rule.rule === "increment") return integerOf(value) !== undefined;
// Serving stamps a clock on every call, so a field an earlier call kept contradicts it.
if (rule.rule === "clock") return true;
const from = rule.rule === "slug" ? sent.get(rule.from) : undefined;
return typeof from === "string" && slugOf(from) !== value;
}),
);
};
/** A plan's body with the members and fixed values a later call's body adds, and the JSON types it stored those members as when the observation shows none. */
export const refinedBody = (
body: WriteBody,
later: WriteBody,
values: { readonly observed: JsonObject; readonly stored: JsonObject },
): WriteBody => {
const fixed = new Set(body.fixed.map((entry) => entry.member));
const accepted = later.accepted.filter(
(member) => !body.accepted.includes(member),
);
return {
...body,
// A later call may be the first to show the body repeating the path's id.
identity: body.identity ?? later.identity,
accepted: [...body.accepted, ...accepted],
fixed: [
...body.fixed,
...later.fixed.filter((entry) => !fixed.has(entry.member)),
],
// Serving reads member types from the observation, which may not show a member a later call stored.
kinds: [
...body.kinds,
...storedKinds(accepted, values.observed, values.stored),
],
};
};
/** Whether a later call explains a member or field otherwise than the plan: echoed by one and stored as something else by the other, or ruled differently. */
export const conflictsWithPlan = (
plan: Pick<StateWritePlan, "body" | "rules">,
explained: ReturnType<typeof explainUpdate>,
): boolean => {
const body = plan.body!;
const fixed = new Map(body.fixed.map((entry) => [entry.member, entry.sent]));
const rules = new Map(plan.rules.map((rule) => [rule.field, rule]));
const { identity } = explained.body;
// Serving checks a call's identity member before its fixed values, so a member both cannot take every recorded call.
return (
explained.body.accepted.some((member) => fixed.has(member)) ||
explained.body.fixed.some(
(entry) =>
body.accepted.includes(entry.member) ||
entry.member === body.identity ||
(fixed.has(entry.member) && !same(fixed.get(entry.member), entry.sent)),
) ||
(identity !== null && fixed.has(identity)) ||
explained.rules.some((rule) => {
const held = rules.get(rule.field);
return held !== undefined && !same(held, rule);
})
);
};
@@ -0,0 +1,86 @@
import {
pointerTokens,
scalarText,
type JsonObject,
} from "./environmentStateRules.js";
import { GENERATED } from "./environmentStateWriteGenerated.js";
import type { FieldRule } from "./environmentStateWriteRules.js";
import { delimitedOffsets } from "./environmentStateWriteValues.js";
/** Shorter body strings are too common to show that a field copied one. */
export const COPY_MIN_LENGTH = 8;
/** A string that embeds a generated value, or a long value copied from the call, delimited is rebuilt around the new value. */
export const templateRule = (
entity: JsonObject,
field: string,
sources: readonly FieldRule[],
): FieldRule | undefined => {
const value = entity[field];
if (typeof value !== "string") return undefined;
for (const rule of sources) {
const token = scalarText(entity[rule.field]);
const generated = GENERATED.has(rule.rule);
if (
rule.field !== field &&
token !== undefined &&
token.length >= (generated ? 1 : COPY_MIN_LENGTH) &&
delimitedOffsets(value, token).length > 0
)
return { field, rule: "template", from: rule.field };
}
return undefined;
};
/** Body members the entity stores as sent, under their own name (echo) or only another (a copy of the whole member). */
export const storedMembers = (
rules: readonly FieldRule[],
entity: JsonObject,
): Set<string> =>
new Set(
rules.flatMap((rule) => {
if (rule.rule === "echo") return [rule.field];
const tokens = rule.rule === "copy" ? pointerTokens(rule.from) : [];
// A member also stored otherwise under its own name is not stored as sent.
return tokens.length === 1 && !Object.hasOwn(entity, tokens[0]!)
? tokens
: [];
}),
);
/** Copy rules whose value also sits in text no template rebuilds around it, so a call changing their member would leave that text stale. */
export const strandedCopies = (
rules: readonly FieldRule[],
entity: JsonObject,
texts: readonly string[],
): Set<string> => {
const rebuilt = new Map(
rules.flatMap((rule) =>
rule.rule === "template" ? [[rule.field, rule.from] as const] : [],
),
);
return new Set(
rules.flatMap((rule) => {
const token = scalarText(entity[rule.field]);
const text = (field: string): string | undefined => {
const value = entity[field];
return field === rule.field || typeof value !== "string"
? undefined
: value;
};
return rule.rule === "copy" &&
token !== undefined &&
token.length >= COPY_MIN_LENGTH &&
texts.some((field) => {
const value = text(field);
return (
rebuilt.get(field) !== rule.field &&
value !== undefined &&
delimitedOffsets(value, token).length > 0
);
})
? [rule.field]
: [];
}),
);
};
@@ -22,18 +22,26 @@ import {
type SimulatedState,
} from "./environmentStateWriteChecks.js";
import {
conflictsWithPlan,
explainUpdate,
refinedBody,
responseShapeOf,
} from "./environmentStateWriteExplain.js";
import { otherFields } from "./environmentStateWriteGenerated.js";
import {
contradictsEarlierCalls,
disagreesWithCall,
keptFields,
scalars,
type UpdateCall,
conflictsWithPlan,
refinedBody,
} from "./environmentStateWriteCalls.js";
import {
laterObservation,
shownBefore,
viewFields,
type Observed,
} from "./environmentStateWriteObserve.js";
import { integerOf, readClock, slugOf } from "./environmentStateWriteValues.js";
import { readClock } from "./environmentStateWriteValues.js";
import {
pathValuesOf,
type FieldRule,
@@ -55,11 +63,8 @@ export interface WriteDraft {
contradictedBy: number | undefined;
sideEffect: boolean;
calls: number;
/** The scalar values each modelled update call showed and sent, which later rules must reproduce. */
readonly updates: {
readonly shown: ReadonlyMap<string, JsonValue>;
readonly sent: ReadonlyMap<string, JsonValue>;
}[];
/** The scalar values each modelled update call showed and sent, and the fields it kept, which later rules must reproduce. */
readonly updates: UpdateCall[];
/** The last value each sequence took, by field and, for a collection's sequence, collection; the next call must follow it. */
readonly sequences: Map<string, number>;
}
@@ -305,41 +310,6 @@ export const retainFixed = (
context.io.retain(64 + JSON.stringify(fixed).length * 2);
};
/** Whether a rule changes its field on calls that do not send it: serving applies it to every call that could. */
const unsentChange = (rule: FieldRule): boolean =>
rule.rule === "increment" || rule.rule === "slug";
/** A call's scalar members by name, as much of it as later rules are checked against. */
const scalars = (object: JsonObject): Map<string, JsonValue> =>
new Map(
Object.entries(object).filter(
([, value]) => value === null || typeof value !== "object",
),
);
/** Whether a rule a later call showed gives an earlier call a value other than the one it showed for a field it did not send. */
const contradictsEarlierCalls = (
draft: WriteDraft,
rules: readonly FieldRule[],
): boolean => {
const ruled = new Set(draft.plan.rules.map((rule) => rule.field));
return rules.some(
(rule) =>
(unsentChange(rule) || rule.rule === "clock") &&
!ruled.has(rule.field) &&
draft.updates.some(({ shown, sent }) => {
// An unruled field an earlier call showed is one it left as it was.
const value = shown.get(rule.field);
if (value === undefined) return false;
if (rule.rule === "increment") return integerOf(value) !== undefined;
// Serving stamps a clock on every call, so a field an earlier call kept contradicts it.
if (rule.rule === "clock") return true;
const from = rule.rule === "slug" ? sent.get(rule.from) : undefined;
return typeof from === "string" && slugOf(from) !== value;
}),
);
};
/** Build an update plan from its exemplar, or add what a later call showed. */
const buildUpdate = (
context: EffectContext,
@@ -432,6 +402,7 @@ export const applyUpdate = (
id,
startMs: startOf(context, step),
...(after.readFields === undefined ? {} : { readFields: after.readFields }),
others: otherFields(context.state.ofType(type), { collection, id }),
shown: shownBefore(context.viewOf, entity, after.ordinal),
});
if (explained.unexplained.length > 0 && !unknown) {
@@ -454,20 +425,20 @@ export const applyUpdate = (
{ ...call, id },
(rule) => ok(after.object[rule.field]),
);
const kept = keptFields(entity.fields, after.object);
const disagrees =
written.ok &&
draft.plan.rules.some(
(rule) =>
unsentChange(rule) &&
Object.hasOwn(after.object, rule.field) &&
!same(written.value[rule.field], after.object[rule.field]),
);
disagreesWithCall(draft.plan.rules, {
written: written.value,
after: after.object,
kept,
});
if (!written.ok || (disagrees && !unknown))
refuse(draft, step, "unexplained-change");
if (!written.ok || disagrees || unknown) return recorded();
const facts = { shown: scalars(after.object), sent: scalars(body) };
const facts = { shown: scalars(after.object), sent: scalars(body), kept };
context.io.retain(
64 + JSON.stringify([...facts.shown, ...facts.sent]).length * 2,
64 + JSON.stringify([...facts.shown, ...facts.sent, kept]).length * 2,
);
draft.updates.push(facts);
context.io.retain(64 + JSON.stringify(written.value).length * 2);
@@ -12,6 +12,11 @@ import {
type CreateEvidence,
type UpdateEvidence,
} from "./environmentStateWriteExplain.js";
import {
callRenewal,
disagreeingRenewals,
renewedUuids,
} from "./environmentStateWriteGenerated.js";
import {
classifyWrite,
pathValuesOf,
@@ -26,6 +31,10 @@ const START = Date.UTC(2026, 2, 4, 5, 6, 0);
const MINUTE = 60_000;
const UUID = "123e4567-e89b-42d3-a456-426614174000";
const OTHER_UUID = "00000000-0000-4000-8000-000000000000";
const FRESH = [
"11111111-1111-4111-8111-111111111111",
"22222222-2222-4222-8222-222222222222",
] as const;
const created = (
entity: JsonObject,
@@ -59,6 +68,7 @@ const updated = (
idField: "id",
id: "42",
startMs: START,
others: [],
...context,
});
@@ -256,6 +266,112 @@ it.each<[string, JsonObject, boolean]>([
expect(result.tied).toBe(tied);
});
it("accepts a create member stored under another name, and rebuilds text that embeds the value it wrote", () => {
const result = explainCreate(
created(
{
id: 7,
code: "spring-tide",
link: "https://links.example.test/spring-tide",
name: "Spring tide",
label: "Lighthouse rota",
},
{
handle: "spring-tide",
name: "Spring tide",
meta: { label: "Lighthouse rota" },
},
),
);
expect(ruleFor(result.rules, "code")).toEqual({
field: "code",
rule: "copy",
from: "/handle",
});
expect(ruleFor(result.rules, "link")).toEqual({
field: "link",
rule: "template",
from: "code",
});
// A member copied only in part (one value nested inside it) is still stored differently.
expect(result.body).toMatchObject({
accepted: ["handle", "name"],
fixed: [{ member: "meta", sent: { label: "Lighthouse rota" } }],
});
});
it("explains an update that puts a fresh UUID in a sent or kept field as unique, accepting the member with any value", () => {
const before = {
id: "42",
name: "Old",
versionId: UUID,
revisionId: OTHER_UUID,
};
const after = {
id: "42",
name: "New",
versionId: FRESH[0],
revisionId: FRESH[1],
};
const body = { name: "New", versionId: UUID };
const result = explainUpdate(updated(before, after, body));
expect(ruleFor(result.rules, "versionId")).toEqual({
field: "versionId",
rule: "unique",
});
expect(ruleFor(result.rules, "revisionId")).toEqual({
field: "revisionId",
rule: "unique",
});
expect(result.body).toMatchObject({
accepted: ["name", "versionId"],
fixed: [],
});
expect(result.unexplained).toEqual([]);
// A UUID another entity holds is a reference the update moved, not one it generated.
const held = explainUpdate(
updated(before, after, body, {
others: [{ id: "7", versionId: FRESH[0], revisionId: FRESH[1] }],
}),
);
expect(ruleFor(held.rules, "versionId")).toEqual({
field: "versionId",
rule: "constant",
value: FRESH[0],
});
expect(held.unexplained).toEqual(["revisionId"]);
});
it("explains child rows an update rewrote with new UUIDs as renewed, and other changes to them as unexplained", () => {
const rows = (...ids: string[]) =>
ids.map((id, index) => ({ id, text: `Step ${String(index)}` }));
const before = { id: "42", notes: "Old", steps: rows(UUID, OTHER_UUID) };
const renewed = explainUpdate(
updated(
before,
{ ...before, notes: "New", steps: rows(...FRESH) },
{ notes: "New" },
),
);
expect(ruleFor(renewed.rules, "steps")).toEqual({
field: "steps",
rule: "renewed",
pointers: ["/*/id"],
});
const edited = explainUpdate(
updated(
before,
{
...before,
notes: "New",
steps: [{ id: FRESH[0], text: "Step one" }, ...rows(FRESH[1]!)],
},
{ notes: "New" },
),
);
expect(edited.unexplained).toEqual(["steps"]);
});
it("explains an update's sent members as echoes, constants or the identity", () => {
const result = explainUpdate(
updated(
@@ -645,3 +761,221 @@ it(
expect(ruleFor(result.rules, "note")).toBeUndefined();
},
);
const ID = (n: number): string =>
`00000000-0000-4000-8000-${String(n).padStart(12, "0")}`;
const steps = (...ids: number[]): JsonValue =>
ids.map((id, index) => ({ id: ID(id), text: `Step ${String(index)}` }));
it("finds the UUIDs a value renewed when nothing else in it changed", () => {
expect(renewedUuids(steps(1, 2), steps(3, 4))).toEqual({
uuids: new Map([
[ID(2), ID(4)],
[ID(1), ID(3)],
]),
pointers: ["/*/id"],
});
expect(renewedUuids(steps(1, 2), steps(1, 2))).toBeUndefined();
// Another text, another length, a UUID moved to another row, one UUID renewed twice.
expect(
renewedUuids(steps(1), [{ id: ID(3), text: "Step one" }]),
).toBeUndefined();
expect(renewedUuids(steps(1, 2), steps(3))).toBeUndefined();
expect(renewedUuids(steps(1, 2), steps(2, 1))).toBeUndefined();
expect(renewedUuids([ID(1), ID(1)], [ID(3), ID(4)])).toBeUndefined();
});
it("renews no UUID kept elsewhere, moved from elsewhere or kept at a pointer it renewed", () => {
expect(
renewedUuids(
{ current: ID(1), fallback: ID(2) },
{ current: ID(2), fallback: ID(2) },
),
).toBeUndefined();
expect(renewedUuids([ID(1), ID(1)], [ID(2), ID(1)])).toBeUndefined();
expect(renewedUuids([ID(1), ID(2)], [ID(3), ID(2)])).toBeUndefined();
expect(renewedUuids([ID(1), ID(2)], [ID(3), ID(3)])).toBeUndefined();
// A reference kept beside renewed ids stays outside the renewal.
expect(
renewedUuids(
[{ id: ID(1), author: ID(9) }],
[{ id: ID(2), author: ID(9) }],
),
).toEqual({ uuids: new Map([[ID(1), ID(2)]]), pointers: ["/*/id"] });
});
it("explains no renewed field whose new UUIDs another entity holds, or that renews a shared UUID otherwise than another field", () => {
const before = { id: "42", owner: { id: ID(1) }, toc: [{ ref: ID(5) }] };
const after = { id: "42", owner: { id: ID(2) }, toc: [{ ref: ID(5) }] };
expect(
explainUpdate(
updated(
before,
after,
{},
{ others: [{ id: "7", owner: { id: ID(2) } }] },
),
).unexplained,
).toEqual(["owner"]);
expect(
ruleFor(explainUpdate(updated(before, after, {})).rules, "owner"),
).toEqual({ field: "owner", rule: "renewed", pointers: ["/id"] });
const shared = { id: "42", rows: [{ id: ID(1) }], toc: [{ ref: ID(1) }] };
expect(
explainUpdate(
updated(
shared,
{ id: "42", rows: [{ id: ID(2) }], toc: [{ ref: ID(3) }] },
{},
),
).unexplained,
).toEqual(["toc"]);
expect(
explainUpdate(
updated(
shared,
{ id: "42", rows: [{ id: ID(2) }], toc: [{ ref: ID(2) }] },
{},
),
).unexplained,
).toEqual([]);
});
it("does not accept a member a create stores otherwise under its own name, though it also copies it", () => {
const result = explainCreate(
created(
{ id: 7, title: "QUARTERLY REPORT", label: "Quarterly Report" },
{ title: "Quarterly Report" },
),
);
expect(ruleFor(result.rules, "label")).toEqual({
field: "label",
rule: "copy",
from: "/title",
});
expect(result.body.accepted).not.toContain("title");
});
it("builds no template from a copy of an echoed value", () => {
const result = explainCreate(
created(
{
id: 7,
markdown: "Weekly planning notes",
text: "Weekly planning notes",
html: "<p>Weekly planning notes</p>",
},
{ markdown: "Weekly planning notes" },
),
);
expect(ruleFor(result.rules, "html")).toBeUndefined();
});
it("explains a sent UUID stored in another letter case as stored otherwise, not as one the application generated", () => {
const result = explainUpdate(
updated(
{ id: "42", assignee: ID(1) },
{ id: "42", assignee: ID(2).replace("4000", "4abc") },
{ assignee: ID(2).replace("4000", "4ABC") },
),
);
expect(ruleFor(result.rules, "assignee")).toEqual({
field: "assignee",
rule: "constant",
value: ID(2).replace("4000", "4abc"),
});
expect(result.body.accepted).toEqual([]);
});
/** A UUID of one repeated hex letter, so its letter case shows. */
const LETTERED = (letter: string): string =>
`${letter.repeat(8)}-${letter.repeat(4)}-4${letter.repeat(3)}-a${letter.repeat(3)}-${letter.repeat(12)}`;
it("generates no UUID into a field that holds one the call sent in another member", () => {
const result = explainUpdate(
updated(
{ id: "42", sourceId: ID(1), aliasId: ID(2) },
{ id: "42", sourceId: ID(3), aliasId: ID(3) },
{ sourceId: ID(3) },
),
);
// The field follows the sent member, so serving keeps the two linked.
expect(ruleFor(result.rules, "aliasId")).toEqual({
field: "aliasId",
rule: "slug",
from: "sourceId",
});
});
it("renews one UUID alike in either letter case, and spells each place in its own case", () => {
const rules: FieldRule[] = [
{ field: "rows", rule: "renewed", pointers: ["/*/id"] },
{ field: "toc", rule: "renewed", pointers: ["/*/ref"] },
];
const before = {
rows: [{ id: LETTERED("a") }],
toc: [{ ref: LETTERED("a").toUpperCase() }],
};
const renewed = (row: string, ref: string) => ({
rows: [{ id: row }],
toc: [{ ref }],
});
expect(
disagreeingRenewals(
rules,
before,
renewed(LETTERED("b"), LETTERED("c").toUpperCase()),
),
).toEqual(["toc"]);
expect(
disagreeingRenewals(
rules,
before,
renewed(LETTERED("b"), LETTERED("b").toUpperCase()),
),
).toEqual([]);
const renew = callRenewal("call");
const row = renew(LETTERED("a"));
expect(renew(LETTERED("a").toUpperCase())).toBe(row.toUpperCase());
expect(row).toBe(row.toLowerCase());
});
it("refuses renewals that join two old UUIDs onto one new one", () => {
const rules: FieldRule[] = [
{ field: "left", rule: "renewed", pointers: ["/*"] },
{ field: "right", rule: "renewed", pointers: ["/*"] },
];
expect(
disagreeingRenewals(
rules,
{ left: [ID(1)], right: [ID(2)] },
{ left: [ID(3)], right: [ID(3)] },
),
).toEqual(["right"]);
});
it("accepts a copied member only with its recorded value when a template keeps it beside another source", () => {
const result = explainCreate(
created(
{
id: 7,
name: "Atlas",
code: "alpha-handle-1",
groupCode: "group-alpha-2",
link: "/g/group-alpha-2/u/alpha-handle-1",
},
{ name: "Atlas", handle: "alpha-handle-1", group: "group-alpha-2" },
),
);
expect(ruleFor(result.rules, "link")).toEqual({
field: "link",
rule: "template",
from: "code",
});
expect(result.body.accepted).toContain("handle");
expect(result.body.accepted).not.toContain("group");
expect(result.body.fixed).toContainEqual({
member: "group",
sent: "group-alpha-2",
});
});
@@ -1,6 +1,5 @@
import type { JsonValue } from "../jsonValue.js";
import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js";
import { storedKinds } from "./environmentStateWriteApply.js";
import {
isObject,
locateItem,
@@ -12,25 +11,33 @@ import {
import type {
FieldRule,
InsertRule,
StateWritePlan,
WriteBody,
WriteCall,
WriteResponse,
} from "./environmentStateWriteRules.js";
import {
containsDelimited,
delimitedOffsets,
integerOf,
isUuid,
readClock,
sequenceNumber,
slugOf,
} from "./environmentStateWriteValues.js";
import {
COPY_MIN_LENGTH,
storedMembers,
strandedCopies,
templateRule,
} from "./environmentStateWriteCopies.js";
import {
disagreeingRenewals,
freshUuid,
GENERATED,
renewedRule,
} from "./environmentStateWriteGenerated.js";
/** A clock value counts as written by the request when it lies this close to the request's start. */
const CLOCK_WINDOW_MS = 10 * 60_000;
/** Shorter body strings are too common to show that a field copied one. */
const COPY_MIN_LENGTH = 8;
/** What explaining a create reads: the observed entity, the call that wrote it and the entities it joined. */
export interface CreateEvidence {
@@ -174,27 +181,6 @@ const generatedRule = (
return undefined;
};
/** A string that embeds a generated value delimited is rebuilt around the new value. */
const templateRule = (
entity: JsonObject,
field: string,
generated: readonly FieldRule[],
): FieldRule | undefined => {
const value = entity[field];
if (typeof value !== "string") return undefined;
for (const rule of generated) {
const token = scalarText(entity[rule.field]);
if (
rule.field !== field &&
token !== undefined &&
token.length > 0 &&
delimitedOffsets(value, token).length > 0
)
return { field, rule: "template", from: rule.field };
}
return undefined;
};
/** Body members the application stored as sent, and the others with what they were sent. */
const bodyOf = (
body: JsonObject,
@@ -228,34 +214,49 @@ export const explainCreate = (
if (rule === undefined) unexplained.push(field);
else rules.push(rule);
}
const generated = rules.filter(
(rule) =>
rule.rule === "identity" ||
rule.rule === "sequence" ||
rule.rule === "unique",
// Generated values first, then copies: an echoed value may recur in unrelated text, a copied one is the call's own unless also echoed.
const echoed = new Set(
rules.flatMap((rule) =>
rule.rule === "echo" ? [scalarText(evidence.entity[rule.field])] : [],
),
);
const sources = [
...rules.filter((rule) => GENERATED.has(rule.rule)),
...rules.filter(
(rule) =>
rule.rule === "copy" &&
!echoed.has(scalarText(evidence.entity[rule.field])),
),
];
const constants: string[] = [];
for (const field of unexplained) {
const template = templateRule(evidence.entity, field, generated);
const template = templateRule(evidence.entity, field, sources);
if (template === undefined) constants.push(field);
else rules.push(template);
}
const echoed = new Set(
rules.filter((rule) => rule.rule === "echo").map((rule) => rule.field),
);
const tokens = [...evidence.call.pathValues.values()].filter(
(value) => value.length > 0,
);
const templates = rules.flatMap((rule) =>
rule.rule === "template" ? [rule.field] : [],
);
// A member whose copy a template cannot rebuild is accepted only with its recorded value.
const stranded = strandedCopies(rules, evidence.entity, [
...constants,
...templates,
]);
return {
rules,
body: bodyOf(evidence.call.body, echoed, Object.keys(evidence.call.body)),
// A template rebuilds only its generated value, so other text it keeps ties it like a constant.
tied: [
...constants,
...rules.flatMap((rule) =>
rule.rule === "template" ? [rule.field] : [],
body: bodyOf(
evidence.call.body,
storedMembers(
rules.filter((rule) => !stranded.has(rule.field)),
evidence.entity,
),
].some((field) =>
Object.keys(evidence.call.body),
),
// A template rebuilds only its generated value, so other text it keeps ties it like a constant.
tied: [...constants, ...templates].some((field) =>
tokens.some((token) => containsDelimited(evidence.entity[field]!, token)),
),
};
@@ -272,6 +273,8 @@ export interface UpdateEvidence {
readonly startMs: number | undefined;
/** Fields covered by a later read view. Missing fields outside that coverage remain unknown. */
readonly readFields?: readonly string[];
/** The type's other entities, none of which may hold a value the update generated. */
readonly others: readonly JsonObject[];
/** The fields of the observing read's view when that view showed the entity before, so one the entity lacked was added. */
readonly shown?: readonly string[];
}
@@ -286,6 +289,13 @@ const changeRule = (
const after = evidence.after[field]!;
if (readClock(after) !== undefined && isClockNear(after, evidence.startMs))
return { field, rule: "clock", format: readClock(after)!.format };
if (freshUuid(evidence, field)) return { field, rule: "unique" };
// Child rows only: a single UUID that changed is a reference or a generated value, judged above.
const renewed =
Array.isArray(before) || isObject(before)
? renewedRule(field, { before, after }, evidence.others)
: undefined;
if (renewed !== undefined) return renewed;
const was = integerOf(before);
if (was !== undefined && integerOf(after) === was + 1)
return { field, rule: "increment" };
@@ -321,6 +331,8 @@ export const explainUpdate = (
const rules: FieldRule[] = [];
const echoed = new Set<string>();
const unexplained: string[] = [];
// A member the application replaces with a value it generates is accepted with any value.
const generated = new Set<string>();
let identity: string | null = null;
for (const [member, sent] of Object.entries(body)) {
// The identity is the entity's key: the path's id repeated is the identity member; another value is one the application ignored, or used to re-key the entity, which no rule models.
@@ -334,6 +346,9 @@ export const explainUpdate = (
if (same(evidence.after[member], sent)) {
echoed.add(member);
rules.push({ field: member, rule: "echo" });
} else if (freshUuid(evidence, member)) {
generated.add(member);
rules.push({ field: member, rule: "unique" });
} else
rules.push({
field: member,
@@ -362,11 +377,15 @@ export const explainUpdate = (
if (rule === undefined) unexplained.push(field);
else rules.push(rule);
}
// Serving renews one UUID alike in every field, so the call must have.
unexplained.push(
...disagreeingRenewals(rules, evidence.before, evidence.after),
);
const shaped = bodyOf(
Object.fromEntries(
Object.entries(body).filter(([member]) => member !== identity),
),
echoed,
new Set([...echoed, ...generated]),
evidence.method === "PUT"
? Object.keys(body).filter((member) => member !== identity)
: [],
@@ -491,56 +510,3 @@ export const documentPointerOf = (
}
return best?.pointer;
};
/** A plan's body with the members and fixed values a later call's body adds, and the JSON types it stored those members as when the observation shows none. */
export const refinedBody = (
body: WriteBody,
later: WriteBody,
values: { readonly observed: JsonObject; readonly stored: JsonObject },
): WriteBody => {
const fixed = new Set(body.fixed.map((entry) => entry.member));
const accepted = later.accepted.filter(
(member) => !body.accepted.includes(member),
);
return {
...body,
// A later call may be the first to show the body repeating the path's id.
identity: body.identity ?? later.identity,
accepted: [...body.accepted, ...accepted],
fixed: [
...body.fixed,
...later.fixed.filter((entry) => !fixed.has(entry.member)),
],
// Serving reads member types from the observation, which may not show a member a later call stored.
kinds: [
...body.kinds,
...storedKinds(accepted, values.observed, values.stored),
],
};
};
/** Whether a later call explains a member or field otherwise than the plan: echoed by one and stored as something else by the other, or ruled differently. */
export const conflictsWithPlan = (
plan: Pick<StateWritePlan, "body" | "rules">,
explained: ReturnType<typeof explainUpdate>,
): boolean => {
const body = plan.body!;
const fixed = new Map(body.fixed.map((entry) => [entry.member, entry.sent]));
const rules = new Map(plan.rules.map((rule) => [rule.field, rule]));
const { identity } = explained.body;
// Serving checks a call's identity member before its fixed values, so a member both cannot take every recorded call.
return (
explained.body.accepted.some((member) => fixed.has(member)) ||
explained.body.fixed.some(
(entry) =>
body.accepted.includes(entry.member) ||
entry.member === body.identity ||
(fixed.has(entry.member) && !same(fixed.get(entry.member), entry.sent)),
) ||
(identity !== null && fixed.has(identity)) ||
explained.rules.some((rule) => {
const held = rules.get(rule.field);
return held !== undefined && !same(held, rule);
})
);
};
@@ -0,0 +1,201 @@
import type { JsonValue } from "../jsonValue.js";
import {
isObject,
pointerTo,
type JsonObject,
type StateEntity,
} from "./environmentStateRules.js";
import type { FieldRule } from "./environmentStateWriteRules.js";
import { isUuid, seededUuid } from "./environmentStateWriteValues.js";
/** The rules whose values the application generates. */
export const GENERATED: ReadonlySet<FieldRule["rule"]> = new Set([
"identity",
"sequence",
"unique",
]);
/** An independently generated UUID: not sent, held by another entity, or linked to another output field. */
export const freshUuid = (
evidence: {
readonly before: JsonObject;
readonly after: JsonObject;
readonly call: { readonly body: JsonObject };
readonly others: readonly JsonObject[];
},
field: string,
): boolean => {
const before = evidence.before[field];
const after = evidence.after[field];
if (!isUuid(before) || !isUuid(after)) return false;
const fresh = after.toLowerCase();
// Scalar generation and nested renewal have separate owners. A shared output UUID
// is a relation they cannot reproduce, not evidence for independent generation.
for (const [name, value] of Object.entries(evidence.after))
if (name !== field && uuidsIn(value).has(fresh)) return false;
return (
before.toLowerCase() !== fresh &&
!uuidsIn(evidence.call.body).has(fresh) &&
!uuidsIn(evidence.others.map((other) => other[field] ?? null)).has(fresh)
);
};
/** What an update renewed in a value: each old UUID's new one, and the pointers (`*` for any array index) it renewed at. */
export interface Renewal {
readonly uuids: ReadonlyMap<string, string>;
readonly pointers: string[];
}
/** The UUIDs two values hold at each pointer: those that changed, and those kept in place. */
interface Paired {
readonly renewed: Map<string, string>;
/** The same renewal in lower case, so one UUID spelled two ways is still one. */
readonly lower: Map<string, string>;
readonly changedAt: Set<string>;
readonly keptAt: Set<string>;
readonly kept: Set<string>;
}
/** The pointer of a value's child, with `*` for an array index. */
const childPointer = (pointer: string, key: string | number): string =>
`${pointer}${typeof key === "number" ? "/*" : pointerTo(key)}`;
/** Walk two values in step, undefined when they differ in anything but UUIDs or one UUID changes two ways. */
const pairUuids = (before: JsonValue, after: JsonValue): Paired | undefined => {
const paired: Paired = {
renewed: new Map(),
lower: new Map(),
changedAt: new Set(),
keptAt: new Set(),
kept: new Set(),
};
const pending: (readonly [JsonValue, JsonValue, string])[] = [
[before, after, ""],
];
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
const [was, now, pointer] = next;
if (isUuid(was) && isUuid(now)) {
if (was === now) {
paired.kept.add(was.toLowerCase());
paired.keptAt.add(pointer);
} else if (
was.toLowerCase() === now.toLowerCase() ||
(paired.lower.get(was.toLowerCase()) ?? now.toLowerCase()) !==
now.toLowerCase()
)
return undefined;
else {
paired.renewed.set(was, now);
paired.lower.set(was.toLowerCase(), now.toLowerCase());
paired.changedAt.add(pointer);
}
} else if (Array.isArray(was) && Array.isArray(now)) {
if (was.length !== now.length) return undefined;
for (const [index, item] of was.entries())
pending.push([item, now[index]!, childPointer(pointer, index)]);
} else if (isObject(was) && isObject(now)) {
const keys = Object.keys(was);
if (keys.length !== Object.keys(now).length) return undefined;
for (const key of keys) {
if (!Object.hasOwn(now, key)) return undefined;
pending.push([was[key]!, now[key]!, childPointer(pointer, key)]);
}
} else if (was !== now) return undefined;
}
return paired;
};
/**
* The UUIDs `after` holds where `before` held others, when the two differ in nothing else: every
* UUID at a pointer it renewed is new, each old one has one new one, and none was held before.
*/
export const renewedUuids = (
before: JsonValue,
after: JsonValue,
): Renewal | undefined => {
const paired = pairUuids(before, after);
if (paired === undefined || paired.renewed.size === 0) return undefined;
const { renewed, changedAt, keptAt, kept } = paired;
const old = [...renewed.keys()].map((uuid) => uuid.toLowerCase());
const fresh = [...renewed.values()].map((uuid) => uuid.toLowerCase());
const held = new Set([...kept, ...old]);
// A UUID kept where another was renewed, renewed twice into one, or already held is a reference that moved, not a fresh one.
return [...changedAt].every((at) => !keptAt.has(at)) &&
old.every((uuid) => !kept.has(uuid)) &&
new Set(fresh).size === fresh.length &&
fresh.every((uuid) => !held.has(uuid))
? { uuids: renewed, pointers: [...changedAt].sort() }
: undefined;
};
/** Every UUID a value holds, in lower case. */
export const uuidsIn = (value: JsonValue | undefined): Set<string> => {
const found = new Set<string>();
const pending: JsonValue[] = value === undefined ? [] : [value];
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
if (isUuid(next)) found.add(next.toLowerCase());
else if (Array.isArray(next) || isObject(next))
for (const child of Object.values(next)) pending.push(child);
}
return found;
};
/** The `renewed` rule of a field whose child rows an update renewed with UUIDs no other entity holds, if it did. */
export const renewedRule = (
field: string,
change: { readonly before: JsonValue; readonly after: JsonValue },
others: readonly JsonObject[],
): FieldRule | undefined => {
const renewal = renewedUuids(change.before, change.after);
const held = uuidsIn(others.map((other) => other[field] ?? null));
return renewal !== undefined &&
[...renewal.uuids.values()].every((uuid) => !held.has(uuid.toLowerCase()))
? { field, rule: "renewed", pointers: renewal.pointers }
: undefined;
};
/** Renewed fields of one update that give an old UUID another new one than an earlier field did, or another old UUID's new one, in any letter case, as serving renews each UUID once and alike. */
export const disagreeingRenewals = (
rules: readonly FieldRule[],
before: JsonObject,
after: JsonObject,
): string[] => {
const renewed = new Map<string, string>();
const owners = new Map<string, string>();
const fields = new Set<string>();
for (const rule of rules) {
if (rule.rule !== "renewed") continue;
const renewal = renewedUuids(before[rule.field]!, after[rule.field]!);
for (const [spelledWas, spelledNow] of renewal?.uuids ?? []) {
const was = spelledWas.toLowerCase();
const now = spelledNow.toLowerCase();
if ((renewed.get(was) ?? now) !== now || (owners.get(now) ?? was) !== was)
fields.add(rule.field);
renewed.set(was, now);
owners.set(now, was);
}
}
return [...fields];
};
/** The UUID replacements of one served call: each UUID once in any letter case, seeded by the call and the order it was first renewed in, and spelled in the case of each place it fills. */
export const callRenewal = (seed: string): ((uuid: string) => string) => {
const order = new Map<string, number>();
return (uuid) => {
const key = uuid.toLowerCase();
if (!order.has(key)) order.set(key, order.size);
return seededUuid(`${seed}:${String(order.get(key))}`, uuid);
};
};
/** The fields of every entity but the one named. */
export const otherFields = (
entities: readonly StateEntity[],
named: { readonly collection: string; readonly id: string },
): JsonObject[] =>
entities
.filter(
(entity) =>
entity.id !== named.id || entity.collection !== named.collection,
)
.map((entity) => entity.fields);
@@ -37,6 +37,12 @@ export type GeneratedRule =
readonly scope: "collection" | "type";
}
| { readonly field: string; readonly rule: "unique" }
/** The value kept with each UUID at the pointers (`*` for any array index) replaced by a fresh one, as rewritten child rows are. */
| {
readonly field: string;
readonly rule: "renewed";
readonly pointers: string[];
}
| {
readonly field: string;
readonly rule: "clock";
@@ -16,12 +16,16 @@ import {
OMITTED_FIELD_ROWS,
PROJECTED_ROWS,
RENAMED_BOOK,
HANDLE_ROWS,
LIST_LINE_ROWS,
QUERY_ROWS,
SECOND_BOOK,
THIRD_BOOK,
assetBook,
VERSIONED_ROWS,
book,
numberedUuid,
versionedBook,
bookClock,
detailedBook,
environmentStateViewOf,
@@ -219,6 +223,73 @@ describe("wider write coverage", () => {
});
});
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[]) =>
+122
View File
@@ -150,6 +150,128 @@ export const ASSET_ROWS: readonly TimedCaptureRow[] = [
["GET", "/api/books/4", ASSET_FOURTH],
];
/** A UUID-shaped value numbered `n`. */
export const numberedUuid = (n: number): string =>
`00000000-0000-4000-8000-${String(n).padStart(12, "0")}`;
/** A book with a version the application renews on every update and chapter rows it rewrites with new ids. */
export const versionedBook = (
entity: Book,
version: number,
chapter: number,
): Book => ({
...entity,
version_id: numberedUuid(version),
chapters: [{ id: numberedUuid(chapter), title: "Opening" }],
});
/** Two renames of the first book, each sending the version it was read with; each gets a new version and the child rows `make` renews. */
const renamingRows = (
make: (entity: Book, version: number, chapter: number) => Book,
second: Book = make(SECOND_BOOK, 3, 4),
): TimedCaptureRow[] => {
const once = make(renamedBook(FIRST_BOOK, "Renamed Book", 3), 5, 6);
const twice = make(renamedBook(FIRST_BOOK, "Renamed Again", 5), 7, 8);
return [
["GET", "/api/books", [make(FIRST_BOOK, 1, 2), second]],
["GET", "/api/books/1", make(FIRST_BOOK, 1, 2)],
["GET", "/api/books/2", second],
[
"PATCH",
"/api/books/1",
once,
200,
{ name: "Renamed Book", version_id: numberedUuid(1) },
],
["GET", "/api/books/1", once],
[
"PATCH",
"/api/books/1",
twice,
200,
{ name: "Renamed Again", version_id: numberedUuid(1) },
],
["GET", "/api/books/1", twice],
];
};
/** Two renames of the first book, each sending the version it was read with; each gets a new version and chapter ids. */
export const VERSIONED_ROWS: readonly TimedCaptureRow[] =
renamingRows(versionedBook);
/** The author every chapter row names, which no renewal changes. */
export const CHAPTER_AUTHOR = numberedUuid(99);
/** As VERSIONED_ROWS, with chapter rows that also name their author. */
export const AUTHORED_ROWS: readonly TimedCaptureRow[] = renamingRows(
(entity, version, chapter) => ({
...versionedBook(entity, version, chapter),
chapters: [
{
id: numberedUuid(chapter),
title: "Opening",
author_id: CHAPTER_AUTHOR,
},
],
}),
);
/** As VERSIONED_ROWS, with contents naming each chapter row by its id. */
export const CONTENTS_ROWS: readonly TimedCaptureRow[] = renamingRows(
(entity, version, chapter) => ({
...versionedBook(entity, version, chapter),
contents: [{ chapter_id: numberedUuid(chapter), page: 1 }],
}),
);
/** As VERSIONED_ROWS, with the second book known only without chapter rows. */
export const UNCHAPTERED_ROWS: readonly TimedCaptureRow[] = renamingRows(
versionedBook,
{ ...SECOND_BOOK, version_id: numberedUuid(3) },
);
/** A book whose sent handle the application stores as its code and embeds in its link. */
export const handledBook = (entity: Book, handle: string): Book => ({
...entity,
code: handle,
link: `https://books.example.test/b/${handle}`,
});
const FOURTH_BOOK = handledBook(
book(4, "Fourth Book", bookClock(5)),
"fourth-handle",
);
/** Two creates, each sending a handle the application stores under another name. */
export const HANDLE_ROWS: readonly TimedCaptureRow[] = [
[
"GET",
"/api/books",
[
handledBook(FIRST_BOOK, "first-handle"),
handledBook(SECOND_BOOK, "second-handle"),
],
],
["GET", "/api/books/1", handledBook(FIRST_BOOK, "first-handle")],
["GET", "/api/books/2", handledBook(SECOND_BOOK, "second-handle")],
[
"POST",
"/api/books",
handledBook(THIRD_BOOK, "third-handle"),
201,
{ name: "Third Book", handle: "third-handle" },
],
["GET", "/api/books/3", handledBook(THIRD_BOOK, "third-handle")],
[
"POST",
"/api/books",
FOURTH_BOOK,
201,
{ name: "Fourth Book", handle: "fourth-handle" },
],
["GET", "/api/books/4", FOURTH_BOOK],
];
/** A page of books as a paging API wraps it, counting what it lists. */
export const bookPage = (books: readonly Book[]) => ({
content: [...books],