diff --git a/docs/agent-environments.md b/docs/agent-environments.md index 4b812f9659..7b5fbbac00 100644 --- a/docs/agent-environments.md +++ b/docs/agent-environments.md @@ -683,7 +683,12 @@ A view that names a field in `sorted_arrays` lists that string array sorted, as a read-back showed the application does, while the state keeps it in the order it was written, so a write's own answer echoes it. If a later counted read contradicts that ordering, the view stays recorded and its earlier reads no -longer confirm served writes. Members a view names in +longer confirm served writes. A view that names a field in `nested_fields` +always showed that field's objects with the same keys, in any order, and fewer +than an entity stores, such +as a list showing a parent without the parent's own nested type; a stored +object it never recorded, such as a created entity's parent, is rendered with +those keys. Members a view names in `per_response_members`, such as a request id each response mints, keep the value its recording carried. A list's `counters` and `query.totals` are top-level members or members one level down in a paging object @@ -721,7 +726,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, nested clock, increment, slug, stamp, derived, composed, unique and renewed 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, nested clock, increment, slug, stamp, derived, composed, reference, unique and renewed changes the plan models. | | `update` (action) | A `POST`, `PUT` or `PATCH` to an item path plus an action segment (`/{type}/{id}/archive`) sets, on the entity before the segment, each scalar the recorded calls showed the action set (`set`), with any sent members; it is served only when a recorded call found those fields already set, so a toggle never is. | | `delete` | Removes the entity the path names. | | bulk `update` or `delete` | A `DELETE`, `PUT` or `PATCH` at a collection, or a `POST` to it plus `delete`, `remove` or `destroy`, applies its effect to every entity the body names, as recorded: an array of ids or of objects carrying the id field, at the root or as the body's one array member (`{"IDs": […], "Read": true}`). | diff --git a/docs/specs/agent-environments/write-create-calls/plan.md b/docs/specs/agent-environments/write-create-calls/plan.md new file mode 100644 index 0000000000..f495592330 --- /dev/null +++ b/docs/specs/agent-environments/write-create-calls/plan.md @@ -0,0 +1,12 @@ +# Implementation plan + +1. **Defaults.** `WriteBody.defaults` and its schema; a create's echo of a member a call leaves out takes its default. +2. **Merging.** Explain a later create on its own and merge it: agreeing rules, references one call shows alone, joined + bodies and defaults, the plan's rule order. +3. **Sorted echoes.** Learn `sorted` for creates and updates, store sorted strings when serving, and agree plain and + sorted echoes in update and create plans. +4. **References.** A references module that learns and resolves them; created and updated fields resolve them against + the replayed or live state; the members they read are accepted. +5. **Nested view keys.** The gate's view spelling learner, moved to its own module, notes object keys; rendering + applies them. +6. **Schema, tests and docs.** diff --git a/docs/specs/agent-environments/write-create-calls/spec.md b/docs/specs/agent-environments/write-create-calls/spec.md new file mode 100644 index 0000000000..b84fc52a56 --- /dev/null +++ b/docs/specs/agent-environments/write-create-calls/spec.md @@ -0,0 +1,48 @@ +# Learn creates from every call, sorted arrays and referenced entities + +Issue: https://github.com/morluto/rea/issues/4933 + +## Outcome + +After the derived-values change, the creates prompt-built environments still withheld failed for four reasons the +write model did not cover: + +- A create plan was learned from the tool's first call alone, so a later call that sent a member the first did not + (Linkding's `shared`), or left one out, was refused even when it agreed with the plan everywhere else. +- Linkding stores `tag_names` sorted, so a call that sent its tags in another order did not echo them, and its create + was withheld or contradicted. +- Homebox's item create answers its parent and type as whole objects (`parent`, `location`, `entityType`) beside the + `parentId` and `entityTypeId` it sent. Kept as constants, they pinned those members to their recorded values. +- Lists show some objects with fewer keys than an entity holds: Homebox lists an item's parent without the parent's + own type, and Keycloak lists a user's `access` with one key. A created entity's object rendered whole, so the list + check failed. + +## Behaviour + +- **Every call.** A later create is explained on its own and merged into the plan when both calls show the same fields, + but for a reference whose member a call without it did not send (one an earlier create added stays), every field + both rule they rule alike, and a field neither rules holds one value. Members either call sent are accepted, only + those every call sent are required, and an echoed member a call left out takes the value that call stored + (`body.defaults`). A clock widens to `trimmed` only when no held entity or earlier call showed it at a fixed width. A + later create that disagrees leaves the plan as it was and is judged as before; one that left out a member the plan + fills while showing that field otherwise is `unexplained-change`. Merged rules keep the plan's order. +- **Sorted arrays.** An echo is `sorted` when the application stored the sent strings in code-point order rather than + as sent; serving stores them sorted. A plain echo and a sorted one agree, and the plan keeps the sorted one, since a + call whose array was already in order cannot show it; once a call stored strings out of order as sent, they disagree. +- **References.** A created or updated object field that holds fields of the entity a sent member names, as the state + holds it, is a `reference` (member, type, fields). Several members naming such entities resolve to the one named like + the field. The member is accepted with any value. Replay and serving look the entity up in the state they hold; + serving refuses a call naming one the environment does not hold or holds in more than one collection, and the field + is absent, from the entity and the create's answer, when the call does not send the member. An update that sends the + member naming the entity the field already repeats keeps a reference an earlier call showed. +- **Nested view keys.** A view that always shows a field's objects with the same keys, in any order, and fewer than + an entity stores, names them in `nested_fields` in the first recording's order, and a stored object it never + recorded renders with those keys. + +Plans and views that need none of this are unchanged. + +## Acceptance + +- Linkding's prompt-recorded bookmark create whose read-back lists tags sorted is served, and Homebox's item create is + explained without pinning its parent or type. +- No write tool of the 69 recorded captures goes from served to withheld. diff --git a/docs/specs/agent-environments/write-create-calls/tasks.md b/docs/specs/agent-environments/write-create-calls/tasks.md new file mode 100644 index 0000000000..136bd78fde --- /dev/null +++ b/docs/specs/agent-environments/write-create-calls/tasks.md @@ -0,0 +1,10 @@ +# Tasks + +- [x] Merge later create calls into the plan, with defaults for members a call leaves out. +- [x] Learn and serve sorted echoes. +- [x] Learn, replay and serve references to held entities. +- [x] Learn the keys views show of nested objects and render written objects with them. +- [x] Add view, serving and unit tests. +- [x] Document the rules and view member. +- [x] Measure the recorded captures and served write tasks. +- [x] Run static, formatting, documentation and test gates. diff --git a/docs/specs/agent-environments/write-create-calls/verification.md b/docs/specs/agent-environments/write-create-calls/verification.md new file mode 100644 index 0000000000..baa0fd4e28 --- /dev/null +++ b/docs/specs/agent-environments/write-create-calls/verification.md @@ -0,0 +1,61 @@ +# Verification + +Executed on macOS with Node v24.22.1 at `043053d1` (main `79845063`, the derived-values branch `8ce1127b` and this +branch). Recorded captures and served write tasks were measured with a local, unpushed build of this branch merged with +the scenario-timing branch (#6967), as the derived-values branch was; its baseline is the derived-values branch with the +same merge. + +## Gates + +- `npm run -s typecheck`, `lint`, `format:check`, `docs:check`, `mcp-catalog:check`, `verify:test-discovery`, + `scan:todos` and `npx knip`: passed. +- `npx vitest run --project domain --project services --project adapters`: 17,258 passed, 25 skipped, 1 failed ("stops + Inspector target authorization after the current stat settles"), which fails the same way on main in this environment. +- `npm run test:focused --` the environment build, export and serve CLI boundaries, the analysis-view CLI and MCP + boundaries, the environment serve, served-writes and bundle MCP boundaries, MCP schema validation and output schema + constraints: 10 files, 82 passed. + +## Tests + +- View: a later create sending a member the first did not, or leaving one out, widens the plan with the member's default; + a later create storing a member otherwise than sent stays unexplained; sent tags stored sorted give a sorted echo; a + create embedding the shelf its `shelf_id` names gives a reference, and the list that shows the shelf by id and name + learns `nested_fields`. +- Serving: a create leaving the member out stores its default and one sending it stores it; a create embeds the named + shelf as the environment holds it, lists it with the listed keys, and is refused for a shelf it does not hold; sent + tags are stored sorted. +- Unit: references (the member named like the field among several, too few keys, a disagreeing value, an unheld entity) + and merges (a constant that differs, a member one fixes and the other accepts, a reference one call shows alone). + +## Write plans over recorded captures + +The 69 captures the derived-values branch measured: 50 published, 18 Claude Code recorded from a prompt, and one timed +Grafana recording. After merging main `ed79bf93` (which bounds create learning), the census was run again against main +with the scenario-timing branch: 124 served there, 132 with both branches, no write going from served to withheld; the +columns below are the measurements before that merge, whose totals it reproduces. + +| Captures | Write plans | Served before both branches | Derived values | This branch | +| ------------------------- | ----------- | --------------------------- | -------------- | ----------- | +| 50 published | 187 | 68 | 68 | 70 | +| 19 recorded from a prompt | 120 | 56 | 61 | 62 | + +- No write goes from served to withheld. +- Gains: Keycloak's published user create and update, whose list shows each user's `access` with one key, so a created + user now lists as recorded and its plan's sorted insert checks; and the round-4 Linkding bookmark create, whose tags + the application stores sorted. +- Explained but still withheld: Homebox's item create now merges with the location create, with references for + `parent`, `location` and `entityType` and `parentId` and `entityTypeId` accepted with any value. Its read-back list + answers items only, sorted by name, from a line with no parameter saying so, which the list model does not cover. +- Linkding's create whose later call sends `shared` merges too, and is withheld as `side-effect`: the recording also + added a bookmark through the UI that no recorded write explains. + +## Served write tasks + +The same captures rebuilt by the baseline and by this branch, with `fireworks_ai/gpt-oss-120b` and +`fireworks_ai/glm-5p3-flash`. The tasks are new; each checks the result from the environment's state, including that +nothing else changed. + +| Capture | Tasks | Baseline (gpt-oss, GLM) | This branch (gpt-oss, GLM) | +| --------------------------- | ------------------------------------------ | ----------------------- | -------------------------- | +| Linkding, recorded round 4 | 2 bookmark creates, one with two tags | 0/2, 0/2 | 2/2, 2/2 | +| Keycloak, published capture | create a user, change another user's email | 0/2, 0/2 | 2/2, 2/2 | diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index 7459535193..3498b99418 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -737,14 +737,34 @@ request body must be a usable JSON object (as response bodies are usable), and the written entity must show in that call's response or in a later read before the next write. Each field of a created entity gets the first rule that explains it: generated `identity` (a sequence, UUID or token, by the recorded -value's shape), `echo` of a body member, `copy` of a body string of 8 or more -characters, a `path` value, a `slug` of a body member, a `clock` value within 10 +value's shape), `echo` of a body member (`sorted` when the application stored +its strings in code-point order rather than as sent), `copy` of a body string of +8 or more characters, a `path` value, a `slug` of a body member, a `clock` value within 10 minutes of the request in its recorded format, a `sequence` one past the largest value of its collection or type, a `unique` UUID, opaque token or integer of 10 or more digits that no entity of the type holds (where every one holds a distinct integer of the same length, as time-based ids do), a `nested-clock` object whose clocks the request stamped, a `template` around -a generated value, or a `composed` string; other fields keep the observed value. +a generated value, a `reference` to an entity, or a `composed` string; other +fields keep the observed value. A `reference` is an object holding fields of the +entity a sent member names, as the state holds it (a parent beside `parentId`): +the member is accepted with any value, serving looks the entity up and refuses a +call naming one the environment does not hold or holds in more than one +collection, and the field is absent, from the entity and the create's answer, +when the call does not send the member. An id naming entities of one type in +several collections teaches no reference. Each later create widens its plan +when, explained on its own, it agrees with it: both calls show the same fields +but for a reference whose member a call without it did not send (one an earlier +create added stays), every field both rule they rule alike (an echo one showed +sorted stays sorted, unless a call stored its strings out of order as sent), +and a field neither rules holds one value. Members either call sent are accepted, only those every call sent are +required, and an echoed member a call left out takes the value that call stored +(`body.defaults`). A clock rule widens to `trimmed` only when no held entity or +earlier call showed that clock at a fixed width. A later create that disagrees +leaves the plan as it was. A create whose observed reference differs from the +entity the plan rebuilds, whether or not it sent the reference member, is +`unexplained-change`. So is a create that left out an echoed member yet shows a +value different from the learned default. A `composed` string is literal text and parts read from the fields other rules write: a field's value, its slug, or a clock's calendar digits (`YYYYMMDDhhmmss`, `YYYYMMDD`, `YYYY-MM-DD` or `YYYY/MM/DD`, in the clock's @@ -763,7 +783,7 @@ recorded value (`body.fixed`) and stores the value the call that showed it recorded (the `value` of its `constant` rule), and a field it changed without sending must be a clock, a nested clock, an increment, a slug, a fresh `unique` value, a `stamp` of the acting user, a value `derived` from the one member it -stores nowhere, a `composed` string whose parts read members it stored as sent +stores nowhere, a `reference` to the entity a sent member names (kept by a call that names the entity the field already repeats), a `composed` string whose parts read members it stored as sent or fields it left as they were and spell the value before the call too, or for an item action a `set` of a scalar to the value it showed, or the plan is `unexplained-change`; an increment, slug, stamp, derived diff --git a/src/application/environment/environmentStateBulkAnswer.ts b/src/application/environment/environmentStateBulkAnswer.ts index 33b01e9bf4..8ce6d25434 100644 --- a/src/application/environment/environmentStateBulkAnswer.ts +++ b/src/application/environment/environmentStateBulkAnswer.ts @@ -32,6 +32,7 @@ import { observedEntity, pathValues, unloadable, + heldEntity, updateTarget, writeAnswer, type WriteRequest, @@ -131,6 +132,7 @@ const writtenTargets = ( { before: entity.fields, observed }, call, values.forTarget(updateTarget(serving, entity, observed)), + heldEntity(serving), ); if (!fields.ok) return err(refused(tool, fields.error)); const field = unloadableMember(fields.value); diff --git a/src/application/environment/environmentStateDerivedAnswer.test.ts b/src/application/environment/environmentStateDerivedAnswer.test.ts index 9ec2dca6c8..74817611e6 100644 --- a/src/application/environment/environmentStateDerivedAnswer.test.ts +++ b/src/application/environment/environmentStateDerivedAnswer.test.ts @@ -2,8 +2,13 @@ import { describe, expect, it } from "vitest"; import { BOOKMARK_ROWS, + FILED_ROWS, FOLDER_ROWS, TAG_ROWS, + TAGGED_ROWS, + optionallyFiledRows, + referencedCreateRows, + shelvedRows, } from "../../../tests/fixtures/environmentStateDerivedValues.js"; import { servedFrom } from "../../../tests/fixtures/environmentStateServing.js"; import { @@ -82,3 +87,96 @@ describe("served values an application derives from others", () => { ).toMatchObject({ title: "Moved Folder", url: renamed["url"] }); }); }); + +describe("served creates learned from every call", () => { + it("stores a member a create leaves out at the default a recorded call showed, and a sent one as sent", () => { + const books = servedFrom( + shelvedRows( + { name: "Third Book" }, + { name: "Fourth Book", shelf: "rare" }, + ), + ); + expect( + objectOf(books.answer("post_books", { body: { name: "Plain" } }).body), + ).toMatchObject({ name: "Plain", shelf: "general" }); + expect( + objectOf( + books.answer("post_books", { body: { name: "Kept", shelf: "attic" } }) + .body, + ), + ).toMatchObject({ name: "Kept", shelf: "attic" }); + }); +}); + +describe("served objects that repeat a named entity, and sorted arrays", () => { + it("embeds the shelf a create names as the environment holds it, lists it with the listed keys, and refuses an unknown shelf", () => { + const books = servedFrom(FILED_ROWS); + const created = objectOf( + books.answer("post_books", { body: { name: "Pilot Guide", shelf_id: 2 } }) + .body, + ); + expect(created["shelf"]).toEqual({ + id: 2, + name: "Map Room", + floor: "upper", + }); + const listed = books.answer("get_books").body as JsonValue[]; + expect(listed.at(-1)).toMatchObject({ + name: "Pilot Guide", + shelf: { id: 2, name: "Map Room" }, + }); + expect(JSON.stringify(listed.at(-1))).not.toContain("upper"); + expect( + JSON.stringify( + books.refusal("post_books", { body: { name: "Lost", shelf_id: 9 } }), + ), + ).toContain("names no entity of /api/shelves"); + }); + + it("keeps a contradicted reference out of state writes without changing recording fallback", () => { + const books = servedFrom( + referencedCreateRows({ id: 1, name: "Different Hall", floor: "ground" }), + ); + expect(books.state.writes.get("post_books")).toMatchObject({ + served: false, + reason: "unexplained-change", + }); + const before = books.snapshot(); + expect( + books.answer("post_books", { body: { name: "New Book", shelf_id: 1 } }), + ).toMatchObject({ state: { source: "recording" } }); + expect(books.snapshot()).toBe(before); + }); + + it.each([true, false])( + "answers a create with the shelf only when it names one, whichever recorded create was first (shelf first: %s)", + (shelfFirst) => { + const books = servedFrom(optionallyFiledRows(shelfFirst)); + const plain = objectOf( + books.answer("post_books", { body: { name: "Loose Leaf" } }).body, + ); + expect(plain).toMatchObject({ name: "Loose Leaf" }); + expect(plain).not.toHaveProperty("shelf"); + const shelved = objectOf( + books.answer("post_books", { + body: { name: "Pilot Guide", shelf_id: 1 }, + }).body, + ); + expect(shelved).toMatchObject({ + name: "Pilot Guide", + shelf: { id: 1, name: "Main Hall", floor: "ground" }, + }); + }, + ); + + it("stores the tags a create sends in code-point order", () => { + const books = servedFrom(TAGGED_ROWS); + expect( + objectOf( + books.answer("post_books", { + body: { name: "Logbook", tags: ["wind", "ice", "fog"] }, + }).body, + )["tags"], + ).toEqual(["fog", "ice", "wind"]); + }); +}); diff --git a/src/application/environment/environmentStateWriteAnswer.ts b/src/application/environment/environmentStateWriteAnswer.ts index 7d803995c2..3d75db50d2 100644 --- a/src/application/environment/environmentStateWriteAnswer.ts +++ b/src/application/environment/environmentStateWriteAnswer.ts @@ -1,6 +1,7 @@ import { createdFields, documentBodyProblem, + optionalFields, renewUuids, updatedEntity, updatedFields, @@ -172,6 +173,14 @@ export interface GeneratedTarget { readonly current?: JsonObject; } +/** The fields of each entity of a type with an id the live state holds, which a referencing write repeats. */ +export const heldEntity = + (serving: StateServing) => + (type: string, id: string): JsonObject[] => + serving.store + .ofType(type) + .flatMap((entity) => (entity.id === id ? [entity.fields] : [])); + /** What an update generates values for: the entity it writes, in its collection. */ export const updateTarget = ( serving: StateServing, @@ -335,7 +344,12 @@ export const writeAnswer = ( const body = typeof rendered === "function" ? rendered(recorded) - : writtenResponse(plan.response, recorded, rendered); + : writtenResponse( + plan.response, + recorded, + rendered, + optionalFields(plan), + ); if (!body.ok) return err(refused(tool, body.error)); return stateAnswer(serving, { exchange, exact }, body.value ?? null, state); }; @@ -456,6 +470,7 @@ const answerCreate = ( observed, created, values.forTarget({ name: collection, collection: members, observed }), + heldEntity(serving), ); if (!fields.ok) return err(refused(tool, fields.error)); const field = unloadableMember(fields.value); @@ -544,6 +559,7 @@ const answerUpdate = ( { before: entity.fields, observed }, { ...call, id: entity.id }, values.forTarget(updateTarget(serving, entity, observed)), + heldEntity(serving), ); if (!written.ok) return err(refused(tool, written.error)); // An increment can pass the exact integers. diff --git a/src/domain/analysisView/EnvironmentStateWriteSimulation.ts b/src/domain/analysisView/EnvironmentStateWriteSimulation.ts index 2a9f21b129..2b524a14fe 100644 --- a/src/domain/analysisView/EnvironmentStateWriteSimulation.ts +++ b/src/domain/analysisView/EnvironmentStateWriteSimulation.ts @@ -219,6 +219,7 @@ export class EnvironmentStateWriteSimulation { sequences: new Map(), steady: new Set(), unstamped: new Set(), + kept: new Set(), fixedWidth: new Map(), linked: link.collection, }); diff --git a/src/domain/analysisView/environmentStateCreateCalls.test.ts b/src/domain/analysisView/environmentStateCreateCalls.test.ts new file mode 100644 index 0000000000..198d8bc78a --- /dev/null +++ b/src/domain/analysisView/environmentStateCreateCalls.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from "vitest"; + +import { + timedCaptureEvidence, + type TimedCaptureRow, +} from "../../../tests/fixtures/analysisView.js"; +import { + HELD_FIXED_TAG_ROWS, + TAG_ROWS, + misshelvedRows, + referencedCreateRows, + shelvedRows, +} from "../../../tests/fixtures/environmentStateDerivedValues.js"; +import { environmentStateViewOf } from "../../../tests/fixtures/environmentStateWrites.js"; +import { mergedCreatePlan } from "./environmentStateWriteCreateCalls.js"; +import type { FieldRule } from "./environmentStateWriteRules.js"; + +const createPlan = (rows: readonly TimedCaptureRow[], tool = "post_books") => { + const plan = environmentStateViewOf(timedCaptureEvidence(rows)).writes.find( + (candidate) => candidate.tool === tool, + ); + if (plan === undefined) throw new Error("No create plan"); + return plan; +}; + +const OPTIONAL_SHELF = { + served: true, + body: { + accepted: ["name", "shelf"], + required: ["name"], + defaults: [{ member: "shelf", value: "general" }], + }, + rules: expect.arrayContaining([{ field: "shelf", rule: "echo" }]), +}; + +describe("create plans learned from every call", () => { + it("accepts a member a later create sends, stored as the earlier one's default when left out", () => { + expect( + createPlan( + shelvedRows( + { name: "Third Book" }, + { name: "Fourth Book", shelf: "rare" }, + ), + ), + ).toMatchObject(OPTIONAL_SHELF); + }); + + it("makes a member a later create leaves out optional, with the default that create stored", () => { + expect( + createPlan( + shelvedRows( + { name: "Third Book", shelf: "rare" }, + { name: "Fourth Book" }, + ), + ), + ).toMatchObject(OPTIONAL_SHELF); + }); + + it("withholds creates whose later call stores a left-out member otherwise than the default it learned", () => { + expect(createPlan(misshelvedRows())).toMatchObject({ + served: false, + reason: "unexplained-change", + }); + }); + + it.each([ + { id: 1, name: "Different Hall", floor: "ground" }, + { id: 2, name: "Map Room", floor: "upper" }, + null, + ])( + "withholds a create whose sent reference contradicts its answer (%j)", + (shelf) => { + expect(createPlan(referencedCreateRows(shelf))).toMatchObject({ + served: false, + reason: "unexplained-change", + }); + }, + ); + + it("accepts a later create whose sent reference agrees in a different object key order", () => { + expect( + createPlan( + referencedCreateRows({ floor: "ground", name: "Main Hall", id: 1 }), + ), + ).toMatchObject({ served: true }); + }); + + it("trims no clock a later create shows trimmed after a held entity showed it at a fixed width", () => { + expect(createPlan(TAG_ROWS, "post_tags")).toMatchObject({ served: true }); + const plan = createPlan(HELD_FIXED_TAG_ROWS, "post_tags"); + expect(plan).toMatchObject({ served: false, reason: "unexplained-change" }); + expect(plan.rules).not.toContainEqual( + expect.objectContaining({ + field: "updatedAt", + format: expect.objectContaining({ trimmed: true }), + }), + ); + }); + + it("serves no plan a later create contradicts by storing a member otherwise than sent", () => { + expect( + createPlan( + shelvedRows( + { name: "Third Book" }, + { name: "Fourth Book", shelf: "rare" }, + (sent) => sent.toUpperCase(), + ), + ), + ).toMatchObject({ served: false, reason: "unexplained-change" }); + }); +}); + +describe("merging a later create into a plan", () => { + const body = (accepted: string[], required = accepted) => ({ + accepted, + fixed: [], + required, + identity: null, + kinds: [], + }); + const plan = { + rules: [ + { field: "id", rule: "identity", generator: "sequence" } as const, + { field: "name", rule: "echo" } as const, + ], + body: body(["name"]), + }; + const exemplar = { id: 3, name: "Third", shelf: "general" }; + const later = ( + overrides: Partial[2]>, + ) => ({ + entity: { id: 4, name: "Fourth", shelf: "rare" }, + body: { name: "Fourth", shelf: "rare" }, + rules: [...plan.rules, { field: "shelf", rule: "echo" } as const], + contract: body(["name", "shelf"]), + ...overrides, + }); + + it("refuses a field neither call rules that holds another value, or a member one fixes and the other accepts", () => { + expect( + mergedCreatePlan(plan, exemplar, later({ rules: plan.rules })), + ).toBeUndefined(); + expect( + mergedCreatePlan( + { + ...plan, + body: { ...body(["name"]), fixed: [{ member: "shelf", sent: "x" }] }, + }, + exemplar, + later({}), + ), + ).toBeUndefined(); + }); + + const reference: FieldRule = { + field: "shelf", + rule: "reference", + member: "shelf_id", + type: "/api/shelves", + fields: ["id", "name"], + }; + const shelved = (id: number, name: string) => + later({ + entity: { id, name, shelf: { id: 1, name: "Main" } }, + body: { name, shelf_id: 1 }, + rules: [...plan.rules, reference], + contract: body(["name", "shelf_id"]), + }); + const unshelved = (id: number, name: string, sent: object = {}) => + later({ + entity: { id, name }, + body: { name, ...sent }, + rules: plan.rules, + contract: body(["name", ...Object.keys(sent)]), + }); + const unshelvedExemplar = { id: 3, name: "Third" }; + + it("takes a reference one call alone shows when the other did not send its member", () => { + const merged = mergedCreatePlan( + plan, + unshelvedExemplar, + shelved(4, "Fourth"), + ); + expect(merged).toMatchObject({ + rules: expect.arrayContaining([reference]), + body: { accepted: ["name", "shelf_id"], required: ["name"] }, + }); + }); + + it("keeps a reference an earlier call added through calls that leave it out, and agrees with it when it returns", () => { + const added = mergedCreatePlan( + plan, + unshelvedExemplar, + shelved(4, "Fourth"), + )!; + const omitted = mergedCreatePlan( + added, + unshelvedExemplar, + unshelved(5, "Fifth"), + ); + expect(omitted).toMatchObject({ + rules: expect.arrayContaining([reference]), + body: { accepted: ["name", "shelf_id"], required: ["name"] }, + }); + expect( + mergedCreatePlan(omitted!, unshelvedExemplar, shelved(6, "Sixth")), + ).toMatchObject({ rules: expect.arrayContaining([reference]) }); + // A call that sends the member yet stores no shelf contradicts the reference. + expect( + mergedCreatePlan( + added, + unshelvedExemplar, + unshelved(7, "Seventh", { shelf_id: 1 }), + ), + ).toBeUndefined(); + }); +}); diff --git a/src/domain/analysisView/environmentStateGate.ts b/src/domain/analysisView/environmentStateGate.ts index 29ceceeebd..53321d7464 100644 --- a/src/domain/analysisView/environmentStateGate.ts +++ b/src/domain/analysisView/environmentStateGate.ts @@ -31,7 +31,7 @@ import { type QueryLine, } from "./environmentStateListQuery.js"; import { singleQueryValues } from "../recordedTransportUrl.js"; -import { readClock, type ClockFormat } from "./environmentStateWriteValues.js"; +import { ClockSpellings } from "./environmentStateViewSpellings.js"; import { declaredMembers, mintedEnvelope, @@ -132,85 +132,6 @@ class FieldUnion { } } -/** The spellings a view always gives clock fields where its recordings spell the stored instant otherwise. */ -class ClockSpellings { - /** Each category retains one spelling or a permanent mixed marker, never an expanding format history. */ - readonly #fields = new Map< - string, - { - matched: ClockFormat | null | undefined; - other: ClockFormat | null | undefined; - } - >(); - - constructor(private readonly retain: StateGateContext["retain"]) { - retain(ENTRY_BYTES); - } - - #note( - field: string, - category: "matched" | "other", - format: ClockFormat | null, - ): void { - let facts = this.#fields.get(field); - if (facts === undefined) { - this.retain(ENTRY_BYTES + textBytes(field.length)); - facts = { matched: undefined, other: undefined }; - this.#fields.set(field, facts); - } - const held = facts[category]; - if (held === undefined) { - if (format !== null) - this.retain( - ENTRY_BYTES + - (format.kind === "iso" ? textBytes(format.zone.length) : 0), - ); - facts[category] = format; - } else if (held !== null && !same(held, format)) facts[category] = null; - } - - note(stored: JsonObject, shown: JsonObject): void { - for (const [field, value] of Object.entries(shown)) { - const now = readClock(value); - if (now === undefined) continue; - const was = Object.hasOwn(stored, field) - ? readClock(stored[field]) - : undefined; - if ( - was === undefined || - was.ms !== now.ms || - was.subMillisecond !== now.subMillisecond - ) { - this.#note(field, "other", now.format); - continue; - } - const spelled = same(stored[field], value) ? null : now.format; - this.#note(field, "matched", spelled); - } - } - - /** The view member, when the view spells any field otherwise, and in that spelling only. */ - get view(): { readonly clock_formats?: Record } { - const formats: [string, ClockFormat][] = []; - for (const [field, { matched, other }] of this.#fields) { - if ( - matched === undefined || - matched === null || - other === null || - (other !== undefined && !same(other, matched)) - ) - continue; - if (formats.length === 0) this.retain(ENTRY_BYTES * 2); - // Both the temporary entry and its returned object member remain covered. - this.retain(ENTRY_BYTES * 2 + textBytes(field.length)); - formats.push([field, matched]); - } - return formats.length === 0 - ? {} - : { clock_formats: Object.fromEntries(formats) }; - } -} - const recorded = (plan: StatePlan, reason: RecordedViewReason): StateView => ({ tool: plan.tool.name, shape: "recorded", diff --git a/src/domain/analysisView/environmentStateRules.ts b/src/domain/analysisView/environmentStateRules.ts index ccc29110dd..a4d1c0c342 100644 --- a/src/domain/analysisView/environmentStateRules.ts +++ b/src/domain/analysisView/environmentStateRules.ts @@ -120,6 +120,8 @@ interface EntityViewPlan { readonly clock_formats?: Readonly> | undefined; /** Fields whose string arrays the view lists in code-point order, whatever order they were written in. */ readonly sorted_arrays?: string[] | undefined; + /** The keys the view shows of object fields it shows with fewer keys than stored. */ + readonly nested_fields?: Readonly> | undefined; /** Envelope members each response there mints, which checks leave out and serving keeps as recorded. */ readonly per_response_members?: string[] | undefined; } @@ -660,7 +662,7 @@ export const renderStateEntity = ( entity: StateEntity, view: Pick< EntityViewPlan, - "tool" | "fields" | "clock_formats" | "sorted_arrays" + "tool" | "fields" | "clock_formats" | "sorted_arrays" | "nested_fields" >, volatileFields: ReadonlySet, observed?: JsonObject, @@ -669,6 +671,7 @@ export const renderStateEntity = ( entity.view_fields.find((candidate) => candidate.tool === view.tool) ?.fields ?? {}; const formats = view.clock_formats ?? {}; + const nested = view.nested_fields ?? {}; const sorted = new Set(view.sorted_arrays); return Object.fromEntries( view.fields.flatMap((name): [string, JsonValue][] => { @@ -679,16 +682,30 @@ export const renderStateEntity = ( ? own : entity.fields; if (!Object.hasOwn(source, name)) return []; - // A stored value the view never recorded, such as a written one, takes the view's spelling. + // A stored value the view never recorded, such as a written one, takes the view's spelling and keys. const value = - source === entity.fields && Object.hasOwn(formats, name) - ? respellClock(source[name]!, formats[name]!) - : source[name]!; + source !== entity.fields + ? source[name]! + : Object.hasOwn(formats, name) + ? respellClock(source[name]!, formats[name]!) + : Object.hasOwn(nested, name) + ? shownKeys(source[name]!, nested[name]!) + : source[name]!; return [[name, sorted.has(name) ? inSetOrder(value) : value]]; }), ); }; +/** An object with only the keys a view shows of it, in the view's order; any other value as it is. */ +const shownKeys = (value: JsonValue, keys: readonly string[]): JsonValue => + isObject(value) + ? Object.fromEntries( + keys.flatMap((key) => + Object.hasOwn(value, key) ? [[key, value[key]!]] : [], + ), + ) + : value; + /** Whether a value is an array of strings. */ export const isStringArray = (value: JsonValue): value is string[] => Array.isArray(value) && value.every((item) => typeof item === "string"); diff --git a/src/domain/analysisView/environmentStateView.ts b/src/domain/analysisView/environmentStateView.ts index c01c556b06..62ef9e08a9 100644 --- a/src/domain/analysisView/environmentStateView.ts +++ b/src/domain/analysisView/environmentStateView.ts @@ -128,6 +128,12 @@ const entityViewFields = { .describe( "How the view spells clock fields it always spells otherwise than the entities' stored values; a stored value the view never recorded is spelled this way.", ), + nested_fields: z + .record(z.string(), z.array(z.string())) + .optional() + .describe( + "The keys the view shows of object fields it always shows with fewer keys than an entity stores, such as a parent listed without its own nested type; a stored object the view never recorded shows those keys.", + ), sorted_arrays: z .array(z.string()) .optional() @@ -253,7 +259,11 @@ const fieldRuleSchema = z.discriminatedUnion("rule", [ rule: z.literal("held-clock"), format: clockFormatSchema, }), - z.strictObject({ ...ruled, rule: z.literal("echo") }), + z.strictObject({ + ...ruled, + rule: z.literal("echo"), + sorted: z.literal(true).exactOptional(), + }), z.strictObject({ ...ruled, rule: z.literal("copy"), from: z.string() }), z.strictObject({ ...ruled, rule: z.literal("path"), from: z.string() }), z.strictObject({ ...ruled, rule: z.literal("slug"), from: z.string() }), @@ -278,6 +288,13 @@ const fieldRuleSchema = z.discriminatedUnion("rule", [ ) .min(1), }), + z.strictObject({ + ...ruled, + rule: z.literal("reference"), + member: z.string(), + type: z.string(), + fields: z.array(z.string()).min(1), + }), z.strictObject({ ...ruled, rule: z.literal("increment") }), z.strictObject({ ...ruled, @@ -360,10 +377,13 @@ const writePlanSchema = z.strictObject({ }), ), envelope: z.string().optional(), + defaults: z + .array(z.strictObject({ member: z.string(), value: jsonValueSchema })) + .optional(), }) .nullable() .describe( - "Body members the application stored as sent (accepted), members accepted only with their recorded value (fixed), members every call sends (required), the JSON type a later call stored an accepted member as when the observation does not show it (kinds), and the member a call wraps its object in as a one-element array (envelope).", + "Body members the application stored as sent (accepted), members accepted only with their recorded value (fixed), members every call sends (required), the JSON type a later call stored an accepted member as when the observation does not show it (kinds), the member a call wraps its object in as a one-element array (envelope), and the value a create stores for an accepted member a call leaves out, as a call that left it out showed (defaults).", ), rules: z .array(fieldRuleSchema) diff --git a/src/domain/analysisView/environmentStateViewSpellings.ts b/src/domain/analysisView/environmentStateViewSpellings.ts new file mode 100644 index 0000000000..488dc216c8 --- /dev/null +++ b/src/domain/analysisView/environmentStateViewSpellings.ts @@ -0,0 +1,140 @@ +import type { JsonValue } from "../jsonValue.js"; +import { isObject, same, type JsonObject } from "./environmentStateRules.js"; +import { readClock, type ClockFormat } from "./environmentStateWriteValues.js"; +import { ENTRY_BYTES, textBytes } from "./webNetworkRouteTemplates.js"; + +/** Retain the bytes a view's learned members keep, as the gate's budget counts them. */ +type Retain = (bytes: number) => void; + +/** Whether two objects show the same keys, in any order. */ +const sameKeys = (left: readonly string[], shown: JsonObject): boolean => + left.length === Object.keys(shown).length && + left.every((key) => Object.hasOwn(shown, key)); + +/** The spellings a view always gives clock fields where its recordings spell the stored instant otherwise. */ +export class ClockSpellings { + /** Each category retains one spelling or a permanent mixed marker, never an expanding format history. */ + readonly #fields = new Map< + string, + { + matched: ClockFormat | null | undefined; + other: ClockFormat | null | undefined; + } + >(); + + /** Per object field, the keys every recording showed in the first one's order, or null once two differ, and whether one showed fewer than stored. */ + readonly #shapes = new Map< + string, + { keys: string[] | null; projects: boolean } + >(); + + constructor(private readonly retain: Retain) { + retain(ENTRY_BYTES); + } + + #shape( + field: string, + shown: JsonObject, + stored: JsonValue | undefined, + ): void { + const keys = Object.keys(shown); + const projects = + isObject(stored) && + Object.keys(stored).some((key) => !Object.hasOwn(shown, key)); + const facts = this.#shapes.get(field); + if (facts === undefined) { + this.retain( + ENTRY_BYTES * 2 + + textBytes(field.length) + + keys.reduce( + (sum, key) => sum + ENTRY_BYTES + textBytes(key.length), + 0, + ), + ); + this.#shapes.set(field, { keys, projects }); + return; + } + if (facts.keys !== null && !sameKeys(facts.keys, shown)) facts.keys = null; + facts.projects ||= projects; + } + + #note( + field: string, + category: "matched" | "other", + format: ClockFormat | null, + ): void { + let facts = this.#fields.get(field); + if (facts === undefined) { + this.retain(ENTRY_BYTES + textBytes(field.length)); + facts = { matched: undefined, other: undefined }; + this.#fields.set(field, facts); + } + const held = facts[category]; + if (held === undefined) { + if (format !== null) + this.retain( + ENTRY_BYTES + + (format.kind === "iso" ? textBytes(format.zone.length) : 0), + ); + facts[category] = format; + } else if (held !== null && !same(held, format)) facts[category] = null; + } + + note(stored: JsonObject, shown: JsonObject): void { + for (const [field, value] of Object.entries(shown)) { + if (isObject(value)) this.#shape(field, value, stored[field]); + const now = readClock(value); + if (now === undefined) continue; + const was = Object.hasOwn(stored, field) + ? readClock(stored[field]) + : undefined; + if ( + was === undefined || + was.ms !== now.ms || + was.subMillisecond !== now.subMillisecond + ) { + this.#note(field, "other", now.format); + continue; + } + const spelled = same(stored[field], value) ? null : now.format; + this.#note(field, "matched", spelled); + } + } + + /** The fields whose objects every recording showed with the same keys, fewer than one stored. */ + get #nested(): { readonly nested_fields?: Record } { + const nested = [...this.#shapes].flatMap(([field, { keys, projects }]) => + keys !== null && projects ? [[field, keys] as const] : [], + ); + return nested.length === 0 + ? {} + : { nested_fields: Object.fromEntries(nested) }; + } + + /** The view members: the spelling of fields it always spells otherwise, and the keys of objects it shows fewer of. */ + get view(): { + readonly clock_formats?: Record; + readonly nested_fields?: Record; + } { + const formats: [string, ClockFormat][] = []; + for (const [field, { matched, other }] of this.#fields) { + if ( + matched === undefined || + matched === null || + other === null || + (other !== undefined && !same(other, matched)) + ) + continue; + if (formats.length === 0) this.retain(ENTRY_BYTES * 2); + // Both the temporary entry and its returned object member remain covered. + this.retain(ENTRY_BYTES * 2 + textBytes(field.length)); + formats.push([field, matched]); + } + return { + ...(formats.length === 0 + ? {} + : { clock_formats: Object.fromEntries(formats) }), + ...this.#nested, + }; + } +} diff --git a/src/domain/analysisView/environmentStateWriteApply.ts b/src/domain/analysisView/environmentStateWriteApply.ts index f980e1138b..c81fa2af04 100644 --- a/src/domain/analysisView/environmentStateWriteApply.ts +++ b/src/domain/analysisView/environmentStateWriteApply.ts @@ -26,6 +26,11 @@ import { } from "./environmentStateWriteStamps.js"; import { composedValue } from "./environmentStateWriteComposed.js"; import { + referencedValue, + type ReferencedEntity, +} from "./environmentStateWriteReferences.js"; +import { + echoed, integerOf, readClock, isUuid, @@ -211,9 +216,12 @@ const ruledValue = ( observed: JsonObject, call: WriteCall, generate: GenerateValue, + referenced?: ReferencedEntity, ): Applied => { const body = call.body ?? {}; switch (rule.rule) { + case "reference": + return referencedValue(rule, body, referenced); case "identity": case "sequence": case "unique": @@ -223,7 +231,11 @@ const ruledValue = ( case "held-clock": return generate(rule); case "echo": - return ok(Object.hasOwn(body, rule.field) ? body[rule.field] : undefined); + return ok( + Object.hasOwn(body, rule.field) + ? echoed(rule, body[rule.field]!) + : undefined, + ); case "copy": { const value = valueAt(body, rule.from); return value === undefined @@ -314,6 +326,7 @@ export const createdFields = ( observed: JsonObject, call: WriteCall, generate: GenerateValue, + referenced?: ReferencedEntity, ): Applied => { if (plan.body === null) return err("The tool has no modelled body."); const problem = bodyProblem(plan.body, call, { @@ -321,11 +334,17 @@ export const createdFields = ( }); if (problem !== undefined) return err(problem); const written: JsonObject = { ...observed }; + const defaults = new Map( + (plan.body.defaults ?? []).map(({ member, value }) => [member, value]), + ); for (const rule of plan.rules) { - const value = ruledValue(rule, observed, call, generate); + const value = ruledValue(rule, observed, call, generate, referenced); if (!value.ok) return value; if (value.value !== undefined) written[rule.field] = value.value; - else if (rule.rule === "echo") delete written[rule.field]; + else if (rule.rule === "echo" && defaults.has(rule.field)) + written[rule.field] = defaults.get(rule.field)!; + else if (rule.rule === "echo" || rule.rule === "reference") + delete written[rule.field]; } const unspelled = derive(plan.rules, observed, written, call.body ?? {}); return unspelled === undefined ? ok(written) : err(unspelled); @@ -353,7 +372,7 @@ const statedValue = ( if (rule.rule === "set") return rule.value; if (rule.rule !== "echo" && rule.rule !== "constant") return undefined; if (!Object.hasOwn(sent, rule.field)) return undefined; - return rule.rule === "echo" ? sent[rule.field] : rule.value; + return rule.rule === "echo" ? echoed(rule, sent[rule.field]!) : rule.value; }; /** The fields of an updated entity: sent echoed members, the constants the plan recorded, generated and derived changes. */ @@ -362,6 +381,7 @@ export const updatedFields = ( entity: { readonly before: JsonObject; readonly observed: JsonObject }, call: WriteCall & { readonly id: string }, generate: GenerateValue, + referenced?: ReferencedEntity, ): Applied => { const { before, observed } = entity; if (plan.body === null) return err("The tool has no modelled body."); @@ -384,7 +404,11 @@ export const updatedFields = ( for (const rule of plan.rules) { const stated = statedValue(rule, sent); if (stated !== undefined) written[rule.field] = stated; - else if (rule.rule === "increment") { + else if (rule.rule === "reference" && Object.hasOwn(sent, rule.member)) { + const value = referencedValue(rule, sent, referenced); + if (!value.ok) return value; + if (value.value !== undefined) written[rule.field] = value.value; + } else if (rule.rule === "increment") { const was = integerOf(before[rule.field]); if (was !== undefined) written[rule.field] = spelledLike(before[rule.field]!, was + 1); @@ -444,11 +468,28 @@ export const writtenDocument = ( return ok(replaceAt(before, pointer, value)); }; -/** A write's answer body from its recorded response: the entity or identity rendered in, nothing, or the recorded body. */ +/** The fields a create stores only when a call sends their member: references and echoes of members not every call sent, with no default. */ +export const optionalFields = ( + plan: Pick, +): string[] => { + if (plan.effect !== "create" || plan.body === null) return []; + const { required, defaults = [] } = plan.body; + return plan.rules.flatMap((rule) => + (rule.rule === "reference" && !required.includes(rule.member)) || + (rule.rule === "echo" && + !required.includes(rule.field) && + !defaults.some(({ member }) => member === rule.field)) + ? [rule.field] + : [], + ); +}; + +/** A write's answer body from its recorded response: the entity or identity rendered in, nothing, or the recorded body; optional fields show only when written. */ export const writtenResponse = ( response: WriteResponse, recorded: JsonValue | undefined, written: { readonly fields: JsonObject | undefined; readonly id: string }, + optional: readonly string[] = [], ): Applied => { if (response.shape === "empty") return ok(undefined); if (response.shape === "recorded" || recorded === undefined) @@ -467,19 +508,24 @@ export const writtenResponse = ( ); if (!isObject(shown) || written.fields === undefined) return err("The recorded response does not carry the entity."); + const held = (field: string) => Object.hasOwn(written.fields!, field); const missing = Object.keys(shown).filter( - (field) => !Object.hasOwn(written.fields!, field), + (field) => !held(field) && !optional.includes(field), ); if (missing.length > 0) return err( `The state holds no complete entity for this response: it lacks ${missing.join(", ")}.`, ); + const fields = [ + ...Object.keys(shown).filter(held), + ...optional.filter((field) => !Object.hasOwn(shown, field) && held(field)), + ]; return ok( replaceAt( recorded, pointer, Object.fromEntries( - Object.keys(shown).map((field) => [field, written.fields![field]!]), + fields.map((field) => [field, written.fields![field]!]), ), ), ); diff --git a/src/domain/analysisView/environmentStateWriteCalls.ts b/src/domain/analysisView/environmentStateWriteCalls.ts index 6b0402f909..c5a691f795 100644 --- a/src/domain/analysisView/environmentStateWriteCalls.ts +++ b/src/domain/analysisView/environmentStateWriteCalls.ts @@ -5,10 +5,11 @@ import { same, type JsonObject } from "./environmentStateRules.js"; import { storedKinds } from "./environmentStateWriteApply.js"; import { composedValue } from "./environmentStateWriteComposed.js"; import type { explainUpdate } from "./environmentStateWriteExplain.js"; -import type { - FieldRule, - StateWritePlan, - WriteBody, +import { + alikeRules, + type FieldRule, + type StateWritePlan, + type WriteBody, } from "./environmentStateWriteRules.js"; import { integerOf, slugOf } from "./environmentStateWriteValues.js"; @@ -186,6 +187,7 @@ export const refinedBody = ( export const conflictsWithPlan = ( plan: Pick, explained: ReturnType, + kept: ReadonlySet, ): boolean => { const body = plan.body!; const fixed = new Map(body.fixed.map((entry) => [entry.member, entry.sent])); @@ -203,7 +205,7 @@ export const conflictsWithPlan = ( (identity !== null && fixed.has(identity)) || explained.rules.some((rule) => { const held = rules.get(rule.field); - return held !== undefined && !same(held, rule); + return held !== undefined && alikeRules(held, rule, kept) === undefined; }) ); }; diff --git a/src/domain/analysisView/environmentStateWriteChanges.ts b/src/domain/analysisView/environmentStateWriteChanges.ts index 1786655e6f..e7336a3854 100644 --- a/src/domain/analysisView/environmentStateWriteChanges.ts +++ b/src/domain/analysisView/environmentStateWriteChanges.ts @@ -1,9 +1,19 @@ import type { JsonValue } from "../jsonValue.js"; -import { isObject, same, type JsonObject } from "./environmentStateRules.js"; +import { + isObject, + same, + type JsonObject, + type StateEntity, +} from "./environmentStateRules.js"; import type { FieldRule, WriteCall } from "./environmentStateWriteRules.js"; -import { integerOf, readClock } from "./environmentStateWriteValues.js"; +import { + integerOf, + readClock, + sortedStrings, +} from "./environmentStateWriteValues.js"; import { freshUuid, renewedRule } from "./environmentStateWriteGenerated.js"; import { composedChange } from "./environmentStateWriteComposed.js"; +import { referenceRule } from "./environmentStateWriteReferences.js"; import { derivedRule, freshToken, @@ -29,6 +39,10 @@ export interface UpdateEvidence { readonly shown?: readonly string[]; /** An item action, whose unsent changes take the values it showed. */ readonly action?: boolean; + /** The entities of any type the state holds under an id, which an object the update rewrote may repeat. */ + readonly named?: ((id: string) => readonly StateEntity[]) | undefined; + /** The references earlier calls showed, which a call naming the entity a field already repeats keeps. */ + readonly references?: readonly Extract[]; } /** The rule a field the update changed without sending follows, if any; `slugs` maps each echoed member's slug to the first such member. */ @@ -48,6 +62,11 @@ export const changeRule = ( if (freshToken(evidence, field)) return { field, rule: "unique" }; const nested = nestedClockRule(field, { before, after }, evidence); if (nested !== undefined) return nested; + const reference = + evidence.named === undefined + ? undefined + : referenceRule(field, after, evidence.call.body, evidence.named); + if (reference !== undefined) return reference; // Child rows only: a single UUID that changed is a reference or a generated value, judged above. const renewed = Array.isArray(before) || isObject(before) @@ -135,6 +154,8 @@ export const sentRule = ( }; if (same(evidence.after[member], sent)) return { field: member, rule: "echo" }; + if (same(evidence.after[member], sortedStrings(sent))) + return { field: member, rule: "echo", sorted: true }; return freshUuid(evidence, member) ? { field: member, rule: "unique" } : { field: member, rule: "constant", value: evidence.after[member]! }; diff --git a/src/domain/analysisView/environmentStateWriteCopies.ts b/src/domain/analysisView/environmentStateWriteCopies.ts index d920ffd124..f4a736c9f1 100644 --- a/src/domain/analysisView/environmentStateWriteCopies.ts +++ b/src/domain/analysisView/environmentStateWriteCopies.ts @@ -34,7 +34,7 @@ export const templateRule = ( return undefined; }; -/** Body members the entity stores as sent, under their own name (echo) or only another (a copy of the whole member). */ +/** Body members the entity stores as sent, under their own name (echo) or only another (a copy of the whole member), or names an entity by (a reference). */ export const storedMembers = ( rules: readonly FieldRule[], entity: JsonObject, @@ -42,6 +42,7 @@ export const storedMembers = ( new Set( rules.flatMap((rule) => { if (rule.rule === "echo") return [rule.field]; + if (rule.rule === "reference") return [rule.member]; 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]!) diff --git a/src/domain/analysisView/environmentStateWriteCreate.ts b/src/domain/analysisView/environmentStateWriteCreate.ts index 78c1f27c4e..b58442780c 100644 --- a/src/domain/analysisView/environmentStateWriteCreate.ts +++ b/src/domain/analysisView/environmentStateWriteCreate.ts @@ -5,6 +5,7 @@ import { isObject, isParam, normalizedPath, + same, scalarText, segmentsOf, selectedCollection, @@ -35,6 +36,7 @@ import { callOf, clockEvidence, fail, + referencedIn, itemReads, refuse, requestBodyPlan, @@ -62,6 +64,7 @@ import { type Observed, } from "./environmentStateWriteObserve.js"; import { + keptOrders, pathValuesOf, siblingTrimmedClocks, type FieldRule, @@ -71,6 +74,7 @@ import { fittedClockFormat, noteFixedWidths, } from "./environmentStateWriteClockFormats.js"; +import { mergedCreatePlan } from "./environmentStateWriteCreateCalls.js"; import { containsDelimited, generatedIntegerDigits, @@ -183,40 +187,103 @@ const strandedInteger = ( ); }); -/** Build a create plan from its exemplar's observation. */ -const buildCreate = ( +/** Whether a created field no rule explains holds a generated value another field links to, or a unique integer another string strands, which serving cannot keep consistent. */ +const linkedUnexplained = ( + rules: readonly FieldRule[], + entity: JsonObject, + held: readonly JsonObject[], +): boolean => { + const explained = new Set(rules.map((rule) => rule.field)); + return ( + Object.keys(entity).some( + (field) => !explained.has(field) && linkedGenerated(field, entity, held), + ) || strandedInteger(rules, entity) + ); +}; + +/** Widen a create plan by a later call, when that call explained on its own agrees with it; whether it did. */ +const refineCreate = ( context: EffectContext, draft: WriteDraft, step: WriteStep, - evidence: { - readonly observed: Observed; - readonly call: WriteCall & { readonly body: JsonObject }; - readonly collection: string; - readonly envelope: string | undefined; - }, + evidence: CreateCallEvidence, ): boolean => { + const { explained, held } = explainedCall(context, draft, step, evidence); + for (const member of keptOrders(evidence.call.body, evidence.observed.object)) + draft.kept.add(member); + if ( + (explained.tied && draft.plan.collection === null) || + linkedUnexplained(explained.rules, evidence.observed.object, held) + ) + return false; + const merged = mergedCreatePlan( + { rules: draft.plan.rules, body: draft.plan.body! }, + draft.observed!, + { + entity: evidence.observed.object, + body: evidence.call.body, + rules: explained.rules, + contract: { + ...explained.body, + ...(evidence.envelope === undefined + ? {} + : { envelope: evidence.envelope }), + }, + }, + { kept: draft.kept, clockOf: (field) => clockEvidence(draft, field) }, + ); + if (merged === undefined) return false; + retainFixed(context, merged.body.fixed.slice(draft.plan.body!.fixed.length)); + draft.plan.rules = merged.rules; + draft.plan.body = merged.body; + return true; +}; + +type CreateCallEvidence = { + readonly observed: Observed; + readonly call: WriteCall & { readonly body: JsonObject }; + readonly collection: string; + readonly envelope: string | undefined; +}; + +/** Explain one recorded create on its own, against the entities its collection and type held before it. */ +const explainedCall = ( + context: EffectContext, + draft: WriteDraft, + step: WriteStep, + evidence: CreateCallEvidence, +) => { const type = draft.plan.target!; - const idField = context.idField(type); const members = fieldsOf(context.state.members(type, evidence.collection)); const held = fieldsOf(context.state.ofType(type)); const explained = explainCreate({ entity: evidence.observed.object, call: evidence.call, - idField, + idField: context.idField(type), startMs: startOf(context, step), collection: members, type: held, named: (id) => context.state.named(id), }); - const explainedFields = new Set(explained.rules.map((rule) => rule.field)); - if ( - Object.keys(evidence.observed.object).some( - (field) => - !explainedFields.has(field) && - linkedGenerated(field, evidence.observed.object, held), - ) || - strandedInteger(explained.rules, evidence.observed.object) - ) { + return { explained, members, held }; +}; + +/** Build a create plan from its exemplar's observation. */ +const buildCreate = ( + context: EffectContext, + draft: WriteDraft, + step: WriteStep, + evidence: CreateCallEvidence, +): boolean => { + const type = draft.plan.target!; + const idField = context.idField(type); + const { explained, members, held } = explainedCall( + context, + draft, + step, + evidence, + ); + if (linkedUnexplained(explained.rules, evidence.observed.object, held)) { refuse(draft, step, "unexplained-change"); return false; } @@ -246,6 +313,8 @@ const buildCreate = ( }, ); retainFixed(context, explained.body.fixed); + for (const member of keptOrders(evidence.call.body, evidence.observed.object)) + draft.kept.add(member); Object.assign(draft.plan, { exemplar_ordinal: step.record.ordinal, observation: { @@ -321,6 +390,30 @@ const followsSequences = ( ); }); +/** Whether a later call's rules reproduce its observed fields and its omitted members agree with the learned defaults. */ +const agreesLater = ( + context: EffectContext, + draft: WriteDraft, + created: Pick, + call: { readonly body: JsonObject; readonly observed: JsonObject }, +): boolean => + followsCreateRules(context, draft, { ...created, observed: call.observed }) && + !contradictsDefault(draft.plan.rules, call, created.fields); + +/** Whether an echoed member the call left out was stored otherwise than its learned default. */ +const contradictsDefault = ( + rules: readonly FieldRule[], + call: { readonly body: JsonObject; readonly observed: JsonObject }, + written: JsonObject, +): boolean => + rules.some( + (rule) => + rule.rule === "echo" && + !Object.hasOwn(call.body, rule.field) && + Object.hasOwn(call.observed, rule.field) && + !same(call.observed[rule.field], written[rule.field]), + ); + /** Whether a later create preserves generation shapes and its derived fields reproduce the captured observation. */ const followsCreateRules = ( context: EffectContext, @@ -332,10 +425,11 @@ const followsCreateRules = ( draft.plan.rules.every((rule) => { const value = created.fields[rule.field]; const exemplar = draft.observed?.[rule.field]; - if (rule.rule === "composed") + if (rule.rule === "composed" || rule.rule === "reference") return ( !Object.hasOwn(created.observed, rule.field) || - value === created.observed[rule.field] + value === created.observed[rule.field] || + (rule.rule === "reference" && same(value, created.observed[rule.field])) ); if (rule.rule === "clock") return ( @@ -578,25 +672,41 @@ export const applyCreate = ( pathValues: pathValuesOf(step.tool.path_template, collection) ?? call.pathValues, }; - const fields = createdFields(draft.plan, draft.observed!, created, (rule) => - recordedGeneratedValue( - rule, - draft.observed![rule.field], - observed.object[rule.field] ?? null, - ), - ); + const fieldsOfCall = () => + createdFields( + draft.plan, + draft.observed!, + created, + (rule) => + recordedGeneratedValue( + rule, + draft.observed![rule.field], + observed.object[rule.field] ?? null, + ), + referencedIn(context), + ); + // A later call that agrees with the plan widens it, as a later update does: to members it alone sent or left out, an + // array it showed sorted, or a clock spelling. One that disagrees leaves the plan as it was. + if (later) + refineCreate(context, draft, step, { + observed, + call, + collection, + envelope: request?.envelope, + }); + const fields = fieldsOfCall(); const id = fields.ok ? scalarText(fields.value[idField]) : undefined; if ( !fields.ok || id === undefined || context.state.entity(type, collection, id) !== undefined || (later && - !followsCreateRules(context, draft, { - type, - collection, - fields: fields.value, - observed: observed.object, - })) + !agreesLater( + context, + draft, + { type, collection, fields: fields.value }, + { body: call.body, observed: observed.object }, + )) ) { refuse(draft, step, "unexplained-change"); return recorded(); diff --git a/src/domain/analysisView/environmentStateWriteCreateCalls.ts b/src/domain/analysisView/environmentStateWriteCreateCalls.ts new file mode 100644 index 0000000000..2146c5b2e9 --- /dev/null +++ b/src/domain/analysisView/environmentStateWriteCreateCalls.ts @@ -0,0 +1,241 @@ +import type { JsonValue } from "../jsonValue.js"; +import { same, type JsonObject } from "./environmentStateRules.js"; +import { storedKinds } from "./environmentStateWriteApply.js"; +import { fittedClockFormat } from "./environmentStateWriteClockFormats.js"; +import { + alikeRules, + type FieldRule, + type WriteBody, +} from "./environmentStateWriteRules.js"; + +type Default = { readonly member: string; readonly value: JsonValue }; + +/** What earlier calls showed that a merge must respect: members stored unsorted as sent, and the value showing each clock's width. */ +export interface EarlierEvidence { + readonly kept: ReadonlySet; + readonly clockOf: (field: string) => JsonValue | undefined; +} + +/** The plan's rule for a field and a later call's agree: equal, or clocks one spelling covers given what earlier calls showed. */ +const agreeing = ( + held: FieldRule, + later: FieldRule, + values: { + readonly field: string; + readonly later: JsonValue | undefined; + readonly earlier: EarlierEvidence; + }, +): FieldRule | undefined => { + const alike = alikeRules(held, later, values.earlier.kept); + if (alike !== undefined) return alike; + if (held.rule !== "clock" || later.rule !== "clock") return undefined; + const format = fittedClockFormat( + held.format, + values.later, + values.earlier.clockOf(values.field), + ); + return format === undefined ? undefined : { ...held, format }; +}; + +/** Defaults joined, undefined when two calls showed one member left out at different values. */ +const joinedDefaults = ( + held: readonly Default[], + added: readonly Default[], +): Default[] | undefined => { + const joined = new Map(held.map((entry) => [entry.member, entry.value])); + for (const { member, value } of added) { + if (joined.has(member) && !same(joined.get(member), value)) + return undefined; + joined.set(member, value); + } + return [...joined].map(([member, value]) => ({ member, value })); +}; + +/** What two calls show of one field. */ +interface FieldCalls { + readonly before: FieldRule | undefined; + readonly after: FieldRule | undefined; + readonly exemplar: JsonObject; + readonly later: { readonly entity: JsonObject; readonly body: JsonObject }; + readonly sentBefore: ReadonlySet; + readonly required: readonly string[]; + readonly earlier: EarlierEvidence; +} + +/** A reference an earlier later call added: it stays while a call leaves its member out, and agrees with this call's when it shows it. */ +const addedReference = ( + field: string, + added: Extract, + calls: FieldCalls, +): { readonly rule: FieldRule } | undefined => { + const { after, later } = calls; + if (!Object.hasOwn(later.entity, field)) + return Object.hasOwn(later.body, added.member) + ? undefined + : { rule: added }; + const rule = + after === undefined + ? undefined + : agreeing(added, after, { + field, + later: later.entity[field], + earlier: calls.earlier, + }); + return rule === undefined ? undefined : { rule }; +}; + +/** A field one call shows alone, which must be a reference the other call did not send the member of. */ +const shownAlone = ( + inExemplar: boolean, + calls: FieldCalls, +): { readonly rule: FieldRule } | undefined => { + const own = inExemplar ? calls.before : calls.after; + if (own?.rule !== "reference") return undefined; + const sent = inExemplar + ? Object.hasOwn(calls.later.body, own.member) + : calls.sentBefore.has(own.member); + return sent ? undefined : { rule: own }; +}; + +/** One field's rule in the merged plan, and the default it gives a member; undefined when the calls disagree on it. */ +const mergedField = ( + field: string, + calls: FieldCalls, +): { readonly rule?: FieldRule; readonly default?: Default } | undefined => { + const { before, after, exemplar, later } = calls; + const inExemplar = Object.hasOwn(exemplar, field); + if (!inExemplar && before?.rule === "reference") + return addedReference(field, before, calls); + if (!inExemplar || !Object.hasOwn(later.entity, field)) + return shownAlone(inExemplar, calls); + if (before !== undefined && after !== undefined) { + const rule = agreeing(before, after, { + field, + later: later.entity[field], + earlier: calls.earlier, + }); + return rule === undefined ? undefined : { rule }; + } + // The plan echoes a member this call left out: what it stored there is the member's default. + if (before !== undefined) + return before.rule === "echo" && !Object.hasOwn(later.body, field) + ? { + rule: before, + default: { member: field, value: later.entity[field]! }, + } + : undefined; + // This call echoes a member the plan's calls left out: what they stored there is its default. + if (after !== undefined) + return after.rule === "echo" && !calls.required.includes(field) + ? { rule: after, default: { member: field, value: exemplar[field]! } } + : undefined; + return same(exemplar[field], later.entity[field]) ? {} : undefined; +}; + +/** + * A create plan widened by a later call, explained on its own: both calls show the same fields, but for a reference + * whose member a call without it did not send (one an earlier call added stays), every field both rule they rule alike + * as far as earlier calls allow, a field neither rules holds one value, and where only one echoes a member the other + * left out, the member becomes optional with the value the call that left it out stored. Members either call sent are + * accepted, and only those every call sent are required. Undefined when the calls disagree otherwise, so the later call + * stays unexplained. + */ +export const mergedCreatePlan = ( + plan: { readonly rules: readonly FieldRule[]; readonly body: WriteBody }, + exemplar: JsonObject, + later: { + readonly entity: JsonObject; + readonly body: JsonObject; + readonly rules: readonly FieldRule[]; + readonly contract: WriteBody; + }, + earlier: EarlierEvidence = { + kept: new Set(), + clockOf: (field) => exemplar[field], + }, +): { readonly rules: FieldRule[]; readonly body: WriteBody } | undefined => { + const held = new Map(plan.rules.map((rule) => [rule.field, rule])); + const shown = new Map(later.rules.map((rule) => [rule.field, rule])); + const sentBefore = new Set([ + ...plan.body.accepted, + ...plan.body.fixed.map(({ member }) => member), + ...plan.body.required, + ]); + const merged = new Map(); + const defaults: Default[] = []; + for (const field of new Set([ + ...Object.keys(exemplar), + ...Object.keys(later.entity), + ...held.keys(), + ])) { + const outcome = mergedField(field, { + before: held.get(field), + after: shown.get(field), + exemplar, + later, + sentBefore, + required: plan.body.required, + earlier, + }); + if (outcome === undefined) return undefined; + if (outcome.rule !== undefined) merged.set(field, outcome.rule); + if (outcome.default !== undefined) defaults.push(outcome.default); + } + const body = mergedBody(plan.body, later, exemplar); + const joined = + body === undefined + ? undefined + : joinedDefaults(plan.body.defaults ?? [], defaults); + if (body === undefined || joined === undefined) return undefined; + // The plan's rules keep their order; rules the later call adds follow. + return { + rules: [ + ...plan.rules.flatMap((rule) => { + const kept = merged.get(rule.field); + return kept === undefined ? [] : [kept]; + }), + ...[...merged.values()].filter((rule) => !held.has(rule.field)), + ], + body: joined.length === 0 ? body : { ...body, defaults: joined }, + }; +}; + +/** Two calls' body contracts joined, undefined when a member one accepts with any value the other fixes, or both fix it at different values. */ +const mergedBody = ( + body: WriteBody, + later: { + readonly body: JsonObject; + readonly contract: WriteBody; + readonly entity: JsonObject; + }, + exemplar: JsonObject, +): WriteBody | undefined => { + const fixed = new Map(body.fixed.map((entry) => [entry.member, entry.sent])); + const { contract } = later; + if ( + contract.accepted.some((member) => fixed.has(member)) || + contract.fixed.some( + (entry) => + body.accepted.includes(entry.member) || + (fixed.has(entry.member) && !same(fixed.get(entry.member), entry.sent)), + ) || + contract.envelope !== body.envelope + ) + return undefined; + const added = contract.accepted.filter( + (member) => !body.accepted.includes(member), + ); + return { + ...body, + accepted: [...body.accepted, ...added], + fixed: [ + ...body.fixed, + ...contract.fixed.filter((entry) => !fixed.has(entry.member)), + ], + required: body.required.filter((member) => + Object.hasOwn(later.body, member), + ), + identity: body.identity ?? contract.identity, + kinds: [...body.kinds, ...storedKinds(added, exemplar, later.entity)], + }; +}; diff --git a/src/domain/analysisView/environmentStateWriteEffects.ts b/src/domain/analysisView/environmentStateWriteEffects.ts index 088f9d4dfb..d8f055aa43 100644 --- a/src/domain/analysisView/environmentStateWriteEffects.ts +++ b/src/domain/analysisView/environmentStateWriteEffects.ts @@ -58,6 +58,8 @@ import { observationNeeds, } from "./environmentStateWriteAddress.js"; import { + alikeRules, + keptOrders, pathValuesOf, siblingTrimmedClocks, type FieldRule, @@ -90,6 +92,8 @@ export interface WriteDraft { readonly steady: Set; /** Unsent clocks a call left as they were in its own second, which it cannot show it stamps. */ readonly unstamped: Set; + /** Members a call stored as sent with strings out of code-point order, which no plan may store sorted. */ + readonly kept: Set; /** A value a held entity or an accepted call showed each clock field at a fixed width in, which keeps it untrimmed. */ readonly fixedWidth: Map; } @@ -260,6 +264,14 @@ export const callOf = ( body, }); +/** The fields of each entity of a type with an id, as the replayed state holds them. */ +export const referencedIn = + (context: EffectContext) => + (type: string, id: string): JsonObject[] => + context.state + .ofType(type) + .flatMap((entity) => (entity.id === id ? [entity.fields] : [])); + export const startOf = ( context: EffectContext, step: WriteStep, @@ -373,8 +385,17 @@ const refineUpdate = ( retainFixed(context, body.fixed.slice(plan.body!.fixed.length)); plan.body = body; const ruled = new Set(plan.rules.map((rule) => rule.field)); + const later = new Map( + evidence.explained.rules.map((rule) => [rule.field, rule]), + ); plan.rules = [ - ...plan.rules, + // A later call may be the first to show an echoed array sorted. + ...plan.rules.map((rule) => { + const shown = later.get(rule.field); + return shown === undefined + ? rule + : (alikeRules(rule, shown, draft.kept) ?? rule); + }), ...evidence.explained.rules.filter((rule) => !ruled.has(rule.field)), ]; }; @@ -542,9 +563,13 @@ export const updateEntity = ( : { readFields: after.readFields }), others: otherFields(context.state.ofType(type), { collection, id }), shown: shownBefore(context.viewOf, entity, after.ordinal), + named: (named) => context.state.named(named), action: needs.action, + references: draft.plan.rules.filter((rule) => rule.rule === "reference"), }), ); + for (const member of keptOrders(call.body, after.object)) + draft.kept.add(member); if (explained.unexplained.length > 0 && !unknown) { refuse(draft, step, "unexplained-change"); return recorded(); @@ -577,6 +602,7 @@ export const updateEntity = ( entity.fields[rule.field], after.object[rule.field], ), + referencedIn(context), ); const kept = keptFields(entity.fields, after.object); const disagrees = @@ -702,4 +728,4 @@ const contradictsPlan = ( ): boolean => contradictsEarlierCalls(draft, explained.rules) || contradictsGeneratedClock(before, after, draft.plan.rules, explained) || - conflictsWithPlan(draft.plan, explained); + conflictsWithPlan(draft.plan, explained, draft.kept); diff --git a/src/domain/analysisView/environmentStateWriteExplain.ts b/src/domain/analysisView/environmentStateWriteExplain.ts index 9bcbdbb3b9..8aef269f26 100644 --- a/src/domain/analysisView/environmentStateWriteExplain.ts +++ b/src/domain/analysisView/environmentStateWriteExplain.ts @@ -26,9 +26,11 @@ import { readClock, sequenceNumber, slugOf, + sortedStrings, type ClockFormat, } from "./environmentStateWriteValues.js"; import { createdComposer } from "./environmentStateWriteComposed.js"; +import { referenceRule } from "./environmentStateWriteReferences.js"; import { fittedClockFormat, showsFixedWidth, @@ -151,6 +153,8 @@ const requestRule = ( const { body, pathValues } = evidence.call; if (Object.hasOwn(body, field) && same(body[field], value)) return { field, rule: "echo" }; + if (Object.hasOwn(body, field) && same(sortedStrings(body[field]), value)) + return { field, rule: "echo", sorted: true }; if (typeof value === "string" && value.length >= COPY_MIN_LENGTH) { const from = index.copies.get(value); if (from !== undefined) return { field, rule: "copy", from }; @@ -257,6 +261,16 @@ const generatedRule = ( return createdNestedClock(field, value, evidence); }; +/** Pinned members less those a reference reads: it rebuilds the object they name for any held entity. */ +const unreferenced = ( + rules: readonly FieldRule[], + pinned: Set, +): Set => { + for (const rule of rules) + if (rule.rule === "reference") pinned.delete(rule.member); + return pinned; +}; + /** Body members the application stored as sent, and the others with what they were sent. */ const bodyOf = ( body: JsonObject, @@ -286,7 +300,10 @@ export const explainCreate = ( for (const [field, value] of Object.entries(evidence.entity)) { const rule = requestRule(evidence, index, field, value) ?? - generatedRule(evidence, field, value); + generatedRule(evidence, field, value) ?? + (evidence.named === undefined + ? undefined + : referenceRule(field, value, evidence.call.body, evidence.named)); if (rule === undefined) unexplained.push(field); else ruled.push(rule); } @@ -337,10 +354,9 @@ export const explainCreate = ( ), ...instantMembers(evidence.call.body, evidence.entity, constants), ]); - const pinned = pinnedEchoes( - evidence.entity, - evidence.call.body, - evidence.type, + const pinned = unreferenced( + rules, + pinnedEchoes(evidence.entity, evidence.call.body, evidence.type), ); const stored = storedMembers( rules.filter((rule) => !stranded.has(rule.field)), @@ -379,6 +395,24 @@ const echoedSlugs = ( return slugs; }; +/** A reference an earlier call showed for a field this call left as it was, when the call names the entity the field repeats through its member. */ +const keptReference = ( + evidence: UpdateEvidence, + field: string, +): FieldRule | undefined => { + const known = evidence.references?.find((rule) => rule.field === field); + if (known === undefined || evidence.named === undefined) return undefined; + const shown = referenceRule( + field, + evidence.after[field], + evidence.call.body, + evidence.named, + ); + return shown?.member === known.member && shown.type === known.type + ? known + : undefined; +}; + /** Explain an update: echoed members, fixed members whose stored value is constant, and the changes it made unsent. */ export const explainUpdate = ( evidence: UpdateEvidence, @@ -428,18 +462,25 @@ export const explainUpdate = ( if ( Object.hasOwn(body, field) || (!Object.hasOwn(evidence.before, field) && - !(evidence.shown ?? []).includes(field)) || - same(evidence.before[field], evidence.after[field]) + !(evidence.shown ?? []).includes(field)) ) continue; + if (same(evidence.before[field], evidence.after[field])) { + const kept = keptReference(evidence, field); + if (kept !== undefined) rules.push(kept); + continue; + } const rule = changeRule(evidence, field, slugs); if (rule === undefined) unexplained.push(field); else rules.push(rule); } rules.push(...pairedStamps(evidence, rules)); + // A member a reference reads names an entity, which any held one may be. + for (const rule of rules) + if (rule.rule === "reference") echoed.add(rule.member); // Serving renews one UUID alike in every field, so the call must have. unexplained.push(...unexplainedRenewals(rules, evidence)); - const pinned = pinnedEchoes(evidence.after, body); + const pinned = unreferenced(rules, pinnedEchoes(evidence.after, body)); const shaped = bodyOf( Object.fromEntries( Object.entries(body).filter(([member]) => member !== identity), diff --git a/src/domain/analysisView/environmentStateWriteReferences.test.ts b/src/domain/analysisView/environmentStateWriteReferences.test.ts new file mode 100644 index 0000000000..9c4d22fb22 --- /dev/null +++ b/src/domain/analysisView/environmentStateWriteReferences.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from "vitest"; + +import { + timedCaptureEvidence, + type TimedCaptureRow, +} from "../../../tests/fixtures/analysisView.js"; +import { + FILED_ROWS, + REFILED_ROWS, + TAGGED_ROWS, +} from "../../../tests/fixtures/environmentStateDerivedValues.js"; +import { environmentStateViewOf } from "../../../tests/fixtures/environmentStateWrites.js"; +import type { StateEntity } from "./environmentStateRules.js"; +import { ClockSpellings } from "./environmentStateViewSpellings.js"; +import { alikeRules, type FieldRule } from "./environmentStateWriteRules.js"; +import { + referenceRule, + referencedValue, +} from "./environmentStateWriteReferences.js"; + +const SHELF = { id: 2, name: "Map Room", floor: "upper" }; +const held = ( + type: string, + id: string, + fields: StateEntity["fields"], +): StateEntity => ({ + type, + collection: type, + id, + fields, + view_fields: [], + record_ordinals: [], +}); + +describe("objects that repeat the entity a member names", () => { + it("serves creates that embed the named shelf, and lists show it with the keys they list", () => { + const view = environmentStateViewOf(timedCaptureEvidence(FILED_ROWS)); + expect( + view.writes.find((plan) => plan.tool === "post_books"), + ).toMatchObject({ + served: true, + body: { accepted: expect.arrayContaining(["name", "shelf_id"]) }, + rules: expect.arrayContaining([ + { + field: "shelf", + rule: "reference", + member: "shelf_id", + type: "/api/shelves", + fields: ["id", "name", "floor"], + }, + ]), + }); + expect( + view.views.find((candidate) => candidate.tool === "get_books"), + ).toMatchObject({ + nested_fields: { shelf: ["id", "name"] }, + }); + }); + + it("learns the keys a view shows of a shelf in whatever order each object gives them, keeping the first order", () => { + const spellings = new ClockSpellings(() => undefined); + spellings.note({ shelf: SHELF }, { shelf: { id: 2, name: "Map Room" } }); + spellings.note({ shelf: SHELF }, { shelf: { name: "Map Room", id: 2 } }); + expect(spellings.view).toEqual({ + nested_fields: { shelf: ["id", "name"] }, + }); + spellings.note({ shelf: SHELF }, { shelf: { id: 2 } }); + expect(spellings.view).toEqual({}); + }); + + it("serves updates that keep a shelf a later call names again", () => { + const view = environmentStateViewOf(timedCaptureEvidence(REFILED_ROWS)); + expect(view.writes.find((plan) => plan.effect === "update")).toMatchObject({ + served: true, + body: { accepted: expect.arrayContaining(["name", "shelf_id"]) }, + rules: expect.arrayContaining([ + expect.objectContaining({ field: "shelf", rule: "reference" }), + ]), + }); + }); + + it("names the member whose entity the object repeats, the one named like the field when several do", () => { + const named = (id: string) => + id === "2" + ? [held("/api/shelves", "2", SHELF), held("/api/rooms", "2", SHELF)] + : []; + expect( + referenceRule("shelf", SHELF, { shelf_id: 2 }, named), + ).toBeUndefined(); + const shelves = (id: string) => + id === "2" ? [held("/api/shelves", "2", SHELF)] : []; + expect( + referenceRule("shelf", SHELF, { shelf_id: 2, room_id: 2 }, shelves), + ).toMatchObject({ member: "shelf_id", type: "/api/shelves" }); + expect( + referenceRule("shelf", { id: 2 }, { shelf_id: 2 }, shelves), + ).toBeUndefined(); + // One id naming a shelf in two collections names neither. + expect( + referenceRule("shelf", SHELF, { shelf_id: 2 }, (id) => + id === "2" + ? [ + held("/api/shelves", "2", SHELF), + { ...held("/api/shelves", "2", SHELF), collection: "/api/annex" }, + ] + : [], + ), + ).toBeUndefined(); + expect( + referenceRule( + "shelf", + { ...SHELF, floor: "lower" }, + { shelf_id: 2 }, + shelves, + ), + ).toBeUndefined(); + }); + + it("refuses a call naming an entity the environment does not hold, or holds in several collections", () => { + const rule: Extract = { + field: "shelf", + rule: "reference", + member: "shelf_id", + type: "/api/shelves", + fields: ["id", "name"], + }; + expect(referencedValue(rule, { shelf_id: 9 }, () => [])).toMatchObject({ + ok: false, + }); + expect(referencedValue(rule, {}, () => [])).toEqual({ + ok: true, + value: undefined, + }); + expect(referencedValue(rule, { shelf_id: 2 }, () => [SHELF])).toEqual({ + ok: true, + value: { id: 2, name: "Map Room" }, + }); + expect( + referencedValue(rule, { shelf_id: 2 }, () => [SHELF, SHELF]), + ).toMatchObject({ + ok: false, + error: expect.stringContaining("2 collections"), + }); + }); +}); + +describe("arrays an application stores sorted", () => { + it("does not sort tags an earlier create stored out of order as sent", () => { + const kept = TAGGED_ROWS.map((row, index): TimedCaptureRow => { + if (index !== 2 && index !== 3) return row; + const [method, path, response, ...rest] = row; + return [ + method, + path, + { ...(response as object), tags: ["zoning", "maps"] }, + ...rest, + ]; + }); + const plan = environmentStateViewOf(timedCaptureEvidence(kept)).writes.find( + (candidate) => candidate.tool === "post_books", + ); + expect(plan?.rules).toContainEqual({ field: "tags", rule: "echo" }); + expect(plan).toMatchObject({ served: false }); + }); + + it("keeps an echo sorted only while no call kept its strings out of order", () => { + const plain: FieldRule = { field: "tags", rule: "echo" }; + const sorted: FieldRule = { field: "tags", rule: "echo", sorted: true }; + expect(alikeRules(plain, sorted)).toEqual(sorted); + expect(alikeRules(sorted, plain)).toEqual(sorted); + expect(alikeRules(plain, sorted, new Set(["tags"]))).toBeUndefined(); + expect(alikeRules(sorted, plain, new Set(["tags"]))).toBeUndefined(); + }); + + it("serves creates that store sent tags in code-point order", () => { + const view = environmentStateViewOf(timedCaptureEvidence(TAGGED_ROWS)); + expect( + view.writes.find((plan) => plan.tool === "post_books"), + ).toMatchObject({ + served: true, + rules: expect.arrayContaining([ + { field: "tags", rule: "echo", sorted: true }, + ]), + }); + }); +}); diff --git a/src/domain/analysisView/environmentStateWriteReferences.ts b/src/domain/analysisView/environmentStateWriteReferences.ts new file mode 100644 index 0000000000..5d5a68525a --- /dev/null +++ b/src/domain/analysisView/environmentStateWriteReferences.ts @@ -0,0 +1,99 @@ +import type { JsonValue } from "../jsonValue.js"; +import { err, ok, type Result } from "../result.js"; +import { + isObject, + same, + scalarText, + type JsonObject, + type StateEntity, +} from "./environmentStateRules.js"; +import type { FieldRule } from "./environmentStateWriteRules.js"; + +type ReferenceRule = Extract; + +/** The fields of each entity of a type with an id the state holds, one per collection holding one. */ +export type ReferencedEntity = ( + type: string, + id: string, +) => readonly JsonObject[]; + +/** A member name with its trailing id dropped and lower case, to tie `parentId` to `parent`. */ +const stemOf = (name: string): string => + name + .replace(/(?:Id|ID|_id|_ID|Ids|_ids)$/u, "") + .toLowerCase() + .replace(/[^a-z0-9]/gu, ""); + +/** Whether every member of an object holds the value an entity holds there. */ +const projects = (object: JsonObject, fields: JsonObject): boolean => + Object.entries(object).every( + ([key, value]) => Object.hasOwn(fields, key) && same(fields[key], value), + ); + +/** + * A created or updated object field that repeats fields of the entity a sent member names, as the state holds it: a + * parent beside `parentId`. Several members naming such entities resolve to the one named like the field; undefined + * when none does, or when that leaves more than one. An id that names entities of one type in several collections + * names none, since serving could not tell which. + */ +export const referenceRule = ( + field: string, + value: JsonValue | undefined, + body: JsonObject, + named: (id: string) => readonly StateEntity[], +): ReferenceRule | undefined => { + if (!isObject(value) || Object.keys(value).length < 2) return undefined; + const matches = new Map(); + for (const [member, sent] of Object.entries(body)) { + const id = scalarText(sent); + if (id === undefined || id.length === 0) continue; + const held = named(id); + for (const entity of held) + if ( + projects(value, entity.fields) && + held.filter((other) => other.type === entity.type).length === 1 + ) + matches.set(`${member} ${entity.type}`, { member, type: entity.type }); + } + const candidates = [...matches.values()]; + const chosen = + candidates.length === 1 + ? candidates + : candidates.filter(({ member }) => stemOf(member) === stemOf(field)); + return chosen.length === 1 + ? { + field, + rule: "reference", + member: chosen[0]!.member, + type: chosen[0]!.type, + fields: Object.keys(value), + } + : undefined; +}; + +/** A reference's value for a call: the named entity's fields it lists, or undefined when the call does not send its member. */ +export const referencedValue = ( + rule: ReferenceRule, + body: JsonObject, + referenced: ReferencedEntity | undefined, +): Result => { + if (!Object.hasOwn(body, rule.member)) return ok(undefined); + const id = scalarText(body[rule.member]); + const held = id === undefined ? [] : (referenced?.(rule.type, id) ?? []); + const [fields] = held; + if (fields === undefined) + return err( + `Member ${rule.member} names no entity of ${rule.type} the environment holds, which ${rule.field} repeats.`, + ); + if (held.length > 1) + return err( + `Member ${rule.member} names entities of ${rule.type} in ${held.length} collections, so ${rule.field} cannot tell which it repeats.`, + ); + return ok( + Object.fromEntries( + rule.fields.flatMap((key) => + Object.hasOwn(fields, key) ? [[key, fields[key]!]] : [], + ), + ), + ); +}; diff --git a/src/domain/analysisView/environmentStateWriteRules.ts b/src/domain/analysisView/environmentStateWriteRules.ts index badc28748f..843b96ab01 100644 --- a/src/domain/analysisView/environmentStateWriteRules.ts +++ b/src/domain/analysisView/environmentStateWriteRules.ts @@ -4,10 +4,14 @@ import { isParam, normalizedPath, pathSegmentMatches, + same, segmentsOf, type JsonObject, } from "./environmentStateRules.js"; -import type { ClockFormat } from "./environmentStateWriteValues.js"; +import { + sortedStrings, + type ClockFormat, +} from "./environmentStateWriteValues.js"; import { showsFixedWidth, trimmedLike, @@ -70,7 +74,8 @@ export type GeneratedRule = /** How one field of a written entity gets its value; fields no rule names keep the observed value. */ export type FieldRule = | GeneratedRule - | { readonly field: string; readonly rule: "echo" } + /** The sent member stored as sent, or with its strings in code-point order when `sorted`. */ + | { readonly field: string; readonly rule: "echo"; readonly sorted?: true } | { readonly field: string; readonly rule: "copy"; readonly from: string } | { readonly field: string; readonly rule: "path"; readonly from: string } | { readonly field: string; readonly rule: "slug"; readonly from: string } @@ -81,6 +86,14 @@ export type FieldRule = readonly rule: "composed"; readonly parts: ComposedPart[]; } + /** An object holding `fields` of the entity of `type` the sent `member` names, as the state holds it; absent when the call does not send it. */ + | { + readonly field: string; + readonly rule: "reference"; + readonly member: string; + readonly type: string; + readonly fields: string[]; + } | { readonly field: string; readonly rule: "increment" } /** An update stores `value` whenever the call sends the member, as the call that showed it did. */ | { @@ -143,6 +156,31 @@ export const siblingTrimmedClocks = ( }); }; +/** + * Two calls' rules for a field as one plan holds them: equal, or echoes one of which sorted its strings (a call whose + * array was already in order cannot show it), kept sorted unless a call kept strings out of order. Undefined when they + * differ otherwise. + */ +export const alikeRules = ( + held: FieldRule, + later: FieldRule, + kept: ReadonlySet = new Set(), +): FieldRule | undefined => { + if (held.rule !== "echo" || later.rule !== "echo") + return same(held, later) ? held : undefined; + if ((held.sorted === true) === (later.sorted === true)) return held; + if (kept.has(held.field)) return undefined; + return held.sorted === true ? held : later; +}; + +/** Members a call sent as strings out of code-point order and stored as sent, which shows the application keeps their order. */ +export const keptOrders = (body: JsonObject, stored: JsonObject): string[] => + Object.keys(body).filter( + (member) => + sortedStrings(body[member]) !== undefined && + same(stored[member], body[member]), + ); + /** One part of a composed string: literal text, or another written field as written, slugged, or as a clock's calendar digits. */ export type ComposedPart = | { readonly text: string } @@ -176,6 +214,10 @@ export interface WriteBody { readonly kinds: { readonly member: string; readonly kind: JsonKind }[]; /** The member a call wraps its object in, as a one-element array. */ readonly envelope?: string | undefined; + /** The value a create stores for an accepted member a call leaves out, as a recorded call that left it out showed. */ + readonly defaults?: + | { readonly member: string; readonly value: JsonValue }[] + | undefined; } /** diff --git a/src/domain/analysisView/environmentStateWriteValues.ts b/src/domain/analysisView/environmentStateWriteValues.ts index 8ab0d19bcb..79d1fe7030 100644 --- a/src/domain/analysisView/environmentStateWriteValues.ts +++ b/src/domain/analysisView/environmentStateWriteValues.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import type { JsonValue } from "../jsonValue.js"; import { readRecordedDateTime } from "../offsetDateTime.js"; +import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; /** How a recorded clock value is spelled: ISO-8601 with its separator, fraction digits and zone, or an epoch number. */ export type ClockFormat = @@ -194,6 +195,28 @@ export const generatedIntegerDigits = ( : undefined; }; +/** A sent array of strings as an application stores it sorted: in code-point order, when that differs from the sent order and shows two distinct strings. */ +export const sortedStrings = ( + sent: JsonValue | undefined, +): string[] | undefined => { + if ( + !Array.isArray(sent) || + !sent.every((item) => typeof item === "string") || + new Set(sent).size < 2 + ) + return undefined; + const sorted = (sent as string[]).toSorted(compareUnicodeCodePoints); + return sorted.some((item, index) => item !== sent[index]) + ? sorted + : undefined; +}; + +/** The value an echo rule stores for a sent value: as sent, or its strings sorted. */ +export const echoed = ( + rule: { readonly sorted?: true }, + sent: JsonValue, +): JsonValue => (rule.sorted === true ? (sortedStrings(sent) ?? sent) : sent); + /** Whether a generated integer retains the exemplar's digit count and JSON kind. */ export const likeGeneratedInteger = ( value: JsonValue | undefined, diff --git a/tests/boundary/mcp/environmentServeWritesMcp.test.ts b/tests/boundary/mcp/environmentServeWritesMcp.test.ts index ef053d67d2..228678bdd2 100644 --- a/tests/boundary/mcp/environmentServeWritesMcp.test.ts +++ b/tests/boundary/mcp/environmentServeWritesMcp.test.ts @@ -23,6 +23,7 @@ import { SECOND_BOOK, renamedBook, } from "../../fixtures/environmentStateWrites.js"; +import { referencedCreateRows } from "../../fixtures/environmentStateDerivedValues.js"; import { unsupportedInputLiteralSchemaError } from "../../fixtures/mcpClientSchemaConstraints.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { startListeningProcess } from "../../support/mcp/streamableHttpProcess.js"; @@ -409,3 +410,40 @@ processTest( }, HTTP_BOUNDARY_TIMEOUT_MS, ); + +processTest( + "withholds a create from public tools when its sent reference contradicts the recorded response", + async ({ processes }) => { + const root = await createTestTempDirectory("rea-create-reference-mcp-"); + const rows = referencedCreateRows({ + id: 1, + name: "Different Hall", + floor: "ground", + }); + const files = await writeEnvironmentServeFiles( + root, + environmentStateServeFixture(timedCaptureEvidence(rows)), + ); + const started = await startListeningProcess(processes, [ + "environment-serve", + files.manifestPath, + "--evidence", + files.capturePath, + "--port", + "0", + ]); + const client = new Client({ + name: "contradictory-reference", + version: "1", + }); + await client.connect(new StreamableHTTPClientTransport(started.url)); + try { + const { tools } = await client.listTools(); + expect(tools.map(({ name }) => name)).toContain("get_books"); + expect(tools.map(({ name }) => name)).not.toContain("post_books"); + } finally { + await client.close(); + } + }, + HTTP_BOUNDARY_TIMEOUT_MS, +); diff --git a/tests/fixtures/environmentStateDerivedValues.ts b/tests/fixtures/environmentStateDerivedValues.ts index dd18c8fab8..76a2f2f006 100644 --- a/tests/fixtures/environmentStateDerivedValues.ts +++ b/tests/fixtures/environmentStateDerivedValues.ts @@ -53,6 +53,21 @@ export const TAG_ROWS: readonly TimedCaptureRow[] = [ ["GET", "/api/tags", [FIRST_TAG, SECOND_TAG, HEAVY_TAG, BORROWED_TAG]], ]; +/** A held tag whose update clock keeps a trailing zero, so the application spells that clock at a fixed width. */ +const FIXED_SECOND_TAG = { + ...SECOND_TAG, + updatedAt: "2026-10-01T10:00:00.223456790Z", +}; + +/** The tag creates beside a held tag that shows the update clock at a fixed width, which the later create's trimmed clock contradicts. */ +export const HELD_FIXED_TAG_ROWS: readonly TimedCaptureRow[] = [ + ["GET", "/api/tags", [FIRST_TAG, FIXED_SECOND_TAG]], + ["GET", `/api/tags/${numberedUuid(1)}`, FIRST_TAG], + ["GET", `/api/tags/${numberedUuid(2)}`, FIXED_SECOND_TAG], + ...TAG_ROWS.slice(3, 7), + ["GET", "/api/tags", [FIRST_TAG, FIXED_SECOND_TAG, HEAVY_TAG, BORROWED_TAG]], +]; + /** A later tag create whose update clock Go trims while its creation clock keeps a trailing zero. */ const FIXED_CREATED_TAG = tag( 4, @@ -320,6 +335,215 @@ export const TAG_UPDATE_ROWS: readonly TimedCaptureRow[] = [ ["GET", `/api/tags/${numberedUuid(2)}`, RENAMED_SECOND_TAG], ]; +/** A book shelved as the application shelves it, on `general` when the call names no shelf. */ +const shelved = ( + id: number, + name: string, + shelf: string, + at: string, +): Entity => ({ + id, + name, + shelf, + created_at: at, +}); + +const SHELVED_SEEDS = [ + shelved(1, "First Book", "general", "2026-10-01T09:00:00Z"), + shelved(2, "Second Book", "general", "2026-10-01T10:00:00Z"), +]; + +/** Two book creates, one naming a shelf and one leaving it out, in the order given, each read back. */ +export const shelvedRows = ( + first: { readonly name: string; readonly shelf?: string }, + second: { readonly name: string; readonly shelf?: string }, + stored: (sent: string) => string = (sent) => sent, +): TimedCaptureRow[] => { + const third = shelved( + 3, + first.name, + first.shelf === undefined ? "general" : stored(first.shelf), + bookClock(3), + ); + const fourth = shelved( + 4, + second.name, + second.shelf === undefined ? "general" : stored(second.shelf), + bookClock(5), + ); + return [ + ["GET", "/api/books", SHELVED_SEEDS], + ["GET", "/api/books/1", SHELVED_SEEDS[0]!], + ["GET", "/api/books/2", SHELVED_SEEDS[1]!], + ["POST", "/api/books", third, 201, first], + ["GET", "/api/books/3", third], + ["POST", "/api/books", fourth, 201, second], + ["GET", "/api/books/4", fourth], + ["GET", "/api/books", [...SHELVED_SEEDS, third, fourth]], + ]; +}; + +/** The shelved creates, then one that leaves the shelf out yet stores another shelf than the default, shown only in its answer. */ +export const misshelvedRows = (): TimedCaptureRow[] => { + const rows = shelvedRows( + { name: "Third Book" }, + { name: "Fourth Book", shelf: "rare" }, + ); + const fifth = shelved(5, "Fifth Book", "attic", bookClock(rows.length)); + return [...rows, ["POST", "/api/books", fifth, 201, { name: "Fifth Book" }]]; +}; + +const SHELVES = [ + { id: 1, name: "Main Hall", floor: "ground" }, + { id: 2, name: "Map Room", floor: "upper" }, +]; + +/** A book as its item read shows it: its shelf whole, which its list read shows by id and name only. */ +const filed = ( + id: number, + name: string, + shelf: number, + at: string, +): Entity => ({ + id, + name, + shelf: SHELVES[shelf - 1]!, + created_at: at, +}); + +const listed = (book: Entity): Entity => { + const { id, name } = book["shelf"] as { id: number; name: string }; + return { ...book, shelf: { id, name } }; +}; + +const FILED_SEED = filed(1, "Tide Tables", 1, "2026-10-01T09:00:00Z"); +const ATLAS = filed(2, "Coastal Atlas", 2, bookClock(5)); +const CHARTS = filed(3, "Harbour Charts", 1, bookClock(8)); + +/** Two book creates naming a shelf by `shelf_id`, whose answers and item reads show the shelf whole and whose list shows it by id and name. */ +export const FILED_ROWS: readonly TimedCaptureRow[] = [ + ["GET", "/api/shelves", SHELVES], + ["GET", "/api/shelves/1", SHELVES[0]!], + ["GET", "/api/shelves/2", SHELVES[1]!], + ["GET", "/api/books", [listed(FILED_SEED)]], + ["GET", "/api/books/1", FILED_SEED], + ["POST", "/api/books", ATLAS, 201, { name: "Coastal Atlas", shelf_id: 2 }], + ["GET", "/api/books/2", ATLAS], + ["GET", "/api/books", [listed(FILED_SEED), listed(ATLAS)]], + ["POST", "/api/books", CHARTS, 201, { name: "Harbour Charts", shelf_id: 1 }], + ["GET", "/api/books/3", CHARTS], + ["GET", "/api/books", [listed(FILED_SEED), listed(ATLAS), listed(CHARTS)]], +]; + +/** Two creates whose last answer can contradict the named shelf, followed only by a list omitting that field. */ +export const referencedCreateRows = (shelf: JsonValue): TimedCaptureRow[] => { + const last = { ...CHARTS, shelf }; + const summary = ({ id, name }: Entity): Entity => ({ id: id!, name: name! }); + return [ + ...FILED_ROWS.filter(([, path]) => path.startsWith("/api/shelves")), + ["GET", "/api/books", [summary(FILED_SEED)]], + ["GET", "/api/books/1", FILED_SEED], + ["POST", "/api/books", ATLAS, 201, { name: "Coastal Atlas", shelf_id: 2 }], + ["GET", "/api/books/2", ATLAS], + ["GET", "/api/books", [FILED_SEED, ATLAS].map(summary)], + ["POST", "/api/books", last, 201, { name: "Harbour Charts", shelf_id: 1 }], + ["GET", "/api/books", [FILED_SEED, ATLAS, last].map(summary)], + ]; +}; + +/** A book moved to another shelf by `shelf_id`, then renamed by a call that sends the shelf it already has. */ +export const REFILED_ROWS: readonly TimedCaptureRow[] = (() => { + const moved = filed(1, "Tide Tables", 2, "2026-10-01T09:00:00Z"); + const renamed = { ...moved, name: "Tide Tables, Revised" }; + return [ + ...FILED_ROWS.slice(0, 5), + ["PATCH", "/api/books/1", moved, 200, { shelf_id: 2 }], + ["GET", "/api/books/1", moved], + ["GET", "/api/books", [listed(moved)]], + [ + "PATCH", + "/api/books/1", + renamed, + 200, + { name: "Tide Tables, Revised", shelf_id: 2 }, + ], + ["GET", "/api/books/1", renamed], + ["GET", "/api/books", [listed(renamed)]], + ]; +})(); + +/** Two book creates, one naming a shelf by `shelf_id` and one leaving it out, in the order given, each read back and listed. */ +export const optionallyFiledRows = (shelfFirst: boolean): TimedCaptureRow[] => { + const unfiled = (id: number, at: string): Entity => ({ + id, + name: "Field Notes", + created_at: at, + }); + const [first, second] = shelfFirst + ? [filed(2, "Coastal Atlas", 2, bookClock(5)), unfiled(3, bookClock(8))] + : [unfiled(2, bookClock(5)), filed(3, "Coastal Atlas", 2, bookClock(8))]; + const sent = (book: Entity) => + "shelf" in book + ? { name: book["name"]!, shelf_id: 2 } + : { name: book["name"]! }; + const shown = (book: Entity) => ("shelf" in book ? listed(book) : book); + return [ + ...FILED_ROWS.slice(0, 5), + ["POST", "/api/books", first, 201, sent(first)], + ["GET", "/api/books/2", first], + ["GET", "/api/books", [listed(FILED_SEED), shown(first)]], + ["POST", "/api/books", second, 201, sent(second)], + ["GET", "/api/books/3", second], + ["GET", "/api/books", [listed(FILED_SEED), shown(first), shown(second)]], + ]; +}; + +const tagged = ( + id: number, + name: string, + tags: string[], + at: string, +): Entity => ({ + id, + name, + tags, + created_at: at, +}); + +/** Two book creates sending tags out of order, which the application stores sorted. */ +export const TAGGED_ROWS: readonly TimedCaptureRow[] = [ + [ + "GET", + "/api/books", + [tagged(1, "Tide Tables", ["coast", "tides"], "2026-10-01T09:00:00Z")], + ], + [ + "GET", + "/api/books/1", + tagged(1, "Tide Tables", ["coast", "tides"], "2026-10-01T09:00:00Z"), + ], + [ + "POST", + "/api/books", + tagged(2, "Atlas", ["maps", "zoning"], bookClock(2)), + 201, + { name: "Atlas", tags: ["zoning", "maps"] }, + ], + ["GET", "/api/books/2", tagged(2, "Atlas", ["maps", "zoning"], bookClock(2))], + [ + "POST", + "/api/books", + tagged(3, "Charts", ["harbour", "sea"], bookClock(4)), + 201, + { name: "Charts", tags: ["sea", "harbour"] }, + ], + [ + "GET", + "/api/books/3", + tagged(3, "Charts", ["harbour", "sea"], bookClock(4)), + ], +]; + /** The same renames with an archive the gate does not model before the second, which leaves that tag unknown. */ export const UNKNOWN_TAG_UPDATE_ROWS: readonly TimedCaptureRow[] = [ ...TAG_UPDATE_ROWS.slice(0, 5),