mirror of
https://github.com/morluto/rea
synced 2026-10-25 14:01:13 -04:00
feat(environment): learn creates from every call, with sorted arrays and referenced entities (#7034)
* feat(environment): explain values an application derives from other fields
Prompt-built environments withheld creates whose responses carry values the
application derives deterministically, which the write model kept as
constants or could not match across calls:
- A composed string is literal text plus parts read from the fields other
rules write: a field's value, its slug, or a clock's calendar digits. A
create composes a string only when another entity of the type spells it
the same way and none spells it otherwise, and never from two sources
serving could tell apart; an update composes an unsent change from members
stored as sent and unchanged fields that also spell its value before the
call. A composed field no longer pins the members it reads.
- A trimmed clock format drops trailing fraction zeros, as Go spells
nanoseconds. It is learned from the type's values, a later create or a
later update, and spreads to the plan's other clocks unless a value shows
a fixed width.
- A large integer no entity of the type holds, among distinct ones of the
same length and kind, is a generated unique value, served as a seeded
integer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(environment): record the derived-values verification
Gates, tests, write plans over 69 recorded captures and served write tasks on the baseline and on this branch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(environment): learn a create plan from every call
A later create the plan does not take is explained on its own and merged when both calls show the same fields, 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, kept in the body's defaults.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(environment): sorted arrays, referenced entities and nested view keys
- An echo is `sorted` when the application stored the sent strings in
code-point order; serving stores them sorted, and a plain echo and a
sorted one agree in update and create plans.
- A `reference` is an object field holding fields of the entity a sent
member names, as the state holds it. The member is accepted with any
value; replay and serving look the entity up, and serving refuses a call
naming one the environment does not hold.
- A view names in `nested_fields` the keys it always shows of objects it
shows with fewer keys than an entity stores; a stored object it never
recorded renders with them. The view spelling learner moves to its own
module.
- Every later create, not only one the plan refuses, is merged into its
plan when it agrees, keeping the plan's rule order.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): keep clock spellings, linked ids and word boundaries honest
- A later update's clocks take the plan's spelling, sibling-trimmed
clocks included, wherever the call's values fit it, so an update whose
later call trims only one of two clocks is served.
- A held value that ends its fraction in a zero keeps a create's clock
at a fixed width, whatever order the held spellings come in.
- A large integer that another field of the created entity also holds
is linked, as a repeated token is: it is never unique, and the create
is withheld as unexplained-change.
- Composed parts are delimited by whole characters through the shared
joinsBefore/joinsAfter, so an astral letter joins a match to its word.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(environment): keep a same-width fixed clock out of trimming
A held .123456780 beside a held .12345678 keeps the create's clock at
nine digits in either order; the cases fail on 8ce1127b5.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(environment): record the create-calls verification
Gates, tests, write plans over 69 recorded captures and served write tasks on the derived-values baseline and on this branch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(environment): note the create-calls census on current main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): check composed parts and clock fits before they shape a plan
- A composed string whose part has nothing to spell refuses the create
or update through the apply Result instead of keeping a stale value.
- A composition learned on a later call is rebuilt from each earlier
call's fields, so an earlier call that left the value otherwise
contradicts it.
- An update adopts its fitted clock spellings only after the
unknown-entity and contradiction checks, so a call on an entity an
unmodelled write left unknown no longer trims a served plan's clock.
- Sibling trimming on a later create also checks that call's fields,
so a sibling clock it shows with a trailing zero stays fixed-width.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(environment): split the derived-values view tests by rule
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): keep fixed-width, zone and generated-integer evidence through a plan
- A plan keeps the first value that showed each clock at a fixed width,
from held entities and every accepted create or update. No later fit
or sibling trim widens that field, and a later call that shows it
shorter is withheld.
- Two stamped clocks are one composed source only when they share a
zone offset. A held entity that holds the target field as anything
the parts do not spell, a non-string included, vetoes the
composition. The token index is built once, and only when a string is
left to compose.
- A generated integer that another field repeats as a number at any
depth is linked. Compare treats two integers of one JSON kind and
length under a unique rule as generated values.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): keep optional references across create calls and in answers
- A reference a later create added stays while further creates leave its
member out, and agrees with a later call that shows it again.
- A create's answer shows optional reference and echo fields only when the
call wrote them, whichever recorded call the answer came from.
- An update that re-sends the id of the entity a field already repeats keeps
the plan's reference instead of pinning the member.
- An id naming entities of one type in several collections teaches no
reference, and serving or replay refuses a call that names one.
- An echo is not promoted to sorted once a call stored its strings out of
code-point order as sent.
- Nested view keys compare as sets and keep the first recording's order.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(setup): share registration prewarm host preparation
* fix(environment): retain write evidence and match trimmed response clocks
* fix(environment): withhold creates whose generated integer another string repeats
A unique integer whose digits show in another top-level string that no
template or composed rule rebuilds from it (an echoed serialText, or a
token like folder-<id>) withholds the create as unexplained-change,
since serving would seed the integer and keep the string. A templated
copy is still served.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): respect earlier clock widths and left-out members in later creates
- The create merge widens a clock to trimmed only against the value that
showed its width: a held entity or accepted call at a fixed width keeps it
untrimmed.
- A later create that left out a member the plan fills, with a default or a
reference's absence, while its observation shows that field otherwise is
unexplained-change, so the plan cannot serve the stale value.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): hold later creates and compare to the composed and integer forms
- A later create whose response spells a composed field otherwise than
the plan rebuilds it is withheld as unexplained-change.
- A unique field learned from a generated integer accepts only an
integer of the same length and kind from a later create, as serving
seeds one.
- Compare counts a composed field as equal when each side's response
spells it from its own fields, which compare under their own rules.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): preserve composed parts and unique generator families
* fix(environment): reject contradictory create references
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
@@ -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}`). |
|
||||
|
||||
@@ -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.**
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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<Parameters<typeof mergedCreatePlan>[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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, ClockFormat> } {
|
||||
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",
|
||||
|
||||
@@ -120,6 +120,8 @@ interface EntityViewPlan {
|
||||
readonly clock_formats?: Readonly<Record<string, ClockFormat>> | 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<Record<string, string[]>> | 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<string>,
|
||||
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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, string[]> } {
|
||||
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<string, ClockFormat>;
|
||||
readonly nested_fields?: Record<string, string[]>;
|
||||
} {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<JsonValue | undefined> => {
|
||||
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<JsonObject> => {
|
||||
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<JsonObject> => {
|
||||
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<StateWritePlan, "effect" | "rules" | "body">,
|
||||
): 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<JsonValue | undefined> => {
|
||||
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]!]),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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<StateWritePlan, "body" | "rules">,
|
||||
explained: ReturnType<typeof explainUpdate>,
|
||||
kept: ReadonlySet<string>,
|
||||
): 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;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<FieldRule, { rule: "reference" }>[];
|
||||
}
|
||||
|
||||
/** 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]! };
|
||||
|
||||
@@ -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]!)
|
||||
|
||||
@@ -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<StateEntity, "type" | "collection" | "fields">,
|
||||
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();
|
||||
|
||||
@@ -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<string>;
|
||||
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<string>;
|
||||
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<FieldRule, { rule: "reference" }>,
|
||||
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<string, FieldRule>();
|
||||
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)],
|
||||
};
|
||||
};
|
||||
@@ -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<string>;
|
||||
/** Unsent clocks a call left as they were in its own second, which it cannot show it stamps. */
|
||||
readonly unstamped: Set<string>;
|
||||
/** Members a call stored as sent with strings out of code-point order, which no plan may store sorted. */
|
||||
readonly kept: Set<string>;
|
||||
/** 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<string, JsonValue>;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string>,
|
||||
): Set<string> => {
|
||||
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),
|
||||
|
||||
@@ -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<FieldRule, { rule: "reference" }> = {
|
||||
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 },
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<FieldRule, { rule: "reference" }>;
|
||||
|
||||
/** 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<string, { member: string; type: string }>();
|
||||
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<JsonValue | undefined, string> => {
|
||||
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]!]] : [],
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -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<string> = 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
+224
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user