mirror of
https://github.com/morluto/rea
synced 2026-10-28 15:07:26 -04:00
feat(environment): serve the writes that later reads explain (#7274)
* feat(environment): serve the writes callers make without recorded form fields
- A create member a recording sent empty ("", null, false, [] or {}) is no
longer required; a call that leaves it out is applied as sending that value
(body.blank).
- List views render envelope members that list the listed entities' ids
(Paperless's `all`), so creates in such collections are confirmed.
- Updates move their entity in collections kept sorted by a field they write,
and a failed whole-collection read after an update can teach that order.
- A later create that stores an unruled field otherwise is withheld at the
create, and an item read may fill values a create's answer had not loaded.
- An unsent field respelled between null, [] and {} is unchanged.
- The skill asks for one create with every field and one with only what a
person must give.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(skill): show how to read signed-in pages before recording
Agents guessed capture keys (artifacts, dom, final) to see pages behind
sign-in, which inspect_web_page cannot reach.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): accept every empty spelling recorded creates sent
A create that fills only what a person must give sends `null` where the
full create sent a string, and `null` or `false` for the same flag. The
merged plan now accepts each recorded empty value, and a call that leaves
the member out is applied as the earliest call sent it.
Also adds the spec folder for this change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(skill): keep the vary-fields step neutral on UI or API writes
Agents read "the form" as a cue to write through the UI, where some
applications' request bodies are not exposed; a body-unusable UI write
now re-records once with request steps.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(environment): record verification for blank members and sorted writes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): address review of blank members and sorted writes
- Bulk updates move their entities in turn against the others' earlier
values and install the final order once, as replay does.
- A member's recorded empty values stay accepted once a later call teaches
its default; a left-out member with a default keeps the default.
- Write answers spell empty lists and maps as their recorded answers did.
- A rename can teach a sort when the creates chose one by a field the
update did not write, such as ids that ascend with titles.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(environment): serve the writes that later reads explain
- An item read whose object is marked deleted (deleted, isDeleted or
is_deleted true) agrees with an absent entity, as EspoCRM answers a
deleted record; such an object never seeds or adopts an entity.
- maxSize, max_results and maxResults are page sizes, for whole-collection
list lines and the list query's limit role.
- An item view also shows the fields only reads after its type's first write
show, when no initial entity holds them (Vikunja's task subscription),
without requiring them; write answers leave such a field out for entities
without it, and it no longer withholds the write as incomplete-entities.
- The skill asks updates to change the fields a person would change, and
expects a deleted object to read back as 404 or marked deleted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): answer sent empty members as sent, and keep a placed create's order
- A write answer spells an empty list or map the call did not send as its
recorded answer did; one the call sent is answered as sent, so a create
sending `[]` is not answered `null` because the first recorded create was.
- A read listing the whole collection that placed a create leaves the create
only the sorted inserts whose order that read follows, so a later rename
cannot teach a sort an earlier read ruled out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): answer bulk updates without fields only later reads show
A bulk update's answer renders each entity with the same allowance a single
update's answer has: a field only reads after the type's first write showed,
which no initial entity holds, is left out for an entity without it, so a
bulk update of a seeded task is no longer refused for lacking it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(environment): correct how the EspoCRM contact update task passes
The agent trajectories show gpt-oss passes it by creating a replacement contact
and deleting the original, since the served update refuses emailAddress; the
update itself is refused in both builds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): answer a read of a served-deleted entity marked deleted
An application that soft-deletes still answers a deleted record by id, marked
deleted. Serving now keeps an entity a served delete removed from a type whose
recorded item reads show that, until a reset or load and within the size
data/add loads, and answers a read of it with its last fields and the flag
true, instead of refusing the read.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): keep unloaded create answers, check bulk answers with sent members, narrow inserts only on collection-order reads
- A create whose item read loaded values its answer had not records them
(`observation.read.unloaded`), and a served create's answer keeps them as
the recorded answer showed them, a zero-time owner included, while the state
holds the read's.
- Replay checks a bulk update's recorded answers with each target's sent
members, as serving answers them, so echoed `null` and `[]` no longer make
the plan body-unusable.
- A whole-collection read whose query sorts it shows the query's order, so it
no longer narrows a create's sorted insert candidates.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(environment): record the census with main's unknown-clock rule merged
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): learn a sort from a rename only when moving its entities reaches it
Serving moves only the entities a write changes in a collection kept sorted,
so replay now tries a sort after a rename only when the entities no write the
read exercised changed already follow it. Seeded folders listed out of title
order no longer teach a title sort that serving would answer with another
order.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): keep unloaded create answers a sent value did not echo, and learn no sort from query-sorted reads
- A served create keeps a value its recorded answer had not loaded unless the
call sent that member and the plan stores it as sent, so a create sending
`active: false` is answered `false` as recorded while the state holds the
read's `true`.
- A rename no longer teaches a sort from a whole-collection read its query
sorts, whose ties need not follow the collection's own order.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): learn the soft-delete flag in the state view and serve by it
Extraction now records the member later item reads set true on a record the
application soft-deleted, and the item view carries it as `deleted_flag`.
Serving reads that fact instead of scanning recorded bodies itself, so one
owner decides what a soft delete looks like.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): learn the soft-delete flag from item reads with a query too
A client may read a deleted record back only with a query, such as a
cache-busting parameter. Every successful read of the item tool now shows the
flag; only plain later reads still add fields.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): learn sorts only from orders a list read confirmed
- A sort retry after a failed whole-collection read requires the entities no
write changed since a list last showed them to follow it already, rather than
excusing every entity the update's tool ever wrote: an earlier rename a list
confirmed in place stays where it was.
- An update takes its creates' sort only from a create a list read showed
placed; replay still tries a sort a create guessed from the initial order.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): drop entities read back as deleted and answer soft deletes as deleted
- A held entity a later read shows marked deleted is dropped, not adopted with
the marked object's fields.
- Bulk-update replay renders answers with the optional and later-only fields
serving uses, so a bulk update of created and seeded entities replays.
- A served delete of a soft-deleting type answers with the entity marked
deleted, single and bulk.
- A deleted flag only query reads showed is a field of the item view, so a
served tombstone keeps it where live records omit it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): move updated entities only by placed sorts and keep query-sorted reads from confirming order
Replay moved an updated entity by a sort a create only guessed, while the
update plan exported only placed sorts, so a read could confirm an update
by a move serving would not make. Both now use the placed sort. A passing
list read clears its entities from the moved set only when the state
decides the order of every entity it lists, so a read whose query sorts
it no longer hides an earlier move from a later sort check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): try a learned sort by moving each pending write in turn, as serving does
#retrySort sorted the whole collection, which a list could match while
serving, moving each renamed entity in turn against the others' values
before their own writes, gave another order. The state now records the
values each write replaced, and the retry replays the writes since a
list in collection order last showed their entities.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): answer a served delete with the deleted flag its recorded answer showed
An application may answer a delete with the record as it was and mark it
deleted only on later reads. Delete answers, single and bulk, now take
the flag's value from the recorded answer; reads of a served-deleted
entity still show it marked deleted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(environment): set aside two hunks next to lines main's saved-values change rewrites
Main's #7267 changes the lines beside the blank-member docs and the
empty-spelling check in explainUpdate. Setting them aside lets that merge
apply cleanly; the commit after it puts them back on top of main's text.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(environment): restore the set-aside hunks on main and bump the skill to 51
Puts the blank-member docs and the empty-spelling check back beside main's
slug and composed text and heldSlugRule. Main is at skill 50 with
different text, so this branch's skill changes take 51.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(environment): bump the skill to 52 above #7260's 51
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): keep sorts a list showed in place ruled out, and created answers' nested members as recorded
A whole list in collection order that showed a renamed entity where a
sort by the renamed field would have moved it now rules that sort out, so
a later rename of the same entity cannot teach it. A create's answer
leaves out members its item read added inside an object the answer
showed, as the recorded answer did; the state still stores them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): compare soft-deleted read fields
* fix(environment): preserve initially deleted item observations
* fix(environment): index stored deleted entities separately
---------
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:
@@ -2,7 +2,7 @@
|
||||
name: reverse-engineer-anything
|
||||
description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA, and build agent environments (AgentEnv bundles) of web applications from a live instance or a capture. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
|
||||
metadata:
|
||||
version: "50"
|
||||
version: "52"
|
||||
---
|
||||
|
||||
# REA
|
||||
|
||||
@@ -23,6 +23,14 @@ again if a hint names a gap, then writes the bundle with
|
||||
- Map the application first with `inspect_web_page` (and `capture_web_screenshot`
|
||||
when layout matters): the login form's locators, and the API calls behind the
|
||||
pages that list and edit the entities the user asked about.
|
||||
- `inspect_web_page` cannot sign in. To see pages behind sign-in, run a short
|
||||
`capture_browser_scenario` that signs in and opens them, with
|
||||
`"capture": {"after_each_step": ["accessibility"]}` (`"dom"` for markup).
|
||||
Read a step's tree with `inspect_analysis_view`, `view: {kind:
|
||||
"browser-scenario", collection: "text", step_index, artifact:
|
||||
"accessibility", offset: 0, limit: 2048}`, paging with `offset`. Capture
|
||||
options are only `after_each_step`, `at_end`, `events`, `network` and
|
||||
`limits`.
|
||||
|
||||
## Record a session an environment can learn writes from
|
||||
|
||||
@@ -145,8 +153,17 @@ client with it. Match `wait_for` locators to exactly one element.
|
||||
read 2, list; never several writes before their reads.
|
||||
4. Leave more than a second between a create and its first edit
|
||||
(`wait_for_timeout` with `duration_ms: 1100`).
|
||||
5. Update both objects and read each back; delete both and read each back
|
||||
(expect 404) and the list.
|
||||
5. Update both objects and read each back, changing the fields a person
|
||||
would change (a name, an email address, a status, a due date) rather than
|
||||
only a description: a served update accepts only the members a recorded
|
||||
update stored. Delete both and read each back (expect 404, or the object
|
||||
marked deleted) and the list.
|
||||
6. Vary what the two creates send, however you write them: the first every
|
||||
field the application takes, the second only what a person must give (its
|
||||
title or name), with the other fields left out or empty. A served create
|
||||
requires each member both creates filled in and lets a caller leave out one
|
||||
a create left out or sent empty, so an agent that names only a title is not
|
||||
refused for fields it had no reason to set.
|
||||
|
||||
About `request` steps:
|
||||
|
||||
@@ -226,7 +243,7 @@ withheld with its hint:
|
||||
| `no-call` | Re-record once with a write that succeeds. |
|
||||
| `side-effect` | Stop: the application changes other entities. |
|
||||
| `contradicted` | Stop: a later read disagrees with the modelled effect. |
|
||||
| `body-unusable` | Stop: the body is not a JSON object. |
|
||||
| `body-unusable` | Re-record a UI write once with `request` steps (its body was not exposed); else stop. |
|
||||
|
||||
Use new scratch names on each attempt, and delete what an earlier attempt
|
||||
created before recording again (through the UI, or a `request` step with the
|
||||
|
||||
@@ -768,7 +768,12 @@ sending a `derived` rule's member to an entity that does not hold the value the
|
||||
recorded call found, a nested clock on an entity holding no such object, and a
|
||||
rendered answer missing a field the recorded response carried, and an update or delete of an entity the state does not hold answers
|
||||
the recorded 400, 404 or 410 of the tool or of an item view of the type, if
|
||||
any. A refused call leaves the state unchanged. Values a write generates are
|
||||
any. A served delete of a type whose item view has a `deleted_flag` (the
|
||||
member, `deleted`, `isDeleted` or `is_deleted`, its reads set `true` on a
|
||||
record the application soft-deleted, as EspoCRM does) keeps the entity until
|
||||
`data/reset` or `data/add`, within the size `data/add` loads, so a read of it by
|
||||
id answers its last fields with that flag `true`. A refused call leaves the
|
||||
state unchanged. Values a write generates are
|
||||
deterministic: the clock starts at `write_clock` and advances one second per
|
||||
served write, sequences take one past both the largest held value and the
|
||||
largest they generated, and UUIDs and tokens derive from the state Evidence
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Implementation plan
|
||||
|
||||
1. **Blank members.** `WriteBody.blank` and its schema; creates explain empty members as blank; merges join them;
|
||||
created fields apply a left-out blank member as sent.
|
||||
2. **Listed ids.** `listed_ids` on list views and its schema; the gate learns them; list rendering sets them.
|
||||
3. **Sorted collections.** The replayed state moves an entity whose sorted field changed; update plans carry the
|
||||
type's sort; serving moves updated entities; a failed whole-collection read after an update can teach the sort.
|
||||
4. **Observations.** A create's item read-back accepts unloaded answer values; updates treat empty-container
|
||||
respellings as unchanged; later creates check unruled fields.
|
||||
5. **Skill, schema, tests and docs.**
|
||||
@@ -0,0 +1,56 @@
|
||||
# Serve the writes callers make without recorded form fields
|
||||
|
||||
Issue: https://github.com/morluto/rea/issues/4933
|
||||
|
||||
## Outcome
|
||||
|
||||
Agents now record writes through an application's UI, and environments built that way refused most of the creates
|
||||
natural-language tasks ask for, or withheld them:
|
||||
|
||||
- A form sends every field it shows, the ones left alone as `""`, `null`, `false`, `[]` or `{}`. Every member a
|
||||
recorded create sent was required, so a call naming only a title was refused (Homebox tags required `color` and
|
||||
`icon`, Linkding bookmarks `notes`, `unread` and `shared`, EspoCRM contacts about twenty members).
|
||||
- Paperless-ngx answers lists with `all`, the ids of the listed entities in list order. The state did not render it,
|
||||
so the first list read after every Paperless create contradicted the create.
|
||||
- Grafana lists folders by title and Paperless lists tags by name. An update kept its entity where it was, so a rename
|
||||
that sorted elsewhere contradicted the update, and a served rename listed in the wrong order.
|
||||
- A Vikunja create answers before it loads what it stored: the owner with empty fields and Go's zero time, and empty
|
||||
lists as `[]` where reads show `null`. The later item read was not taken as the observation, so it contradicted the
|
||||
create; an update whose answer spelled an empty list `[]` was unexplained.
|
||||
|
||||
## Behaviour
|
||||
|
||||
- **Blank members.** A create member a recorded call sent empty is not required. A call that leaves it out is applied
|
||||
as sending that value (`body.blank`), so the entity, the answer and every rule reading the member come out as for
|
||||
the recorded call. A member a later call left out takes the default it showed instead. A call may send a member as
|
||||
any recorded call sent it empty, such as `null` where another stored a string, even once a default is learned, and
|
||||
one leaving it out without a default is applied as the earliest did. Updates are unchanged.
|
||||
- **Listed ids.** A list view names top-level envelope members that list two or more listed entities' identity values
|
||||
in list order (`listed_ids`). A rendered list sets them to its own entities' values, unless it is a page of more
|
||||
matches, which keeps the recorded value as a partial page's total does.
|
||||
- **Sorted collections.** An update that writes the field its type's creates are sorted by moves its entity to where
|
||||
the new value sorts, in replay and serving (a bulk update moving each entity in turn), and its plan carries that
|
||||
`insert`. When a read listing the whole collection in collection order (not sorted by its query) fails after an
|
||||
update and the type's creates are not sorted by a
|
||||
field the update writes, replay tries the collection sorted by such a field when the entities no exercised write
|
||||
changed already follow it, as serving moves only written entities, and unless a create was placed by a sort
|
||||
that excludes it or a whole-collection read in collection order (not sorted by its query) that placed a create did
|
||||
not follow it; on a match, the type's creates and updates take that order.
|
||||
- **Unloaded answers.** A create's later item read is taken as its observation when it differs from the answer only in
|
||||
values the answer had not loaded: empty, zero, `false` or Go's zero time, or objects whose members are each the same
|
||||
or loaded so; the plan points to those values (`observation.read.unloaded`), which a served create's answer keeps as
|
||||
the recorded answer showed them, unless the call sent one the plan stores as sent, while the state holds the read's. An unsent update field that changes only between
|
||||
`null`, `[]` and `{}` is unchanged. Write answers, bulk answers and replay's checks of them included, spell empty
|
||||
lists and maps the call did not send as their recorded answers did, and a sent one as sent.
|
||||
- **Unruled fields.** A later create that stored a field no rule writes otherwise than the plan keeps it is
|
||||
`unexplained-change` at the create, so its recorded entity is held and later reads check other writes as before.
|
||||
- **Skill.** The two scratch creates differ in what they send, the second only what a person must give, however they
|
||||
are made; the skill shows how to read signed-in pages' accessibility trees from a scenario, and re-records once with
|
||||
request steps a UI write whose body the browser did not expose.
|
||||
|
||||
Plans and views that need none of this are unchanged.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- No write tool of the 109 recorded captures goes from served to withheld.
|
||||
- Rebuilt from the same recordings, the branch passes at least the write tasks main passes.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Learn blank create members and apply them when a call leaves them out.
|
||||
- [x] Learn and render list envelope members listing the listed ids.
|
||||
- [x] Move updated entities in sorted collections, in replay and serving, and learn a sort from a rename.
|
||||
- [x] Take item read-backs over unloaded create answers and empty-container respellings.
|
||||
- [x] Withhold later creates that store an unruled field otherwise.
|
||||
- [x] Add view, serving and unit tests.
|
||||
- [x] Document the rules and view member, and update the skill.
|
||||
- [x] Measure the recorded captures and served write tasks.
|
||||
- [x] Run static, formatting, documentation and test gates.
|
||||
@@ -0,0 +1,124 @@
|
||||
# Verification
|
||||
|
||||
Executed on macOS with Node v24.22.1 at `5a3c6762` (main `99062fea`), and on Ubuntu 24.04 VMs with snap Chromium for
|
||||
the agent recordings.
|
||||
|
||||
## Gates
|
||||
|
||||
- `npm run -s check:fast`, `format:check`, `scan:todos`, `docs:check`, `verify:test-discovery`, `mcp-catalog:check` and
|
||||
`jscpd`: passed. `npx knip` reports one unused export, `isToolFamilyId` in `src/contracts/toolFamilies.ts`, which this
|
||||
branch does not touch and main reports too.
|
||||
- `npx vitest run --project domain --project services --project adapters`: 18,281 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 -s test:focused --` the environment build, export and serve CLI boundaries, the analysis-view CLI and MCP
|
||||
boundaries and the environment serve MCP boundary: 7 files, 75 passed. `outputSchemaProfile.test.ts`: 4 passed; the
|
||||
full catalog grows 648 bytes and the compact catalog, 554,640 bytes, does not change.
|
||||
|
||||
## Tests
|
||||
|
||||
- View: form creates whose empty members differ require only the member both filled and apply the others as sent; a
|
||||
create with only a last name, sending `null` for strings and for a flag another create sent `false`, merges and is
|
||||
served; a rename moves a label in a collection its
|
||||
creates sort by name; a rename that sorts first teaches a folder collection's title order to its creates and updates;
|
||||
a list envelope's `all` is learned and creates there are confirmed; a create answer with an unloaded owner and `[]`
|
||||
for `null` takes its item read; an update answer spelling an empty list `[]` is unchanged; a later create storing a
|
||||
derived field otherwise is withheld at the create and later deletes stay confirmed.
|
||||
- Serving: a label created from its name alone stores the empty members as sent and is inserted by name, and one
|
||||
without the required name is refused; renamed labels and folders move to where their new names sort, and a bulk
|
||||
rename of two labels moves each; a created tag's id appears in the list envelope's `all` in list order; a note sent
|
||||
`null` as one recording did is accepted after a later call taught `""` as its default, which a note left out takes;
|
||||
a created and an updated task answer `assignees: []` as their recordings did while reads show `null`.
|
||||
- Review cases: a member's recorded empty value stays accepted once a default is learned; a rename teaches title order
|
||||
where the creates fit both id and title order; moving written entities in turn sorts `[a, b, c, d]` with `b→h` and
|
||||
`c→i` as `[a, d, h, i]`. The plan and serving tests for these fail on the branch's first head `0ceff6fa`.
|
||||
|
||||
## Write plans over recorded captures
|
||||
|
||||
The 109 captures the saved-values branch measured: 50 published captures and 59 Claude Code recorded from a prompt,
|
||||
built by main and by this branch.
|
||||
|
||||
| Captures | Write plans | Served on main | This branch |
|
||||
| ------------------------- | ----------- | -------------- | ----------- |
|
||||
| 50 published | 187 | 70 | 70 |
|
||||
| 59 recorded from a prompt | 337 | 177 | 209 |
|
||||
|
||||
No write goes from served to withheld. The 32 newly served are 23 Paperless creates (tags, correspondents and document
|
||||
types, every recording), 3 Vikunja label creates, a Vikunja task update, Grafana's folder rename and delete, a Homebox
|
||||
tag delete, and a Linkding bundle update and delete. 65 plans require fewer members and 66 carry `body.blank`; 30 plans
|
||||
change `insert`, mostly updates that now move their entity by the field their type's creates sort by.
|
||||
|
||||
## Agent-built environments
|
||||
|
||||
Claude Code (`claude-sonnet-5-5`) built an environment of each of seven applications from the same prompt, as the
|
||||
saved-values branch measured, and each bundle's write tasks ran with `fireworks_ai/gpt-oss-120b` and
|
||||
`fireworks_ai/glm-5p3-flash`. Each task checks its result from the environment's state, including that nothing else
|
||||
changed.
|
||||
|
||||
The Homebox and Linkding task specs used until now checked for seed objects an earlier round's recordings left behind
|
||||
("Rec Fragile", `scratch-two.example.test`), which no current recording holds, so their "nothing else changed" check
|
||||
could not pass. They now check the current seed. With the corrected specs, main passes both Homebox and both Linkding
|
||||
tasks on the saved-values branch's recordings, so earlier rounds' 0/2 for those applications measured the specs, not
|
||||
the required members.
|
||||
|
||||
**The same recordings, built by main and by this branch** (gpt-oss, GLM):
|
||||
|
||||
| Recording | Write tasks | Main | This branch |
|
||||
| ----------------------------- | ----------- | --------- | ----------- |
|
||||
| Paperless, saved-values | 3 | 1/3 2/3 | 3/3 3/3 |
|
||||
| Paperless, main (two) | 3 + 3 | 3/6 3/6 | 5/6 5/6 |
|
||||
| Grafana, saved-values | 2 | 0/2 0/2 | 1/2 1/2 |
|
||||
| Grafana, main (two) | 2 + 2 | 3/4 3/4 | 4/4 3/4 |
|
||||
| Vikunja, saved-values + main | 5 + 5 | 3/10 3/10 | 3/10 3/10 |
|
||||
| EspoCRM, saved-values + main | 3 + 3 | 1/6 1/6 | 1/6 1/6 |
|
||||
| Homebox, saved-values + main | 2 + 2 | 2/4 2/4 | 2/4 2/4 |
|
||||
| Linkding, saved-values + main | 2 + 2 | 2/4 2/4 | 2/4 2/4 |
|
||||
| Miniflux, saved-values | 3 | 3/3 3/3 | 3/3 3/3 |
|
||||
| Total | 42 | 18 19 | 24 23 |
|
||||
|
||||
No recording passes fewer tasks with this branch. Paperless creates pass once served. The saved-values Grafana
|
||||
recording's rename is served and passes; its create still needs the saved-values branch's composed address.
|
||||
Vikunja's task create, EspoCRM's leftover contact (held as seeded data, so every "nothing else changed" check fails)
|
||||
and the main Homebox recording, which wrote no tags, are unchanged.
|
||||
|
||||
**Fresh recordings.** Each application was recorded afresh with main (skill 49) and with this branch. The branch was
|
||||
recorded again for the four applications whose first branch recording passed fewer tasks, and a third time after the
|
||||
skill's sixth step was reworded; the final skill's recordings are the last column (served writes, then tasks):
|
||||
|
||||
| Application | Write tasks | Main | Branch, first two recordings | Branch, final skill |
|
||||
| ----------- | ----------- | -------------------- | ---------------------------- | -------------------- |
|
||||
| Paperless | 3 | 2, 1/3 2/3 | 8, 1/3 2/3 | |
|
||||
| Linkding | 2 | 0, 0/2 0/2 | 5, 2/2 2/2 | |
|
||||
| Miniflux | 3 | no bundle | 4, 3/3 3/3 | |
|
||||
| Grafana | 2 | 3, 2/2 2/2 | 1, 0/2 1/2; 3, 1/2 2/2 | 0, 0/2 0/2 |
|
||||
| Vikunja | 3 + 2 | 3, 1/3 1/3 + 2/2 2/2 | 0, 0/5 0/5; 0, 0/5 0/5 | 1, 1/3 1/3 + 0/2 0/2 |
|
||||
| EspoCRM | 3 | 2, 1/3 1/3 | 2, 1/3 1/3; 2, 1/3 1/3 | 2, 1/3 1/3 |
|
||||
| Homebox | 2 | 4, 2/2 2/2 | 1, 0/2 0/2; 1, 0/2 0/2 | 3, 2/2 2/2 |
|
||||
|
||||
Every fresh recording builds the same plans with main and with this branch, except Paperless (main serves 0 of the
|
||||
branch recording's writes and 2 of 3 of its own), Vikunja's main recording (3 to 4) and the EspoCRM recordings below;
|
||||
the per-application differences are what each agent recorded:
|
||||
|
||||
- **Paperless:** the branch recording serves creates, renames and deletes of tags, correspondents and document types.
|
||||
Its two tag creates used a light and a dark color, whose derived `text_color` differs, so the tag create stays
|
||||
withheld and the tasks, which create a tag, score as on main.
|
||||
- **Linkding:** main's agent recorded no writes. The branch's second bookmark create sent only `url` and `title`, so
|
||||
the served create requires only those.
|
||||
- **Miniflux:** main's agent stopped and asked for an API key. The branch's agent created a token in the UI and sent it
|
||||
as a literal header value, which REA recorded as written; the instance and its token were destroyed afterwards.
|
||||
- **EspoCRM:** following the sixth step, the branch's second contact sent only a last name, with `null` for strings the
|
||||
first stored and for flags it sent `false`. That merge was refused until the empty-spelling fix in this PR; with it,
|
||||
the create requires only `lastName` and `name` and its task passes, as on main.
|
||||
- **Vikunja:** the first two branch agents wrote through the UI, whose request bodies Chromium did not expose, so every
|
||||
write was `body-unusable`. The sixth step then read "fill every field the form offers"; reworded to say how the
|
||||
creates differ, not where they are made, the third agent wrote with request steps. It recorded no label writes.
|
||||
- **Homebox:** the first two branch agents recorded item writes only, the third tags as main's did.
|
||||
- **Grafana:** the branch agents created folders with request steps and their own uids. The second create's address
|
||||
needs the saved-values branch's composed-address fixes to be explained, so the create is withheld here.
|
||||
|
||||
## Not verified
|
||||
|
||||
- One to three fresh recordings per application and version; agent runs vary more than the builds do.
|
||||
- Request bodies Chromium does not expose for some UI writes (Vikunja's); the skill now re-records such a write once
|
||||
with request steps.
|
||||
- Paperless derives `text_color` from `color`, so a create whose two recorded colors differ in brightness stays
|
||||
withheld; EspoCRM's create still requires `name` and `emailAddressData`, which it derives from other members.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Implementation plan
|
||||
|
||||
1. **Soft deletes.** `markedDeleted` reads the deleted flags; item-read checks, adoption and replay's resync treat a
|
||||
marked object as absent.
|
||||
2. **Page sizes.** Add the names to the paging and query `limit` sets.
|
||||
3. **Later fields.** Extraction collects the members later plain item reads show; the gate adds those no merged entity
|
||||
holds to the item view without requiring them.
|
||||
4. **Answers.** `laterItemFields` names the item view's fields no initial entity holds; write answers and the verdict's
|
||||
`incomplete-entities` check skip them for entities without them.
|
||||
5. **Skill, tests and docs.**
|
||||
@@ -0,0 +1,42 @@
|
||||
# Serve the writes that later reads explain
|
||||
|
||||
Issue: https://github.com/morluto/rea/issues/4933
|
||||
|
||||
## Outcome
|
||||
|
||||
Environments built from a prompt withheld some writes in every recording of an application, though the recordings read
|
||||
each write back:
|
||||
|
||||
- EspoCRM keeps a deleted record. A read of it answers `200` with the record and `deleted: true`, which replay took
|
||||
as the record still being there, so contact and task deletes were contradicted or left unconfirmed.
|
||||
- EspoCRM pages its lists with `maxSize`, which REA did not know as a page size, so a list read after a delete was not
|
||||
taken as listing the whole collection and confirmed nothing.
|
||||
- Vikunja shows `subscription` only on tasks created after the recording started. The tasks' item view did not show
|
||||
it, so reads of created tasks disagreed with the state and contradicted the task update before them; and an update
|
||||
whose recorded answer shows it could not answer for a seeded task, which lacks it.
|
||||
|
||||
## Behaviour
|
||||
|
||||
- **Soft deletes.** An item read whose object is marked deleted (`deleted`, `isDeleted` or `is_deleted` set to `true`)
|
||||
agrees with an entity the state does not hold, as a `404` does. Replay drops the entity such a read names, and such
|
||||
an object never seeds or adopts an entity. The item view records the flag its reads set as `deleted_flag`, and
|
||||
serving keeps an entity a served delete removed from such a type until a reset or load, so a read of it by id answers
|
||||
its last fields with the flag `true`, as the application does.
|
||||
- **Page sizes.** `maxSize`, `max_results` and `maxResults` are page sizes, for a list read's whole-collection
|
||||
membership and for a list query's `limit` role.
|
||||
- **Fields only later reads show.** An item view also shows the members that reads after its type's first write show
|
||||
of the entities they name, when no initial entity holds them. They are never required. An update or delete that
|
||||
answers with the entity, a bulk update's answer included, leaves such a field out when the entity does not hold it,
|
||||
and the field no longer makes the write `incomplete-entities`. A field some initial entity holds keeps the existing rule: an answer showing it is
|
||||
refused for an entity without it.
|
||||
- **Skill.** Updates change the fields a person would change (a name, an email address, a status, a due date) rather
|
||||
than only a description, since a served update accepts only the members a recorded update stored, and a deleted
|
||||
object may read back marked deleted instead of `404`.
|
||||
|
||||
Plans and views that need none of this are unchanged.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- No write tool of the 130 recorded captures goes from served to withheld, and no plan's rules, body or `insert`
|
||||
changes.
|
||||
- Rebuilt from the same recordings, the branch passes at least the write tasks #7260 passes.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Treat item reads of objects marked deleted as absent in checks, adoption and replay.
|
||||
- [x] Answer a read of a served-deleted entity of such a type marked deleted, until a reset or load.
|
||||
- [x] Learn `maxSize`, `max_results` and `maxResults` as page sizes.
|
||||
- [x] Add fields only later item reads show to item views, unrequired.
|
||||
- [x] Leave those fields out of write answers for entities without them, and stop them withholding updates.
|
||||
- [x] Add view, serving and unit tests.
|
||||
- [x] Document the rules and update the skill.
|
||||
- [x] Measure the recorded captures and served write tasks.
|
||||
- [x] Run static, formatting, documentation and test gates.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Verification
|
||||
|
||||
Executed on macOS with Node v24.22.1 on this branch, built on #7260's head `053264bd` (main `662ec2e4`), and on
|
||||
Ubuntu 24.04 VMs for the task runs.
|
||||
|
||||
## Gates
|
||||
|
||||
- `npm run -s check:fast`, `scan:todos`, `docs:check`, `verify:test-discovery`, `mcp-catalog:check` and `jscpd`:
|
||||
passed.
|
||||
- `npx oxfmt --check .` reports `scripts/verify-apk-signature.mjs` only, which this branch does not touch and
|
||||
`053264bd` reports too. `npx knip` reports `isToolFamilyId` in `src/contracts/toolFamilies.ts` only, as main does.
|
||||
- `npx vitest run --project domain --project services --project adapters`: 18,420 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 -s test:focused --` the environment build, export and serve CLI boundaries, the analysis-view CLI and MCP
|
||||
boundaries and the environment serve MCP boundary: 7 files, 75 passed.
|
||||
|
||||
## Tests
|
||||
|
||||
- View: a contact delete whose item read answers the record with `deleted: true` is served, confirmed by that read and
|
||||
the list; only a `true` `deleted`, `isDeleted` or `is_deleted` marks an object deleted; a delete is confirmed by a
|
||||
list read paged with `maxSize`; a field only created tasks show joins the task item view without being required; a
|
||||
task update whose answers show it is served.
|
||||
- Serving: a deleted contact leaves the served list; an update of a seeded task, single or bulk, answers without the
|
||||
field only created tasks show (the bulk case fails on `4515a000`, before the review fix).
|
||||
- The six behaviour tests fail on `053264bd`.
|
||||
- The existing serving test that refuses to give chapter rows to a book the state holds without them still passes. A
|
||||
first version of the answer rule left out every field the item view does not require, which served that update; the
|
||||
rule now covers only fields no initial entity holds.
|
||||
|
||||
## Write plans and views over recorded captures
|
||||
|
||||
The 109 captures #7260 measured and #7260's 21 fresh recordings, built by `053264bd` and by this branch:
|
||||
|
||||
| Captures | Write plans | Served on #7260 | This branch |
|
||||
| ------------------------- | ----------- | --------------- | ----------- |
|
||||
| 50 published | 187 | 70 | 71 |
|
||||
| 80 recorded from a prompt | 428 | 258 | 268 |
|
||||
|
||||
- No write goes from served to withheld, and no plan's rules, body or `insert` changes. The 11 newly served are
|
||||
EspoCRM contact deletes in seven recordings, the published EspoCRM capture's task delete, and Vikunja task updates
|
||||
in three recordings.
|
||||
- With main `c6893d1a` merged, whose `unknown-clock` withholds clock writes without an anchor on both builds, #7260
|
||||
serves 322 and this branch 332. One of the Vikunja task updates is among the writes it withholds, so this branch
|
||||
gains 10, and none goes from served to withheld.
|
||||
- 73 list views learn a page size they held as `inert`, valid only for its recorded values, as `limit`: 72 EspoCRM
|
||||
lists (`maxSize`) and one Umami list (`maxResults`). One EspoCRM stream list now counts a line paged only by
|
||||
`maxSize` as listing the whole collection and explains its other line's query; no view explains fewer lines.
|
||||
- Six item views gain a field only later reads show, never required: Vikunja's tasks `subscription` in four recordings,
|
||||
a Homebox entity's `children` and Mattermost's posts `has_reactions`. Every other view is unchanged.
|
||||
|
||||
## The same recordings, built by #7260 and by this branch
|
||||
|
||||
Claude Code (`claude-sonnet-5-5`) recordings from earlier rounds, rebuilt by each version, their write tasks run with
|
||||
`fireworks_ai/gpt-oss-120b` then `fireworks_ai/glm-5p3-flash`, each checked from the environment's state:
|
||||
|
||||
| Recording | Write tasks | #7260 | This branch |
|
||||
| ---------------------------------------------- | ----------- | ----------- | ----------- |
|
||||
| EspoCRM: main, #7260 (three), saved-values | 5 × 3 | 5/15 5/15 | 15/15 10/15 |
|
||||
| EspoCRM: saved-values, leftover contact | 3 | 0/3 0/3 | 0/3 0/3 |
|
||||
| Vikunja: main, #7260 final, saved-values (two) | 4 × (3 + 2) | 8/20 8/20 | 8/20 8/20 |
|
||||
| Homebox: main | 2 | 2/2 2/2 | 2/2 2/2 |
|
||||
| Total | 40 | 15/40 15/40 | 25/40 20/40 |
|
||||
|
||||
No recording passes fewer tasks with this branch.
|
||||
|
||||
- **EspoCRM:** the contact delete passes with both models on all five recordings. The contact update passes with
|
||||
gpt-oss on all five only by replacing the contact: the served update refuses `emailAddress`, which no recorded update
|
||||
stored, so the agent created a contact with the new address and deleted the old one, which the task's check, by name
|
||||
and address, accepts. The update itself is refused in both builds, as is a read of a seeded contact by id, since the
|
||||
state knows seeded contacts only from list reads, which lack fields the item reads show. GLM's update fails in both
|
||||
builds the same way, and it often ends a turn at the eval agent's 1,024-token completion limit.
|
||||
- **EspoCRM, leftover contact:** the recording started with a contact an earlier attempt left, held as seeded data, so
|
||||
every "nothing else changed" check fails in both builds.
|
||||
- **Vikunja:** the newly served task update accepts the members the recorded update stored (`title`, `description`,
|
||||
`priority`), so the task asking to mark a task done is refused in both builds. The skill now asks for updates that
|
||||
change a status, an email address or a due date. Task creates are unchanged.
|
||||
- **Homebox:** the entity view's new `children` field changes no task.
|
||||
|
||||
## Not verified
|
||||
|
||||
- An application answering `deleted: true` for a record it still lists, as a trash view might: such a read counts as
|
||||
absent. No recorded capture does this.
|
||||
- The skill's update wording, which no fresh recording has used yet.
|
||||
@@ -670,12 +670,12 @@ Every entity cites its `record_ordinals`.
|
||||
|
||||
`views` lists one plan per read tool in tool order:
|
||||
|
||||
| Shape | Plan |
|
||||
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `item` | The entity object at `pointer` (the root, one member, or `/0` or `/<member>/0`), the ordered `fields` the view shows and the `required_fields` every recording had, and `envelope_ordinal`: the recording whose response, with the entity replaced, every item response shares, leaving out the `per_response_members` each response there mints: a top-level UUID or clock that differed on every repeat of every concrete path the view answers, with no write between, and never stayed the same on a repeat elsewhere; list views name theirs too |
|
||||
| `list` | The array at `pointer`, its `fields`, `membership` (`all`: every entity of the collection in state order, when each recording listed exactly that, had no query or only paging parameters (`limit`, `offset`, `page`, `per_page`, `perpage`, `page_size`, `pagesize`, `size`, `start`, `_start`, `_end`), no item-total member (`total`, `count`, `totalCount`, `item_count`, `totalcount`, `itemcount`, `numberOfElements`, but not `total_pages` or `per_page`; a number or a string of digits; top-level, or in a top-level object beside the list such as `pageInfo.totalRows`) differing from the number listed, and no `has_more`, `next` or next-page token member set; `recorded`: the entities each recording listed, except on the lines `all_record_ordinals` names, which each meet those conditions on their own and list every entity), and `counters` that equal the number listed |
|
||||
| `document` | The latest initial value of a list tool whose responses hold no identified list, when its initial reads share one request line; held in `documents` with that `path`, whether or not the capture writes it, unless its value changed across any recorded write between those reads (`fields-differ`); its `per_response_members` are the members of the value each response on that path mints |
|
||||
| `recorded` | A `reason` the tool replays its recordings instead |
|
||||
| Shape | Plan |
|
||||
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `item` | The entity object at `pointer` (the root, one member, or `/0` or `/<member>/0`), the ordered `fields` the view shows and the `required_fields` every recording had (the fields include those only reads after the type's first write show, of entities that hold its identity, when no initial entity holds them, as Vikunja adds `subscription` to the tasks it creates; they are never required), and `envelope_ordinal`: the recording whose response, with the entity replaced, every item response shares, leaving out the `per_response_members` each response there mints: a top-level UUID or clock that differed on every repeat of every concrete path the view answers, with no write between, and never stayed the same on a repeat elsewhere; list views name theirs too |
|
||||
| `list` | The array at `pointer`, its `fields`, `membership` (`all`: every entity of the collection in state order, when each recording listed exactly that, had no query or only paging parameters (`limit`, `offset`, `page`, `per_page`, `perpage`, `page_size`, `pagesize`, `maxsize`, `max_results`, `maxresults`, `size`, `start`, `_start`, `_end`, ignoring case), no item-total member (`total`, `count`, `totalCount`, `item_count`, `totalcount`, `itemcount`, `numberOfElements`, but not `total_pages` or `per_page`; a number or a string of digits; top-level, or in a top-level object beside the list such as `pageInfo.totalRows`) differing from the number listed, and no `has_more`, `next` or next-page token member set; `recorded`: the entities each recording listed, except on the lines `all_record_ordinals` names, which each meet those conditions on their own and list every entity), `counters` that equal the number listed, and `listed_ids`: top-level members that list two or more listed entities' identity values in list order (Paperless's `all`), which a rendered list sets to its own unless it is a page of more matches |
|
||||
| `document` | The latest initial value of a list tool whose responses hold no identified list, when its initial reads share one request line; held in `documents` with that `path`, whether or not the capture writes it, unless its value changed across any recorded write between those reads (`fields-differ`); its `per_response_members` are the members of the value each response on that path mints |
|
||||
| `recorded` | A `reason` the tool replays its recordings instead |
|
||||
|
||||
An item or list plan is kept only when it reproduces the latest initial
|
||||
recording of each request line from the state, comparing JSON values without
|
||||
@@ -712,7 +712,7 @@ exactly what it recorded from the collection's initial entities:
|
||||
| `search` | `q`, `query`, `search`, `searchTerm`, `term`, `keyword`(s) | entities with one of `fields` containing the value, ignoring case; the fields are those it matched in the listed items, URLs only where nothing else matched; a term that matched no field leaves the parameter inert until another line's term does |
|
||||
| `sort` | a name asking for an order in any common spelling: `sort`, `_sort`, `sort[]`, `sort[0][field]`, `sorters[...]`, `sortBy`, `sort-column`, `sortKey`, `order`, `orderBy`, `ordering` | ordered by the field the value names (`name`, `-name`, `name:desc`, `name-ASC`): numbers by value, text ignoring case, null last ascending, ties in state order |
|
||||
| `direction` | a direction name (`dir`, `direction`, `sortDir`, `sortOrder`, `sortDirection`, `order_direction`, json-server's `_order`, and `order`) given `asc`, `desc`, `ascending` or `descending`; given anything else, `order` is a `sort` and the others are ordinary parameters | the sort's direction |
|
||||
| `limit` | `limit`, `per_page`, `perPage`, `page_size`, `size`, `count`, `take`, `itemsPerPage` | page size; zero or a negative value lists every match |
|
||||
| `limit` | `limit`, `per_page`, `perPage`, `page_size`, `size`, `count`, `take`, `itemsPerPage`, `maxSize`, `max_results`, `maxResults` | page size; zero or a negative value lists every match |
|
||||
| `offset` | `offset`, `start`, `_start`, `skip` | matches skipped before the page |
|
||||
| `page` | `page` | page number, counted from `first` (1 or 0, whichever explains the line) |
|
||||
| `inert` | any that names no order or direction; never a true `reverse`, or a paging value that is not a safe integer | changes nothing for its recorded `values`; other values are not computed |
|
||||
@@ -749,15 +749,28 @@ 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, a `reference` to an entity, or a `composed` string; other
|
||||
fields keep the observed value. When the first read of a created entity before
|
||||
the next write shows fields the create's answer lacks, or its clocks to another
|
||||
precision, the observed entity takes them (`observation.read`). An item read
|
||||
the next write shows fields the create's answer lacks, its clocks to another
|
||||
precision, or values the answer had not loaded (an empty, zero or `false` value,
|
||||
Go's zero time, or an object whose members are each loaded so), the observed
|
||||
entity takes them (`observation.read`, whose `unloaded` points to the values the
|
||||
answer had not loaded). An item read
|
||||
that agrees with the answer otherwise lends its fields and clock spellings and
|
||||
no longer confirms the create; one that disagrees or omits an answer field its
|
||||
item view renders still checks it. Clock agreement uses the coarser recorded
|
||||
precision, including microseconds. A list read
|
||||
only adds fields the answer lacks and still checks it. Serving answers a create
|
||||
in its answer's shape and clock precision and stores the fields of both with
|
||||
the item read-back's clock precision. A `reference` is an object holding fields of the
|
||||
in its answer's shape and clock precision, with empty lists and maps the call
|
||||
did not send spelled (`null`, `[]` or `{}`) as its answer spelled them and the
|
||||
values it had not loaded as it answered them (unless the call sent one the plan
|
||||
stores as sent), leaving out members the read added inside an object the answer
|
||||
showed, and stores the fields of both,
|
||||
as the item read-back shows them, with its clock precision. Updates
|
||||
answer empty lists and maps they did not send as their answers spelled them
|
||||
too; a member a call sent is answered as sent. An update or delete that answers
|
||||
with the entity leaves out a field only later reads showed, which no initial
|
||||
entity holds, when the entity does not hold it, as Vikunja's reads of tasks it
|
||||
did not create show no `subscription`. 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
|
||||
@@ -770,7 +783,13 @@ 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
|
||||
(`body.defaults`). A member a create sent empty (`""`, `null`, `false`, `[]` or
|
||||
`{}`), as a form sends a field left as it was, is not required: a call that
|
||||
leaves it out is applied as sending that value (`body.blank`), unless a call
|
||||
that left it out showed a default, which it then keeps. A call may send it as any
|
||||
recorded call sent it empty, even after a default is learned, and one leaving it
|
||||
out is applied as the earliest did, as when one create sent `false` and another
|
||||
`null`. A clock rule widens to `trimmed` only when no held entity or
|
||||
earlier call showed that clock at a fixed width, and a slug to spelling `&` as
|
||||
the word (`ampersand: "and"`) only when no call the plan accepted nor entity the
|
||||
type held dropped one. A later
|
||||
@@ -783,7 +802,8 @@ or leaves out is `unexplained-change`. 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.
|
||||
value different from the learned default, or that stored a field no rule writes
|
||||
otherwise than the plan keeps it.
|
||||
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
|
||||
@@ -821,7 +841,8 @@ a newly learned slug. Any other member stored
|
||||
differently is accepted only with its
|
||||
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`
|
||||
sending (an empty list or map one answer spells `null` and another `[]` or `{}`
|
||||
is unchanged) 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 `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
|
||||
@@ -831,7 +852,7 @@ value or set must hold for every call of the tool, and an action is served
|
||||
only once a call found what it sets already set. A write is served only
|
||||
with the query its first call sent, which serving applies to a call that
|
||||
leaves a parameter out and the catalog advertises as required. Every member the first call sent is required, except by `PATCH` and
|
||||
`POST` updates.
|
||||
`POST` updates and members a create sent empty.
|
||||
|
||||
A read on a line `query` answers is checked against the query evaluated over
|
||||
the replayed state. It covers the collection only when every parameter sorts or
|
||||
@@ -846,8 +867,15 @@ collection when the path names no held entity), and taints what it wrote at or
|
||||
above. A read the served environment would answer from the state is compared
|
||||
with its recording as the read plans are, as the whole response it would serve:
|
||||
the entity or list rendered into the envelope of the item view's or the line's
|
||||
base recording, with the members the state still holds. A 400, 404 or 410 (or a
|
||||
`null` body) agrees with an entity the copy does not hold. A match confirms
|
||||
base recording, with the members the state still holds. A 400, 404 or 410, a
|
||||
`null` body, or an object marked deleted (`deleted`, `isDeleted` or `is_deleted`
|
||||
`true`, as EspoCRM answers a read of a deleted record) agrees with an entity the
|
||||
copy does not hold; such a read drops a held entity it names and adopts none,
|
||||
while an initial read keeps the record it shows held, as its item read answers it.
|
||||
The item view records the flag its reads set as `deleted_flag`, a field it shows
|
||||
even when only a query read showed it, which serving answers by: later reads of
|
||||
a served-deleted entity show it marked deleted, and a served delete answers with
|
||||
the flag as its recorded answer showed it. A match confirms
|
||||
the writes that changed its entity or collection since it was last read, less
|
||||
those whose entity a read since showed absent, which no longer count for the
|
||||
collection; a mismatch contradicts them, or, when an unmodelled write came after them, is
|
||||
@@ -855,11 +883,30 @@ checked only on the members they sent; a mismatch no write explains marks the re
|
||||
`side-effect`. A plan is `served` when a read confirmed it and none
|
||||
contradicted it; otherwise its `reason` is the first of its build reason
|
||||
(`no-call`, `body-unusable`, `no-observation`, `unexplained-change`),
|
||||
`contradicted` (with `contradicted_by`), `side-effect`, `unconfirmed` and `incomplete-entities` (an update or delete that answers with the entity, when no initial entity holds every field its recorded answer shows, counting for an update the fields its rules write, so it could answer only for entities the environment created). An otherwise served plan whose learned rules generate clock values is withheld with `unknown-clock` when no global clock start is established; its learned rules and confirming reads remain retained.
|
||||
`contradicted` (with `contradicted_by`), `side-effect`, `unconfirmed` and `incomplete-entities` (an update or delete that answers with the entity, when no initial entity holds every field its recorded answer shows but those only later reads showed, counting for an update the fields its rules write, so it could answer only for entities the environment created). An otherwise served plan whose learned rules generate clock values is withheld with `unknown-clock` when no global clock start is established; its learned rules and confirming reads remain retained.
|
||||
`checked_record_ordinals` lists the confirming reads, `insert` places created
|
||||
entities (sorted by a field the collection's recorded order shows, else last),
|
||||
and `write_clock` is the end of the last timed record, to the next second, when
|
||||
all request completions are known; otherwise it is null.
|
||||
all request completions are known; otherwise it is null. An
|
||||
update that writes the field its type's creates are sorted by moves its entity
|
||||
to where the new value sorts (its own `insert`); a bulk update moves each entity
|
||||
it writes in turn, as replay applies them. When a read listing the whole
|
||||
collection in its own order, not one its query sorts, fails after an update and the type's creates are not sorted by a field
|
||||
that update writes, the view tries the order serving gives when each write since
|
||||
a list in the collection's own order showed its entity moves it in turn by such a
|
||||
field, against the others' values before their own writes, when
|
||||
the entities no write has changed since a list in the collection's own order last
|
||||
showed them already follow it, as serving moves only the entities a write
|
||||
changes, and unless an earlier read listing the whole collection in its own
|
||||
order showed a write that changed such a field where moving it by that order
|
||||
would not have put it, a create was placed by a sort that excludes it, or a read listing the whole
|
||||
collection in its own order, not one its query sorts, that placed a create did
|
||||
not follow it; on a match, the type's creates
|
||||
and updates take that order, as Grafana keeps folders by title though no earlier
|
||||
read could tell that from creation order, or from id order where both ascend.
|
||||
An update moves its entity by its creates' sort only when a list read showed a
|
||||
create placed, in replay as in serving, so no read confirms an update by a sort
|
||||
a create only guessed from the initial order.
|
||||
|
||||
Values in the state are recorded data, so the view refuses the same parents as
|
||||
the manifest, including those inspected with `sensitive_values`, and a
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { hasRepeatedQueryArgument } from "./environmentTools.js";
|
||||
import { stateBodyJson } from "../../domain/analysisView/EnvironmentStateAccumulator.js";
|
||||
import {
|
||||
isObject,
|
||||
ownMember,
|
||||
collectionMatch,
|
||||
collectionPathOf,
|
||||
@@ -241,18 +242,17 @@ const startedWithItem = (
|
||||
);
|
||||
};
|
||||
|
||||
/** The entity an item call names, preferring verbatim collection spellings as replay does; the id is tried percent-decoded, then as written, unless it is literal. */
|
||||
export const namedEntity = (
|
||||
store: EnvironmentStateStore,
|
||||
type: string,
|
||||
/** The first entity `find` gives for a collection an item call names, preferring verbatim spellings as replay does; the id is tried percent-decoded, then as written, unless it is literal. */
|
||||
const callNamed = (
|
||||
collections: readonly string[],
|
||||
call: ItemCall,
|
||||
find: (collection: string, id: string) => StateEntity | undefined,
|
||||
): StateEntity | undefined => {
|
||||
const ids =
|
||||
call.literal === true
|
||||
? [call.id]
|
||||
: [...new Set([decodedSegment(call.id), call.id])];
|
||||
const candidates = store
|
||||
.collectionsOf(type)
|
||||
const candidates = collections
|
||||
.flatMap((collection) => {
|
||||
const verbatim = collectionMatch(collection, call.segments);
|
||||
return verbatim === undefined ? [] : [{ collection, verbatim }];
|
||||
@@ -260,12 +260,77 @@ export const namedEntity = (
|
||||
.sort((left, right) => right.verbatim - left.verbatim);
|
||||
for (const { collection } of candidates)
|
||||
for (const id of ids) {
|
||||
const entity = store.entity(type, collection, id);
|
||||
const entity = find(collection, id);
|
||||
if (entity !== undefined) return entity;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** The entity an item call names. */
|
||||
export const namedEntity = (
|
||||
store: EnvironmentStateStore,
|
||||
type: string,
|
||||
call: ItemCall,
|
||||
): StateEntity | undefined =>
|
||||
callNamed(store.collectionsOf(type), call, (collection, id) =>
|
||||
store.entity(type, collection, id),
|
||||
);
|
||||
|
||||
/** The member a type's item view learned its reads set `true` on an entity the application soft-deleted, if any. */
|
||||
export const softDeleteFlag = (
|
||||
serving: StateServing,
|
||||
type: string,
|
||||
): string | undefined => {
|
||||
for (const view of serving.store.state.views.values())
|
||||
if (
|
||||
view.shape === "item" &&
|
||||
view.type === type &&
|
||||
view.deleted_flag !== undefined
|
||||
)
|
||||
return view.deleted_flag;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Fields as an application that soft-deletes shows them once deleted: with the type's deleted flag set, when it has one. */
|
||||
const deletedFields = (
|
||||
serving: StateServing,
|
||||
type: string,
|
||||
fields: JsonObject,
|
||||
): JsonObject => {
|
||||
const flag = softDeleteFlag(serving, type);
|
||||
return flag === undefined ? fields : { ...fields, [flag]: true };
|
||||
};
|
||||
|
||||
/** Fields as a delete's answer shows them: with the type's deleted flag as its recorded answer `shown` gave it, if it did. */
|
||||
export const deleteAnswerFields = (
|
||||
serving: StateServing,
|
||||
type: string,
|
||||
fields: JsonObject,
|
||||
shown: JsonValue | undefined,
|
||||
): JsonObject => {
|
||||
const flag = softDeleteFlag(serving, type);
|
||||
const value =
|
||||
flag === undefined || !isObject(shown) ? undefined : ownMember(shown, flag);
|
||||
return value === undefined ? fields : { ...fields, [flag!]: value };
|
||||
};
|
||||
|
||||
/** An entity a served delete removed, as an application that soft-deletes answers for it by id: with its deleted flag set. */
|
||||
const deletedEntity = (
|
||||
serving: StateServing,
|
||||
type: string,
|
||||
call: ItemCall,
|
||||
): StateEntity | undefined => {
|
||||
const { store } = serving;
|
||||
const entity = callNamed(
|
||||
store.knownCollectionsOf(type),
|
||||
call,
|
||||
(collection, id) => store.deleted(type, collection, id),
|
||||
);
|
||||
return entity === undefined
|
||||
? undefined
|
||||
: { ...entity, fields: deletedFields(serving, type, entity.fields) };
|
||||
};
|
||||
|
||||
/** The answer for an entity the state does not hold: the tool's latest recorded 400, 404 or 410, else a tool error. */
|
||||
export const missingAnswer = (
|
||||
serving: StateServing,
|
||||
@@ -331,9 +396,14 @@ const answerItem = (
|
||||
const call = itemCall(tool, args);
|
||||
if (call === undefined)
|
||||
return err(refused(tool, `Tool ${tool.name} needs every path argument.`));
|
||||
const entity = namedEntity(store, view.type, call);
|
||||
const held = namedEntity(store, view.type, call);
|
||||
// A projection or another representation of an entity the state holds replays as the capture's reads did.
|
||||
const replayed = replayedCall(source, view, callQuery(tool, args));
|
||||
const entity =
|
||||
held ??
|
||||
(replayed === undefined
|
||||
? deletedEntity(serving, view.type, call)
|
||||
: undefined);
|
||||
if (entity === undefined) {
|
||||
// An item only a replayed line showed, which the state never held, answers with its own recording; a created
|
||||
// item may since have been deleted, so a type a served create added to keeps the missing answer.
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
} from "../../domain/analysisView/environmentStateWriteAddress.js";
|
||||
import {
|
||||
bodyProblem,
|
||||
laterItemFields,
|
||||
optionalFields,
|
||||
updatedEntity,
|
||||
updatedFields,
|
||||
writtenBulkResponse,
|
||||
@@ -21,6 +23,8 @@ import { callSegments } from "./environmentStateCallLines.js";
|
||||
import {
|
||||
namedEntity,
|
||||
refused,
|
||||
deleteAnswerFields,
|
||||
softDeleteFlag,
|
||||
type StateAnswer,
|
||||
type StateServing,
|
||||
} from "./environmentStateAnswer.js";
|
||||
@@ -32,6 +36,7 @@ import {
|
||||
generator,
|
||||
observedEntity,
|
||||
pathValues,
|
||||
sortedOrder,
|
||||
unloadable,
|
||||
heldEntity,
|
||||
updateTarget,
|
||||
@@ -171,7 +176,29 @@ export const answerBulk = (
|
||||
const answer = writeAnswer(
|
||||
serving,
|
||||
request,
|
||||
(recorded) => writtenBulkResponse(plan.response!, recorded, written.value),
|
||||
(recorded) =>
|
||||
writtenBulkResponse(
|
||||
plan.response!,
|
||||
recorded,
|
||||
written.value.map((entity, index) => ({
|
||||
...entity,
|
||||
fields: update
|
||||
? entity.fields
|
||||
: deleteAnswerFields(
|
||||
serving,
|
||||
entity.type,
|
||||
entity.fields,
|
||||
Array.isArray(recorded) ? recorded[0] : undefined,
|
||||
),
|
||||
sent: Object.keys(targets.value[index]!.body ?? {}),
|
||||
})),
|
||||
optionalFields(plan),
|
||||
laterItemFields(
|
||||
serving.store.state.views.values(),
|
||||
serving.store.state.entities,
|
||||
plan.target,
|
||||
),
|
||||
),
|
||||
{
|
||||
source: "state",
|
||||
view: update ? "update" : "delete",
|
||||
@@ -183,9 +210,23 @@ export const answerBulk = (
|
||||
);
|
||||
if (!answer.ok) return answer;
|
||||
if (update) {
|
||||
const updated = serving.store.updateAll(written.value);
|
||||
const updated = serving.store.updateAll(
|
||||
written.value,
|
||||
sortedOrder(
|
||||
serving,
|
||||
plan,
|
||||
written.value.map((after, index) => ({
|
||||
before: targets.value[index]!.entity,
|
||||
after,
|
||||
})),
|
||||
),
|
||||
);
|
||||
if (!updated.ok) return updated;
|
||||
} else serving.store.removeAll(written.value);
|
||||
} else
|
||||
serving.store.removeAll(
|
||||
written.value,
|
||||
softDeleteFlag(serving, first!.entity.type) !== undefined,
|
||||
);
|
||||
serving.store.commitWrite(values.taken);
|
||||
return answer;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ACTIVE_ON_READ_ROWS,
|
||||
BULK_RENAMED_LABEL_ROWS,
|
||||
ECHOED_EMPTY_ROWS,
|
||||
formLabelRows,
|
||||
LISTED_ID_ROWS,
|
||||
NOTE_DEFAULT_ROWS,
|
||||
RENAMED_LABEL_ROWS,
|
||||
TITLE_ORDER_ROWS,
|
||||
UNLOADED_ANSWER_ROWS,
|
||||
PARTIAL_OWNER_ROWS,
|
||||
} from "../../../tests/fixtures/environmentStateFormWrites.js";
|
||||
import { servedFrom } from "../../../tests/fixtures/environmentStateServing.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
|
||||
const field = (body: JsonValue | undefined, name: string): JsonValue[] => {
|
||||
if (!Array.isArray(body)) throw new Error("Expected a list body");
|
||||
return body.map((item) =>
|
||||
typeof item === "object" && item !== null && !Array.isArray(item)
|
||||
? (item[name] ?? null)
|
||||
: null,
|
||||
);
|
||||
};
|
||||
|
||||
describe("served creates and updates modelled on form recordings", () => {
|
||||
it("answers a create with the nested members its recorded answer showed, storing those its read showed", () => {
|
||||
const tasks = servedFrom(PARTIAL_OWNER_ROWS);
|
||||
const body = tasks.answer("post_tasks", {
|
||||
body: { title: "Coil the ropes" },
|
||||
}).body;
|
||||
const { id, owner } = body as { id: number; owner: JsonValue };
|
||||
expect(owner).toEqual({ id: 1 });
|
||||
expect(
|
||||
tasks.answer("get_tasks_by_id", { id: String(id) }).body,
|
||||
).toMatchObject({
|
||||
owner: { id: 1, name: "Rea Admin" },
|
||||
});
|
||||
});
|
||||
|
||||
it("creates an entity from its name alone, storing the members the form sent empty as it sent them", () => {
|
||||
const labels = servedFrom(formLabelRows());
|
||||
expect(
|
||||
labels.answer("post_labels", { body: { name: "Loaned Out" } }).body,
|
||||
).toEqual({
|
||||
id: 3,
|
||||
name: "Loaned Out",
|
||||
description: "",
|
||||
color: "",
|
||||
icon: "",
|
||||
});
|
||||
expect(field(labels.answer("get_labels").body, "name")).toEqual([
|
||||
"Kitchen",
|
||||
"Loaned Out",
|
||||
"Tools",
|
||||
]);
|
||||
expect(
|
||||
labels.refusal("post_labels", { body: { description: "No name" } }),
|
||||
).toMatchObject({ message: expect.stringContaining('"name"') });
|
||||
});
|
||||
|
||||
it("answers an empty member a call sent as it was sent, whichever spelling the recorded answer used", () => {
|
||||
const labels = servedFrom(ECHOED_EMPTY_ROWS);
|
||||
const tags = (sent: JsonValue) => {
|
||||
const body = labels.answer("post_labels", {
|
||||
body: { name: `Spare ${JSON.stringify(sent)}`, tags: sent },
|
||||
}).body;
|
||||
if (typeof body !== "object" || body === null || Array.isArray(body))
|
||||
throw new Error("Expected an object body");
|
||||
return body["tags"];
|
||||
};
|
||||
expect(tags([])).toEqual([]);
|
||||
expect(tags(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("moves a renamed entity to where the collection's order puts its new name", () => {
|
||||
const labels = servedFrom(RENAMED_LABEL_ROWS);
|
||||
labels.answer("patch_labels_by_id", { id: "1", body: { name: "Zinc" } });
|
||||
expect(field(labels.answer("get_labels").body, "name")).toEqual([
|
||||
"Tools",
|
||||
"Zinc",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps folders by title after a served create and rename", () => {
|
||||
const folders = servedFrom(TITLE_ORDER_ROWS);
|
||||
folders.answer("post_folders", { body: { title: "Harbour Ops" } });
|
||||
expect(field(folders.answer("get_folders").body, "title")).toEqual([
|
||||
"Harbour Ops",
|
||||
"Lighthouse Rota",
|
||||
]);
|
||||
folders.answer("put_folders_by_id", {
|
||||
id: "1",
|
||||
body: { title: "Archive", version: 1 },
|
||||
});
|
||||
expect(field(folders.answer("get_folders").body, "title")).toEqual([
|
||||
"Archive",
|
||||
"Harbour Ops",
|
||||
]);
|
||||
});
|
||||
|
||||
it("moves every entity a bulk rename writes to where its new name sorts", () => {
|
||||
const labels = servedFrom(BULK_RENAMED_LABEL_ROWS);
|
||||
const created = (name: string) => {
|
||||
const body = labels.answer("post_labels", { body: { name } }).body;
|
||||
if (typeof body !== "object" || body === null || Array.isArray(body))
|
||||
throw new Error("Expected an object body");
|
||||
return body["id"]!;
|
||||
};
|
||||
created("Attic");
|
||||
const garden = created("Garden");
|
||||
expect(field(labels.answer("get_labels").body, "name")).toEqual([
|
||||
"Attic",
|
||||
"Garden",
|
||||
"Kitchen",
|
||||
"Tools",
|
||||
]);
|
||||
labels.answer("patch_labels", {
|
||||
body: [
|
||||
{ id: garden, name: "Vane" },
|
||||
{ id: 1, name: "Wharf" },
|
||||
],
|
||||
});
|
||||
expect(field(labels.answer("get_labels").body, "name")).toEqual([
|
||||
"Attic",
|
||||
"Tools",
|
||||
"Vane",
|
||||
"Wharf",
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts a member sent empty as one recording did, and stores the default for one left out", () => {
|
||||
const notes = servedFrom(NOTE_DEFAULT_ROWS);
|
||||
expect(
|
||||
notes.answer("post_notes", { body: { name: "Blank", note: null } }).body,
|
||||
).toMatchObject({ name: "Blank", note: null });
|
||||
expect(
|
||||
notes.answer("post_notes", { body: { name: "Left out" } }).body,
|
||||
).toMatchObject({ name: "Left out", note: "" });
|
||||
});
|
||||
|
||||
it("answers writes with empty lists spelled as their recorded answers spelled them", () => {
|
||||
const tasks = servedFrom(UNLOADED_ANSWER_ROWS);
|
||||
expect(
|
||||
tasks.answer("post_tasks", { body: { title: "Coil the ropes" } }).body,
|
||||
).toMatchObject({
|
||||
id: 2,
|
||||
title: "Coil the ropes",
|
||||
assignees: [],
|
||||
owner: { id: 1, name: "", created: "0001-01-01T00:00:00Z" },
|
||||
});
|
||||
expect(tasks.answer("get_tasks_by_id", { id: "2" }).body).toMatchObject({
|
||||
owner: { id: 1, name: "Rea Admin" },
|
||||
});
|
||||
expect(
|
||||
tasks.answer("patch_tasks_by_id", { id: "1", body: { done: true } }).body,
|
||||
).toMatchObject({ done: true, assignees: [] });
|
||||
expect(tasks.answer("get_tasks_by_id", { id: "1" }).body).toMatchObject({
|
||||
done: true,
|
||||
assignees: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("answers a sent value its create does not store as the recorded answer did, and stores the read's", () => {
|
||||
const users = servedFrom(ACTIVE_ON_READ_ROWS);
|
||||
expect(
|
||||
users.answer("post_users", { body: { name: "Cy", active: false } }).body,
|
||||
).toEqual({ id: 2, name: "Cy", active: false });
|
||||
expect(users.answer("get_users_by_id", { id: "2" }).body).toEqual({
|
||||
id: 2,
|
||||
name: "Cy",
|
||||
active: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("lists a created tag's id in the list envelope's ids, in list order", () => {
|
||||
const tags = servedFrom(LISTED_ID_ROWS);
|
||||
tags.answer("post_tags", { body: { name: "Lamp" } });
|
||||
expect(tags.answer("get_tags").body).toMatchObject({
|
||||
count: 3,
|
||||
all: [1, 3, 2],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
LATER_BULK_ROWS,
|
||||
LATER_FIELD_ROWS,
|
||||
OMITTED_FLAG_ROWS,
|
||||
SOFT_DELETED_ANSWER_ROWS,
|
||||
SOFT_DELETED_QUERY_ROWS,
|
||||
SOFT_DELETED_ROWS,
|
||||
STALE_DELETE_ANSWER_ROWS,
|
||||
STALE_BULK_DELETE_ROWS,
|
||||
} from "../../../tests/fixtures/environmentStateLaterReads.js";
|
||||
import { servedFrom } from "../../../tests/fixtures/environmentStateServing.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
|
||||
const names = (body: JsonValue | undefined): JsonValue[] => {
|
||||
if (typeof body !== "object" || body === null || Array.isArray(body))
|
||||
throw new Error("Expected an object body");
|
||||
const list = body["list"];
|
||||
if (!Array.isArray(list)) throw new Error("Expected a list");
|
||||
return list.map((item) =>
|
||||
typeof item === "object" && item !== null && !Array.isArray(item)
|
||||
? (item["name"] ?? null)
|
||||
: null,
|
||||
);
|
||||
};
|
||||
|
||||
describe("served writes modelled on later reads", () => {
|
||||
it("keeps an initially deleted item readable without treating it as live", () => {
|
||||
const deleted = { id: 4, name: "Gone", deleted: true };
|
||||
const rows = [
|
||||
...SOFT_DELETED_ROWS.slice(0, 4),
|
||||
["GET", "/api/contacts/4", deleted] as const,
|
||||
...SOFT_DELETED_ROWS.slice(4),
|
||||
];
|
||||
const contacts = servedFrom(rows);
|
||||
expect(contacts.answer("get_contacts_by_id", { id: "4" }).body).toEqual(
|
||||
deleted,
|
||||
);
|
||||
expect(
|
||||
contacts.store.entity("/api/contacts", "/api/contacts", "4"),
|
||||
).toBeUndefined();
|
||||
expect(names(contacts.answer("get_contacts").body)).not.toContain("Gone");
|
||||
expect(contacts.call("delete_contacts_by_id", { id: "4" }).ok).toBe(false);
|
||||
const snapshot = contacts.store.snapshot();
|
||||
expect(contacts.store.replace(snapshot).ok).toBe(true);
|
||||
expect(contacts.answer("get_contacts_by_id", { id: "4" }).body).toEqual(
|
||||
deleted,
|
||||
);
|
||||
const held = snapshot.rea_state.entities.find(
|
||||
(entity) => entity.id === "4",
|
||||
)!;
|
||||
expect(
|
||||
contacts.store.insert(
|
||||
{ ...held, fields: { ...held.fields, deleted: false } },
|
||||
0,
|
||||
).ok,
|
||||
).toBe(true);
|
||||
expect(
|
||||
contacts.store
|
||||
.snapshot()
|
||||
.rea_state.entities.filter((entity) => entity.id === "4"),
|
||||
).toHaveLength(1);
|
||||
expect(contacts.store.byteLength()).toBe(
|
||||
Buffer.byteLength(JSON.stringify(contacts.store.snapshot())),
|
||||
);
|
||||
contacts.store.reset();
|
||||
expect(
|
||||
contacts.store.entity("/api/contacts", "/api/contacts", "4"),
|
||||
).toBeUndefined();
|
||||
expect(contacts.answer("get_contacts_by_id", { id: "4" }).body).toEqual(
|
||||
deleted,
|
||||
);
|
||||
const noWrites = servedFrom(rows.slice(0, 5));
|
||||
expect(noWrites.answer("get_contacts_by_id", { id: "4" }).body).toEqual(
|
||||
deleted,
|
||||
);
|
||||
});
|
||||
it("removes a soft-deleted contact from the served state", () => {
|
||||
const contacts = servedFrom(SOFT_DELETED_ROWS);
|
||||
contacts.answer("delete_contacts_by_id", { id: "1" });
|
||||
expect(names(contacts.answer("get_contacts").body)).toEqual([
|
||||
"Mina Okafor",
|
||||
"Rui Castellanos",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads a served-deleted contact back marked deleted, as the application answers", () => {
|
||||
const contacts = servedFrom(SOFT_DELETED_ROWS);
|
||||
contacts.answer("delete_contacts_by_id", { id: "1" });
|
||||
expect(contacts.answer("get_contacts_by_id", { id: "1" }).body).toEqual({
|
||||
id: 1,
|
||||
name: "Ada Finch",
|
||||
deleted: true,
|
||||
});
|
||||
// The read finds only what a served delete kept: a reset starts again without it.
|
||||
contacts.store.reset();
|
||||
expect(contacts.answer("get_contacts_by_id", { id: "1" }).body).toEqual({
|
||||
id: 1,
|
||||
name: "Ada Finch",
|
||||
deleted: false,
|
||||
});
|
||||
// A removal that keeps nothing, as a delete of a type that hard-deletes is, leaves no read.
|
||||
contacts.store.removeAll(contacts.store.startedWith("/api/contacts"));
|
||||
expect(contacts.refusal("get_contacts_by_id", { id: "1" })).toMatchObject({
|
||||
code: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("reads a served-deleted contact back marked deleted when only reads with a query showed one", () => {
|
||||
const contacts = servedFrom(SOFT_DELETED_QUERY_ROWS);
|
||||
contacts.answer("delete_contacts_by_id", { id: "1" });
|
||||
expect(contacts.answer("get_contacts_by_id", { id: "1" }).body).toEqual({
|
||||
id: 1,
|
||||
name: "Ada Finch",
|
||||
deleted: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("answers an update of an initial entity without the field only created entities show", () => {
|
||||
const tasks = servedFrom(LATER_FIELD_ROWS);
|
||||
const body = tasks.answer("patch_tasks_by_id", {
|
||||
id: "1",
|
||||
body: { done: true },
|
||||
}).body;
|
||||
expect(body).toEqual({ id: 1, title: "Trim the wicks", done: true });
|
||||
});
|
||||
|
||||
it("answers a bulk update of an initial entity without the field only created entities show", () => {
|
||||
const tasks = servedFrom(LATER_BULK_ROWS);
|
||||
const body = tasks.answer("patch_tasks", {
|
||||
body: [{ id: 1, done: true }],
|
||||
}).body;
|
||||
expect(body).toEqual([{ id: 1, title: "Trim the wicks", done: true }]);
|
||||
});
|
||||
|
||||
it("answers a soft delete with the record marked deleted, as the application does", () => {
|
||||
const contacts = servedFrom(SOFT_DELETED_ANSWER_ROWS);
|
||||
expect(contacts.answer("delete_contacts_by_id", { id: "1" }).body).toEqual({
|
||||
id: 1,
|
||||
name: "Ada Finch",
|
||||
deleted: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("answers a soft delete with the flag its recorded answer showed, and reads the record back marked deleted", () => {
|
||||
const contacts = servedFrom(STALE_DELETE_ANSWER_ROWS);
|
||||
expect(contacts.answer("delete_contacts_by_id", { id: "1" }).body).toEqual({
|
||||
id: 1,
|
||||
name: "Ada Finch",
|
||||
deleted: false,
|
||||
});
|
||||
expect(contacts.answer("get_contacts_by_id", { id: "1" }).body).toEqual({
|
||||
id: 1,
|
||||
name: "Ada Finch",
|
||||
deleted: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("answers a soft bulk delete with the flag its recorded answer showed", () => {
|
||||
const contacts = servedFrom(STALE_BULK_DELETE_ROWS);
|
||||
expect(
|
||||
contacts.answer("delete_contacts", { body: [{ id: 1 }] }).body,
|
||||
).toEqual([{ id: 1, name: "Ada Finch", deleted: false }]);
|
||||
});
|
||||
|
||||
it("reads a soft-deleted record back marked deleted when live records omit the flag", () => {
|
||||
const contacts = servedFrom(OMITTED_FLAG_ROWS);
|
||||
contacts.answer("delete_contacts_by_id", { id: "1" });
|
||||
expect(contacts.answer("get_contacts_by_id", { id: "1" }).body).toEqual({
|
||||
id: 1,
|
||||
name: "Ada Finch",
|
||||
deleted: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
StateEntity,
|
||||
StateType,
|
||||
import {
|
||||
markedDeleted,
|
||||
type StateEntity,
|
||||
type StateType,
|
||||
} from "../../domain/analysisView/environmentStateRules.js";
|
||||
import { nextSequence } from "../../domain/analysisView/environmentStateWriteValues.js";
|
||||
import {
|
||||
@@ -100,6 +101,7 @@ export const initialStateBytes = (state: EnvironmentState): number =>
|
||||
*/
|
||||
export class EnvironmentStateStore {
|
||||
#entities: ReadonlyMap<string, StateEntity> = new Map();
|
||||
#storedDeleted: ReadonlyMap<string, StateEntity> = new Map();
|
||||
#collections: ReadonlyMap<string, readonly StateEntity[]> = new Map();
|
||||
#typeCollections: ReadonlyMap<string, readonly string[]> = new Map();
|
||||
#documents: ReadonlyMap<string, HeldDocument> = new Map();
|
||||
@@ -125,6 +127,10 @@ export class EnvironmentStateStore {
|
||||
#collectionHistoryBytes = 0;
|
||||
/** The collections of the Evidence's own state, which stay known after any load. */
|
||||
#evidenceCollections: ReadonlyMap<string, readonly string[]> = new Map();
|
||||
/** Entities served deletes kept for an application that still answers a deleted record by id, oldest first. */
|
||||
readonly #deleted = new Map<string, StateEntity>();
|
||||
/** Their UTF-8 bytes, kept within what `data/add` loads by dropping the oldest. */
|
||||
#deletedBytes = 0;
|
||||
|
||||
constructor(readonly state: EnvironmentState) {
|
||||
this.#types = new Map(
|
||||
@@ -215,6 +221,8 @@ export class EnvironmentStateStore {
|
||||
this.#createdTypes.clear();
|
||||
this.#knownCollections.clear();
|
||||
this.#collectionHistoryBytes = 0;
|
||||
this.#deleted.clear();
|
||||
this.#deletedBytes = 0;
|
||||
for (const [type, collections] of this.#typeCollections)
|
||||
for (const collection of collections)
|
||||
this.#rememberCollection(type, collection);
|
||||
@@ -241,7 +249,9 @@ export class EnvironmentStateStore {
|
||||
|
||||
/** Every held entity of a type, in state order. */
|
||||
ofType(type: string): readonly StateEntity[] {
|
||||
return this.#content.entities.filter((entity) => entity.type === type);
|
||||
return this.#content.entities.filter(
|
||||
(entity) => entity.type === type && !markedDeleted(entity.fields),
|
||||
);
|
||||
}
|
||||
|
||||
/** Add an entity at `index` of its collection's order, unless the state would outgrow what `data/add` loads. */
|
||||
@@ -249,9 +259,16 @@ export class EnvironmentStateStore {
|
||||
entity: StateEntity,
|
||||
index: number,
|
||||
): Result<void, AnalysisResourceConstraintError> {
|
||||
const entities = this.#content.entities;
|
||||
const key = compositeKey([entity.type, entity.collection, entity.id]);
|
||||
const replaced = this.#storedDeleted.get(key);
|
||||
const entities =
|
||||
replaced === undefined
|
||||
? this.#content.entities
|
||||
: this.#content.entities.filter((held) => held !== replaced);
|
||||
const bytes = this.#grown(
|
||||
jsonByteLength(entity) + (entities.length > 0 ? 1 : 0),
|
||||
jsonByteLength(entity) -
|
||||
(replaced === undefined ? 0 : jsonByteLength(replaced)) +
|
||||
(replaced === undefined && entities.length > 0 ? 1 : 0),
|
||||
);
|
||||
if (!bytes.ok) return bytes;
|
||||
const known = this.#knownCollections.get(entity.type);
|
||||
@@ -311,9 +328,13 @@ export class EnvironmentStateStore {
|
||||
return this.updateAll([entity]);
|
||||
}
|
||||
|
||||
/** Replace held entities in one step, unless the state would outgrow what `data/add` loads; a refusal changes nothing. */
|
||||
/**
|
||||
* Replace held entities in one step, unless the state would outgrow what `data/add` loads; a refusal changes nothing.
|
||||
* `order` puts held entities, the replacements among them, in that order in the places they hold.
|
||||
*/
|
||||
updateAll(
|
||||
entities: readonly StateEntity[],
|
||||
order: readonly StateEntity[] = [],
|
||||
): Result<void, AnalysisResourceConstraintError> {
|
||||
const replaced = new Map<StateEntity, StateEntity>();
|
||||
for (const entity of entities) {
|
||||
@@ -329,11 +350,17 @@ export class EnvironmentStateStore {
|
||||
delta += jsonByteLength(entity) - jsonByteLength(held);
|
||||
const bytes = this.#grown(delta);
|
||||
if (!bytes.ok) return bytes;
|
||||
const key = (entity: StateEntity) =>
|
||||
compositeKey([entity.type, entity.collection, entity.id]);
|
||||
const placed = new Set(order.map(key));
|
||||
let next = 0;
|
||||
this.#install(
|
||||
{
|
||||
...this.#content,
|
||||
entities: this.#content.entities.map(
|
||||
(candidate) => replaced.get(candidate) ?? candidate,
|
||||
entities: this.#content.entities.map((candidate) =>
|
||||
placed.has(key(candidate))
|
||||
? order[next++]!
|
||||
: (replaced.get(candidate) ?? candidate),
|
||||
),
|
||||
},
|
||||
bytes.value,
|
||||
@@ -341,12 +368,12 @@ export class EnvironmentStateStore {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
remove(entity: StateEntity): void {
|
||||
this.removeAll([entity]);
|
||||
remove(entity: StateEntity, keep = false): void {
|
||||
this.removeAll([entity], keep);
|
||||
}
|
||||
|
||||
/** Remove held entities in one step. */
|
||||
removeAll(entities: readonly StateEntity[]): void {
|
||||
/** Remove held entities in one step; with `keep`, a later item read can still find each as deleted. */
|
||||
removeAll(entities: readonly StateEntity[], keep = false): void {
|
||||
const held = new Set(
|
||||
entities.flatMap((entity) => {
|
||||
const found = this.#entities.get(
|
||||
@@ -364,6 +391,35 @@ export class EnvironmentStateStore {
|
||||
// Each removed entity takes its separator, except the last one an emptied list had none for.
|
||||
bytes -= held.size - (kept.length === 0 ? 1 : 0);
|
||||
this.#install({ ...this.#content, entities: kept }, bytes);
|
||||
if (keep) for (const entity of held) this.#keepDeleted(entity);
|
||||
}
|
||||
|
||||
/** An entity a served delete removed and kept, unless the state holds one under its key again. */
|
||||
deleted(
|
||||
type: string,
|
||||
collection: string,
|
||||
id: string,
|
||||
): StateEntity | undefined {
|
||||
const key = compositeKey([type, collection, id]);
|
||||
return this.#entities.has(key)
|
||||
? undefined
|
||||
: (this.#storedDeleted.get(key) ?? this.#deleted.get(key));
|
||||
}
|
||||
|
||||
#keepDeleted(entity: StateEntity): void {
|
||||
const key = compositeKey([entity.type, entity.collection, entity.id]);
|
||||
const earlier = this.#deleted.get(key);
|
||||
if (earlier !== undefined) {
|
||||
this.#deleted.delete(key);
|
||||
this.#deletedBytes -= jsonByteLength(earlier);
|
||||
}
|
||||
this.#deleted.set(key, entity);
|
||||
this.#deletedBytes += jsonByteLength(entity);
|
||||
for (const [oldest, dropped] of this.#deleted) {
|
||||
if (this.#deletedBytes <= STATE_INTAKE_LIMIT_BYTES) break;
|
||||
this.#deleted.delete(oldest);
|
||||
this.#deletedBytes -= jsonByteLength(dropped);
|
||||
}
|
||||
}
|
||||
|
||||
/** Set a held document's value, unless the state would outgrow what `data/add` loads; a value that is not the Evidence's cites no record. */
|
||||
@@ -467,9 +523,20 @@ export class EnvironmentStateStore {
|
||||
/** Build current-state indexes without copying baseline collections into generated history. */
|
||||
#install(content: StateContent, bytes: number): void {
|
||||
const entities = new Map<string, StateEntity>();
|
||||
const storedDeleted = new Map<string, StateEntity>();
|
||||
const collections = new Map<string, StateEntity[]>();
|
||||
const typeCollections = new Map<string, string[]>();
|
||||
for (const entity of content.entities) {
|
||||
if (markedDeleted(entity.fields)) {
|
||||
storedDeleted.set(
|
||||
compositeKey([entity.type, entity.collection, entity.id]),
|
||||
entity,
|
||||
);
|
||||
const named = typeCollections.get(entity.type) ?? [];
|
||||
if (!named.includes(entity.collection)) named.push(entity.collection);
|
||||
typeCollections.set(entity.type, named);
|
||||
continue;
|
||||
}
|
||||
entities.set(
|
||||
compositeKey([entity.type, entity.collection, entity.id]),
|
||||
entity,
|
||||
@@ -484,9 +551,11 @@ export class EnvironmentStateStore {
|
||||
const named = typeCollections.get(entity.type);
|
||||
if (named === undefined)
|
||||
typeCollections.set(entity.type, [entity.collection]);
|
||||
else named.push(entity.collection);
|
||||
else if (!named.includes(entity.collection))
|
||||
named.push(entity.collection);
|
||||
}
|
||||
this.#entities = entities;
|
||||
this.#storedDeleted = storedDeleted;
|
||||
this.#collections = collections;
|
||||
this.#typeCollections = typeCollections;
|
||||
this.#documents = new Map(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
createdFields,
|
||||
documentBodyProblem,
|
||||
laterItemFields,
|
||||
optionalFields,
|
||||
renewUuids,
|
||||
updatedEntity,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
} from "../../domain/analysisView/environmentStateWriteApply.js";
|
||||
import {
|
||||
insertIndex,
|
||||
resortedMembers,
|
||||
nextInteger,
|
||||
} from "../../domain/analysisView/environmentStateWriteExplain.js";
|
||||
import { callRenewal } from "../../domain/analysisView/environmentStateWriteGenerated.js";
|
||||
@@ -38,10 +40,12 @@ import {
|
||||
spelledLike,
|
||||
} from "../../domain/analysisView/environmentStateWriteValues.js";
|
||||
import {
|
||||
at,
|
||||
ownMember,
|
||||
collectionMatch,
|
||||
decodedSegment,
|
||||
isObject,
|
||||
pointerTokens,
|
||||
scalarText,
|
||||
type JsonObject,
|
||||
type StateEntity,
|
||||
@@ -63,6 +67,8 @@ import {
|
||||
namedEntity,
|
||||
recordedJson,
|
||||
refused,
|
||||
deleteAnswerFields,
|
||||
softDeleteFlag,
|
||||
stateAnswer,
|
||||
type StateAnswer,
|
||||
type StateProvenance,
|
||||
@@ -333,7 +339,11 @@ export const writeAnswer = (
|
||||
serving: StateServing,
|
||||
request: WriteRequest,
|
||||
rendered:
|
||||
| { readonly fields: JsonObject | undefined; readonly id: string }
|
||||
| {
|
||||
readonly fields: JsonObject | undefined;
|
||||
readonly id: string;
|
||||
readonly sent?: readonly string[];
|
||||
}
|
||||
| ((
|
||||
recorded: JsonValue | undefined,
|
||||
) => Result<JsonValue | undefined, string>),
|
||||
@@ -370,8 +380,25 @@ export const writeAnswer = (
|
||||
rule.rule === "clock" ? [rule.field] : [],
|
||||
)
|
||||
: [],
|
||||
// A member the call sent and the plan stores as sent is answered as sent; any other keeps the recorded answer.
|
||||
unloaded: (plan.observation?.read?.unloaded ?? []).filter(
|
||||
(path) => {
|
||||
const [field] = pointerTokens(path);
|
||||
return !(
|
||||
rendered.sent?.includes(field!) === true &&
|
||||
plan.rules.some(
|
||||
(rule) => rule.field === field && rule.rule === "echo",
|
||||
)
|
||||
);
|
||||
},
|
||||
),
|
||||
},
|
||||
optionalFields(plan),
|
||||
laterItemFields(
|
||||
serving.store.state.views.values(),
|
||||
serving.store.state.entities,
|
||||
plan.target,
|
||||
),
|
||||
);
|
||||
if (!body.ok) return err(refused(tool, body.error));
|
||||
return stateAnswer(serving, { exchange, exact }, body.value ?? null, state);
|
||||
@@ -519,7 +546,7 @@ const answerCreate = (
|
||||
const answer = writeAnswer(
|
||||
serving,
|
||||
request,
|
||||
{ fields: fields.value, id },
|
||||
{ fields: fields.value, id, sent: Object.keys(call.body ?? {}) },
|
||||
state,
|
||||
);
|
||||
if (!answer.ok) return answer;
|
||||
@@ -591,7 +618,11 @@ const answerUpdate = (
|
||||
const answer = writeAnswer(
|
||||
serving,
|
||||
request,
|
||||
{ fields: written.value, id: entity.id },
|
||||
{
|
||||
fields: written.value,
|
||||
id: entity.id,
|
||||
sent: Object.keys(call.body ?? {}),
|
||||
},
|
||||
{
|
||||
source: "state",
|
||||
view: "update",
|
||||
@@ -601,12 +632,34 @@ const answerUpdate = (
|
||||
},
|
||||
);
|
||||
if (!answer.ok) return answer;
|
||||
const updated = serving.store.update(updatedEntity(entity, written.value));
|
||||
const after = updatedEntity(entity, written.value);
|
||||
const updated = serving.store.updateAll(
|
||||
[after],
|
||||
sortedOrder(serving, plan, [{ before: entity, after }]),
|
||||
);
|
||||
if (!updated.ok) return updated;
|
||||
serving.store.commitWrite(values.taken);
|
||||
return answer;
|
||||
};
|
||||
|
||||
/** The collection's members in the order a sorted collection holds them after these writes; empty when the plan keeps them in place. */
|
||||
export const sortedOrder = (
|
||||
serving: StateServing,
|
||||
plan: StateWritePlan,
|
||||
writes: readonly {
|
||||
readonly before: StateEntity;
|
||||
readonly after: StateEntity;
|
||||
}[],
|
||||
): StateEntity[] => {
|
||||
const [first] = writes;
|
||||
if (plan.insert?.rule !== "sorted" || first === undefined) return [];
|
||||
return resortedMembers(
|
||||
plan.insert,
|
||||
serving.store.inCollection(first.before.type, first.before.collection),
|
||||
writes,
|
||||
);
|
||||
};
|
||||
|
||||
const answerDelete = (
|
||||
serving: StateServing,
|
||||
request: WriteRequest,
|
||||
@@ -614,10 +667,27 @@ const answerDelete = (
|
||||
const target = targetEntity(serving, request);
|
||||
if (!target.ok) return target.error;
|
||||
const entity = target.value;
|
||||
const { response } = request.plan;
|
||||
const exchange =
|
||||
response === null
|
||||
? undefined
|
||||
: serving.source.exchanges.get(response.ordinal);
|
||||
const recorded = exchange === undefined ? undefined : recordedJson(exchange);
|
||||
// A soft-deleting application answers with the entity's deleted flag as its recorded answer showed it.
|
||||
const answer = writeAnswer(
|
||||
serving,
|
||||
request,
|
||||
{ fields: entity.fields, id: entity.id },
|
||||
{
|
||||
fields: deleteAnswerFields(
|
||||
serving,
|
||||
entity.type,
|
||||
entity.fields,
|
||||
recorded === undefined
|
||||
? undefined
|
||||
: at(recorded, response!.pointer ?? ""),
|
||||
),
|
||||
id: entity.id,
|
||||
},
|
||||
{
|
||||
source: "state",
|
||||
view: "delete",
|
||||
@@ -627,7 +697,11 @@ const answerDelete = (
|
||||
},
|
||||
);
|
||||
if (answer.ok) {
|
||||
serving.store.remove(entity);
|
||||
// An application that soft-deletes still answers the entity by id, so its last fields are kept for that read.
|
||||
serving.store.remove(
|
||||
entity,
|
||||
softDeleteFlag(serving, entity.type) !== undefined,
|
||||
);
|
||||
serving.store.commitWrite(new Map());
|
||||
}
|
||||
return answer;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
apiBaseLengths,
|
||||
at,
|
||||
collectionPathOf,
|
||||
deletedFlag,
|
||||
identifies,
|
||||
IDENTITY_NAMES,
|
||||
identityCandidates,
|
||||
@@ -439,7 +440,10 @@ export class EnvironmentStateExtraction {
|
||||
candidate: Candidate,
|
||||
pointer: string,
|
||||
recordings: readonly StateRecordFacts[],
|
||||
replayed: Pick<StatePlan, "projected" | "late"> = {},
|
||||
replayed: Pick<
|
||||
StatePlan,
|
||||
"projected" | "late" | "laterFields" | "deletedFlag"
|
||||
> = {},
|
||||
): void {
|
||||
this.#plans.push({
|
||||
...candidate,
|
||||
@@ -448,9 +452,61 @@ export class EnvironmentStateExtraction {
|
||||
latest: this.#latestPerLine(recordings),
|
||||
projected: replayed.projected ?? [],
|
||||
late: replayed.late ?? [],
|
||||
...(replayed.laterFields === undefined ||
|
||||
replayed.laterFields.length === 0
|
||||
? {}
|
||||
: { laterFields: replayed.laterFields }),
|
||||
...(replayed.deletedFlag === undefined
|
||||
? {}
|
||||
: { deletedFlag: replayed.deletedFlag }),
|
||||
});
|
||||
}
|
||||
|
||||
/** The members later plain item reads show of the entity they name, in first-seen order, and the flag any of its reads sets on one the application soft-deleted. */
|
||||
#laterFields(
|
||||
tool: StateToolFacts,
|
||||
type: string,
|
||||
entity: { readonly field: string; readonly pointer: string },
|
||||
): Pick<StatePlan, "laterFields" | "deletedFlag"> {
|
||||
const limit = this.#initLimit(type);
|
||||
const names: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
let flag: StatePlan["deletedFlag"];
|
||||
if (limit === undefined) return { laterFields: names };
|
||||
for (const ordinal of tool.record_ordinals) {
|
||||
const record = this.#byOrdinal.get(ordinal);
|
||||
if (
|
||||
record === undefined ||
|
||||
record.status === null ||
|
||||
record.status < 200 ||
|
||||
record.status >= 300
|
||||
)
|
||||
continue;
|
||||
// Any read may show the soft-delete flag; only plain later reads add fields.
|
||||
const later =
|
||||
(record.query ?? "") === "" && !this.#beforeInitLimit(record, limit);
|
||||
if (!later && flag !== undefined) continue;
|
||||
this.#withBody(ordinal, (body) => {
|
||||
const object =
|
||||
body === undefined ? undefined : at(body, entity.pointer);
|
||||
if (!isObject(object) || ownMember(object, entity.field) === undefined)
|
||||
return;
|
||||
flag ??= deletedFlag(object);
|
||||
if (!later) return;
|
||||
for (const name of Object.keys(object))
|
||||
if (!seen.has(name)) {
|
||||
seen.add(name);
|
||||
names.push(name);
|
||||
this.#retain(ENTRY_BYTES + textBytes(name.length));
|
||||
}
|
||||
});
|
||||
}
|
||||
if (flag === undefined) return { laterFields: names };
|
||||
// The flag is a field the item view shows, even when only a query read showed it.
|
||||
if (!seen.has(flag)) names.push(flag);
|
||||
return { laterFields: names, deletedFlag: flag };
|
||||
}
|
||||
|
||||
/**
|
||||
* Later identity-free projections are evidence of replay, never initial entities; later lines that
|
||||
* show the identity and name a shaping or projection parameter are kept, with their members, for
|
||||
@@ -558,7 +614,8 @@ export class EnvironmentStateExtraction {
|
||||
this.#chronology.positions.get(left)! -
|
||||
this.#chronology.positions.get(right)!,
|
||||
);
|
||||
this.#addPlan(candidate, pointer, kept, { projected, late });
|
||||
const later = this.#laterFields(tool, type, { field, pointer });
|
||||
this.#addPlan(candidate, pointer, kept, { projected, late, ...later });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -782,6 +839,16 @@ export class EnvironmentStateExtraction {
|
||||
entity: (type, collection, id) =>
|
||||
this.#merged.get(entityKey(type, collection, id)),
|
||||
inCollection: (type, collection) => this.#inCollection(type, collection),
|
||||
heldFields: (type) => {
|
||||
const fields = new Set<string>();
|
||||
for (const entity of this.#merged.values())
|
||||
if (entity.type === type) {
|
||||
for (const name of Object.keys(entity.fields)) fields.add(name);
|
||||
for (const view of entity.view_fields)
|
||||
for (const name of Object.keys(view.fields)) fields.add(name);
|
||||
}
|
||||
return fields;
|
||||
},
|
||||
volatileOf: (type) => this.#volatileOf(type),
|
||||
idField: (type) => this.#identity.get(type)!.field,
|
||||
};
|
||||
|
||||
@@ -5,8 +5,13 @@ import {
|
||||
changesState,
|
||||
isObject,
|
||||
isStateWrite,
|
||||
listItems,
|
||||
listsAll,
|
||||
markedDeleted,
|
||||
normalizedPath,
|
||||
ownMember,
|
||||
same,
|
||||
scalarText,
|
||||
type JsonObject,
|
||||
type ParsedRequestBody,
|
||||
type StateEntity,
|
||||
@@ -20,7 +25,10 @@ import { FailedReadBlame } from "./environmentStateWriteBlame.js";
|
||||
import { addressedWrite } from "./environmentStateWriteAddress.js";
|
||||
import { withoutMembers } from "./environmentStateResponseMembers.js";
|
||||
import { linkedTarget } from "./environmentStateWriteLinks.js";
|
||||
import { updatedEntity } from "./environmentStateWriteApply.js";
|
||||
import {
|
||||
laterItemFields,
|
||||
updatedEntity,
|
||||
} from "./environmentStateWriteApply.js";
|
||||
import { projectedRead } from "./environmentStateWriteObserve.js";
|
||||
import { OmittedFields } from "./environmentStateWriteOmitted.js";
|
||||
import { SortedArrays } from "./environmentStateWriteSorted.js";
|
||||
@@ -38,6 +46,7 @@ import {
|
||||
showsPlacement,
|
||||
SimulatedState,
|
||||
type CheckOutcome,
|
||||
type PendingWrite,
|
||||
} from "./environmentStateWriteChecks.js";
|
||||
import {
|
||||
checkedLines,
|
||||
@@ -76,10 +85,46 @@ import {
|
||||
classifyWrite,
|
||||
prefixRelated,
|
||||
unservedPlan,
|
||||
type InsertRule,
|
||||
type StateWritePlan,
|
||||
} from "./environmentStateWriteRules.js";
|
||||
import { WriteTaint } from "./environmentStateWriteTaint.js";
|
||||
import { writeVerdict } from "./environmentStateWriteVerdict.js";
|
||||
import { compareValues, orderedBy } from "./environmentStateWriteExplain.js";
|
||||
|
||||
/** Whether members are in a sorted rule's order, ties kept in place as an insert keeps them. */
|
||||
const keptInOrder = (
|
||||
members: readonly JsonObject[],
|
||||
rule: Extract<InsertRule, { rule: "sorted" }>,
|
||||
): boolean =>
|
||||
members.every((member, index) => {
|
||||
if (index === 0) return true;
|
||||
const order = compareValues(
|
||||
ownMember(members[index - 1]!, rule.field),
|
||||
ownMember(member, rule.field),
|
||||
);
|
||||
return (
|
||||
order !== undefined &&
|
||||
(rule.direction === "ascending" ? order <= 0 : order >= 0)
|
||||
);
|
||||
});
|
||||
|
||||
/** Whether a list read's query sets a parameter the view's query model reads as a sort or its direction. */
|
||||
const sortedByQuery = (
|
||||
view: Extract<StateView, { shape: "list" }>,
|
||||
record: StateRecordFacts,
|
||||
): boolean => {
|
||||
const sent = new Set(
|
||||
[...new URLSearchParams(record.query ?? "").keys()].map((key) =>
|
||||
key.toLowerCase(),
|
||||
),
|
||||
);
|
||||
return Object.entries(view.query?.parameters ?? {}).some(
|
||||
([parameter, { role }]) =>
|
||||
(role === "sort" || role === "direction") &&
|
||||
sent.has(parameter.toLowerCase()),
|
||||
);
|
||||
};
|
||||
|
||||
const APPLY = {
|
||||
create: applyCreate,
|
||||
@@ -129,8 +174,16 @@ export class EnvironmentStateWriteSimulation {
|
||||
}[] = [];
|
||||
/** Entities a create inserted, by target, with the create's tool. */
|
||||
readonly #inserted = new Map<string, string>();
|
||||
/** Targets a write changed or placed since a passing list read last showed them, whose position no read has confirmed. */
|
||||
readonly #moved = new Set<string>();
|
||||
/** The writes to those targets' entities, in order. */
|
||||
#pending: PendingWrite[] = [];
|
||||
/** Sorts, by type, that a whole list in collection order ruled out: moving the writes it showed by them gives another order. */
|
||||
readonly #refutedSorts = new Map<string, Set<string>>();
|
||||
/** Creates whose insert rule a passing list check relied on, so it is no longer changed. */
|
||||
readonly #placed = new Set<string>();
|
||||
/** The order a read showed a type's collections sorted in after an update, when no create of it showed one. */
|
||||
readonly #learnedSorts = new Map<string, InsertRule>();
|
||||
/** Entities writes no plan models made, taken when an item read first shows them. */
|
||||
readonly #unseen: UnseenEntities;
|
||||
readonly #blame: FailedReadBlame;
|
||||
@@ -174,6 +227,28 @@ export class EnvironmentStateWriteSimulation {
|
||||
),
|
||||
this.#state,
|
||||
);
|
||||
this.#state.keepSorted((type) => this.#sortOf(type));
|
||||
}
|
||||
|
||||
/**
|
||||
* The sorted order a type's placed creates insert by, when they agree on one and nothing contradicted them: updates
|
||||
* move their entity only by an order a list read showed, in replay as in serving.
|
||||
*/
|
||||
#sortOf(type: string): InsertRule | undefined {
|
||||
const rules = new Map<string, InsertRule>();
|
||||
for (const { plan, observed, contradictedBy } of this.#drafts.values())
|
||||
if (
|
||||
plan.effect === "create" &&
|
||||
plan.target === type &&
|
||||
observed !== undefined &&
|
||||
plan.reason === null &&
|
||||
contradictedBy === undefined &&
|
||||
plan.insert?.rule === "sorted" &&
|
||||
this.#placed.has(plan.tool)
|
||||
)
|
||||
rules.set(JSON.stringify(plan.insert), plan.insert);
|
||||
if (rules.size === 0) return this.#learnedSorts.get(type);
|
||||
return rules.size === 1 ? [...rules.values()][0] : undefined;
|
||||
}
|
||||
|
||||
/** Every state-changing tool's plan, in manifest order, with its verdict. */
|
||||
@@ -244,6 +319,9 @@ export class EnvironmentStateWriteSimulation {
|
||||
view,
|
||||
]),
|
||||
);
|
||||
const sorts = new Map(
|
||||
[...this.#types.keys()].map((type) => [type, this.#sortOf(type)]),
|
||||
);
|
||||
return [...this.#drafts.values()].map((draft) => {
|
||||
// A view that became recorded cannot supply proof for a served write.
|
||||
let kept = 0;
|
||||
@@ -253,6 +331,16 @@ export class EnvironmentStateWriteSimulation {
|
||||
draft.confirmed[kept++] = ordinal;
|
||||
}
|
||||
draft.confirmed.length = kept;
|
||||
// An update moves its entity in a sorted collection when it writes the field sorted by.
|
||||
const sort =
|
||||
draft.plan.effect === "update" && draft.plan.target !== null
|
||||
? sorts.get(draft.plan.target)
|
||||
: undefined;
|
||||
if (
|
||||
sort?.rule === "sorted" &&
|
||||
draft.plan.rules.some((rule) => rule.field === sort.field)
|
||||
)
|
||||
draft.plan.insert = sort;
|
||||
return writeVerdict(draft, this.#input);
|
||||
});
|
||||
}
|
||||
@@ -301,6 +389,7 @@ export class EnvironmentStateWriteSimulation {
|
||||
return view === undefined ? undefined : this.#viewOf(view);
|
||||
},
|
||||
idField: (type) => this.#types.get(type)!.id_field,
|
||||
volatileOf: this.#input.volatileOf,
|
||||
observed: (ordinal) =>
|
||||
this.#observations.set(
|
||||
ordinal,
|
||||
@@ -312,6 +401,8 @@ export class EnvironmentStateWriteSimulation {
|
||||
this.#input.documents.find((document) => document.tool === tool),
|
||||
startedWith: (type) =>
|
||||
this.#input.entities.filter((entity) => entity.type === type),
|
||||
laterFields: (type) =>
|
||||
laterItemFields(this.#input.views.values(), this.#input.entities, type),
|
||||
pending: (target) =>
|
||||
[...(this.#touched.get(target)?.keys() ?? [])].some(
|
||||
(name) => name !== writer,
|
||||
@@ -349,6 +440,7 @@ export class EnvironmentStateWriteSimulation {
|
||||
const { address } = draft.plan;
|
||||
const bulk = address !== undefined && address.by !== "action";
|
||||
const apply = (body?: ParsedRequestBody): void => {
|
||||
this.#state.track();
|
||||
const context = this.#context(
|
||||
tool.name,
|
||||
body === undefined ? undefined : { ordinal: record.ordinal, body },
|
||||
@@ -403,11 +495,19 @@ export class EnvironmentStateWriteSimulation {
|
||||
return;
|
||||
}
|
||||
this.#effects.supersede(this.#touched, write.tool, result);
|
||||
for (const target of result.targets)
|
||||
if (result.entity !== undefined) {
|
||||
const { type, collection, id } = result.entity;
|
||||
const target = entityTarget(type, collection, id);
|
||||
const before = this.#state.priorFields(target);
|
||||
if (before !== undefined) this.#pending.push({ target, before });
|
||||
}
|
||||
for (const target of result.targets) {
|
||||
this.#moved.add(target);
|
||||
touchTarget(this.#touched, this.#touchedSince, target, {
|
||||
...write,
|
||||
fields: result.fields,
|
||||
});
|
||||
}
|
||||
this.#log.push(write);
|
||||
if (result.inserted === undefined) return;
|
||||
this.#inserted.set(result.inserted, write.tool);
|
||||
@@ -428,7 +528,16 @@ export class EnvironmentStateWriteSimulation {
|
||||
const writes = this.#touched.get(target) ?? new Map();
|
||||
this.#taint.known(entity.type, entity.collection, entity.id);
|
||||
this.#touched.delete(target);
|
||||
if (isAbsence(record.status)) {
|
||||
if (
|
||||
isAbsence(record.status) ||
|
||||
this.#input.io.withBody(record.ordinal, (body) =>
|
||||
markedDeleted(
|
||||
body === undefined || body === null
|
||||
? undefined
|
||||
: at(body, view.pointer),
|
||||
),
|
||||
)
|
||||
) {
|
||||
this.#removeEntity(entity);
|
||||
return;
|
||||
}
|
||||
@@ -606,7 +715,9 @@ export class EnvironmentStateWriteSimulation {
|
||||
return;
|
||||
}
|
||||
if (!outcome.pass && view.shape === "list") {
|
||||
const retried = this.#retryInsert(view, record, exercised);
|
||||
const retried =
|
||||
this.#retryInsert(view, record, exercised) ??
|
||||
this.#retrySort(view, record, exercised);
|
||||
if (retried !== undefined) {
|
||||
// Reordering can replace the entities on a query page. Confirmation
|
||||
// and pending-write cleanup must use the successful check's coverage.
|
||||
@@ -622,6 +733,17 @@ export class EnvironmentStateWriteSimulation {
|
||||
const unseen = outcome.pass
|
||||
? this.#confirm(view, record, outcome, exercised)
|
||||
: undefined;
|
||||
// Only a read whose order the state decides for every entity it lists shows them in place.
|
||||
if (
|
||||
outcome.pass &&
|
||||
view.shape === "list" &&
|
||||
outcome.positional === undefined
|
||||
) {
|
||||
this.#refuteSorts(view, record);
|
||||
const shown = new Set(outcome.targets);
|
||||
for (const target of shown) this.#moved.delete(target);
|
||||
this.#pending = this.#pending.filter((write) => !shown.has(write.target));
|
||||
}
|
||||
if (!outcome.pass) {
|
||||
const interfered = new Set([...exercised].filter(interferes));
|
||||
if (interfered.size === 0)
|
||||
@@ -670,8 +792,10 @@ export class EnvironmentStateWriteSimulation {
|
||||
});
|
||||
for (const name of shown.confirmed) {
|
||||
this.#drafts.get(name)!.confirmed.push(record.ordinal);
|
||||
if (showsPlacement(view, outcome, this.#inserted, name))
|
||||
if (showsPlacement(view, outcome, this.#inserted, name)) {
|
||||
this.#placed.add(name);
|
||||
if (view.shape === "list") this.#narrowInserts(view, record, name);
|
||||
}
|
||||
}
|
||||
return shown.unseen;
|
||||
}
|
||||
@@ -741,7 +865,7 @@ export class EnvironmentStateWriteSimulation {
|
||||
const object = this.#input.io.withBody(record.ordinal, (body) =>
|
||||
body === undefined || body === null ? undefined : at(body, view.pointer),
|
||||
);
|
||||
if (isObject(object))
|
||||
if (isObject(object) && !markedDeleted(object))
|
||||
this.#state.put(updatedEntity(entity, { ...entity.fields, ...object }));
|
||||
else this.#removeEntity(entity);
|
||||
}
|
||||
@@ -780,6 +904,189 @@ export class EnvironmentStateWriteSimulation {
|
||||
for (const key of failed.keys) this.#taint.fail(key);
|
||||
}
|
||||
|
||||
/** What `use` makes of a read's items when the read lists the whole collection the state holds, the only read that shows the order it is kept in. */
|
||||
#wholeListing<T>(
|
||||
view: Extract<StateView, { shape: "list" }>,
|
||||
record: StateRecordFacts,
|
||||
use: (items: readonly JsonObject[]) => T,
|
||||
): T | undefined {
|
||||
const line = this.#line(view, record);
|
||||
const idField = this.#types.get(view.type)?.id_field;
|
||||
if (line === undefined || idField === undefined) return undefined;
|
||||
const held = new Set(
|
||||
this.#state
|
||||
.members(view.type, line.collection)
|
||||
.map((entity) => entity.id),
|
||||
);
|
||||
return this.#input.io.withBody(record.ordinal, (body) => {
|
||||
const items =
|
||||
body === undefined ? undefined : listItems(body, view.pointer);
|
||||
if (
|
||||
items === undefined ||
|
||||
items.length < 2 ||
|
||||
items.length !== held.size ||
|
||||
!items.every((item) =>
|
||||
held.has(scalarText(ownMember(item, idField)) ?? ""),
|
||||
)
|
||||
)
|
||||
return undefined;
|
||||
return use(items);
|
||||
});
|
||||
}
|
||||
|
||||
/** A whole-collection read in collection order that placed a create leaves it only the sorted inserts whose order the read follows; one its query sorts shows the query's order instead. */
|
||||
#narrowInserts(
|
||||
view: Extract<StateView, { shape: "list" }>,
|
||||
record: StateRecordFacts,
|
||||
name: string,
|
||||
): void {
|
||||
if (sortedByQuery(view, record)) return;
|
||||
const draft = this.#drafts.get(name)!;
|
||||
const kept = this.#wholeListing(view, record, (items) =>
|
||||
draft.candidates.filter(
|
||||
(candidate) =>
|
||||
candidate.rule !== "sorted" ||
|
||||
same(candidate, draft.plan.insert) ||
|
||||
orderedBy(items, candidate.field, candidate.direction),
|
||||
),
|
||||
);
|
||||
if (kept !== undefined) draft.candidates = kept;
|
||||
}
|
||||
|
||||
/**
|
||||
* The successful check with the collection sorted by a field an update the read exercised writes, when the read
|
||||
* lists the whole collection in that order and no create of the type was placed by a sort that excludes it; the
|
||||
* type's creates then insert by it, and its updates move their entity by it. A sort the creates chose by a field the
|
||||
* update did not write does not stop this when every whole-collection read that placed them followed this order too.
|
||||
*/
|
||||
#retrySort(
|
||||
view: Extract<StateView, { shape: "list" }>,
|
||||
record: StateRecordFacts,
|
||||
exercised: ReadonlySet<string>,
|
||||
): CheckOutcome | undefined {
|
||||
const line = this.#line(view, record);
|
||||
// A read its query sorts shows the query's order, which may break ties otherwise than the collection.
|
||||
if (line === undefined || sortedByQuery(view, record)) return undefined;
|
||||
const written = new Set(
|
||||
[...exercised].flatMap((name) => {
|
||||
const { plan } = this.#drafts.get(name)!;
|
||||
return plan.effect === "update" && plan.target === view.type
|
||||
? plan.rules.map((rule) => rule.field)
|
||||
: [];
|
||||
}),
|
||||
);
|
||||
// A sort by a field the update wrote has moved its entity already; one by another field may be a guess both fit.
|
||||
const current = this.#sortOf(view.type);
|
||||
if (current?.rule === "sorted" && written.has(current.field))
|
||||
return undefined;
|
||||
const creates = [...this.#drafts.values()].filter(
|
||||
(draft) =>
|
||||
draft.plan.effect === "create" &&
|
||||
draft.plan.target === view.type &&
|
||||
draft.observed !== undefined,
|
||||
);
|
||||
const rules =
|
||||
this.#wholeListing(view, record, (items) =>
|
||||
[...written].flatMap((field) =>
|
||||
(["ascending", "descending"] as const).flatMap((direction) =>
|
||||
orderedBy(items, field, direction)
|
||||
? [{ rule: "sorted", field, direction } as const]
|
||||
: [],
|
||||
),
|
||||
),
|
||||
) ?? [];
|
||||
// Serving moves only the entities writes changed since a list last showed them, so the others, including one an
|
||||
// earlier write moved that a list then showed in place, must already be in the order tried.
|
||||
const kept = this.#state
|
||||
.members(view.type, line.collection)
|
||||
.filter(
|
||||
(entity) =>
|
||||
!this.#moved.has(
|
||||
entityTarget(entity.type, entity.collection, entity.id),
|
||||
),
|
||||
)
|
||||
.map((entity) => entity.fields);
|
||||
const refuted = this.#refutedSorts.get(view.type);
|
||||
for (const rule of rules.filter(
|
||||
(candidate) =>
|
||||
(current === undefined || !same(candidate, current)) &&
|
||||
refuted?.has(JSON.stringify(candidate)) !== true &&
|
||||
keptInOrder(kept, candidate),
|
||||
)) {
|
||||
// A create placed by another sort shows the collection is not kept in this order.
|
||||
if (
|
||||
creates.some(
|
||||
({ candidates }) =>
|
||||
candidates.some((candidate) => candidate.rule === "sorted") &&
|
||||
!candidates.some((candidate) => same(candidate, rule)),
|
||||
)
|
||||
)
|
||||
continue;
|
||||
// Serving moves each written entity in turn, against the others' values before their own writes.
|
||||
const before = this.#state.replay(
|
||||
view.type,
|
||||
line.collection,
|
||||
rule,
|
||||
this.#pending,
|
||||
);
|
||||
const outcome = this.#check(view, record);
|
||||
if (outcome?.pass === true) {
|
||||
for (const draft of creates) draft.plan.insert = rule;
|
||||
this.#learnedSorts.set(view.type, rule);
|
||||
return outcome;
|
||||
}
|
||||
this.#state.restore(view.type, line.collection, before);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Note the sorts by a field a pending write changed that this passing whole list in collection order rules out. */
|
||||
#refuteSorts(
|
||||
view: Extract<StateView, { shape: "list" }>,
|
||||
record: StateRecordFacts,
|
||||
): void {
|
||||
const line = this.#line(view, record);
|
||||
if (
|
||||
line === undefined ||
|
||||
this.#wholeListing(view, record, () => true) === undefined
|
||||
)
|
||||
return;
|
||||
const members = new Map(
|
||||
this.#state
|
||||
.members(view.type, line.collection)
|
||||
.map((entity) => [
|
||||
entityTarget(entity.type, entity.collection, entity.id),
|
||||
entity.fields,
|
||||
]),
|
||||
);
|
||||
const writes = this.#pending.filter((write) => members.has(write.target));
|
||||
const fields = new Set<string>();
|
||||
for (const [index, { target, before }] of writes.entries()) {
|
||||
if (before === null) continue;
|
||||
const after =
|
||||
writes.slice(index + 1).find((later) => later.target === target)
|
||||
?.before ?? members.get(target)!;
|
||||
for (const field of Object.keys(after))
|
||||
if (!same(ownMember(before, field), ownMember(after, field)))
|
||||
fields.add(field);
|
||||
}
|
||||
const order = [...members.keys()];
|
||||
const refuted = this.#refutedSorts.get(view.type) ?? new Set<string>();
|
||||
for (const field of fields)
|
||||
for (const direction of ["ascending", "descending"] as const) {
|
||||
const rule = { rule: "sorted", field, direction } as const;
|
||||
const replayed = this.#state.replayedOrder(
|
||||
view.type,
|
||||
line.collection,
|
||||
rule,
|
||||
writes,
|
||||
);
|
||||
if (replayed !== undefined && !same(replayed, order))
|
||||
refuted.add(JSON.stringify(rule));
|
||||
}
|
||||
if (refuted.size > 0) this.#refutedSorts.set(view.type, refuted);
|
||||
}
|
||||
|
||||
/** The successful check under another create insertion candidate, including its possibly different query-page coverage. */
|
||||
#retryInsert(
|
||||
view: Extract<StateView, { shape: "list" }>,
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
timedCaptureEvidence,
|
||||
type TimedCaptureRow,
|
||||
} from "../../../tests/fixtures/analysisView.js";
|
||||
import {
|
||||
BULK_EMPTY_TAG_ROWS,
|
||||
CREATION_ORDER_ROWS,
|
||||
DERIVED_TEXT_ROWS,
|
||||
formLabelRows,
|
||||
ID_OR_TITLE_ROWS,
|
||||
LISTED_ID_ROWS,
|
||||
MINIMAL_CONTACT_ROWS,
|
||||
NOTE_DEFAULT_ROWS,
|
||||
RENAMED_LABEL_ROWS,
|
||||
SHELF_SORTED_ROWS,
|
||||
SHELF_TIE_ROWS,
|
||||
SHELF_GUESS_ROWS,
|
||||
ID_SORTED_READ_ROWS,
|
||||
UNSORTED_PENDING_ROWS,
|
||||
IN_PLACE_THEN_LAST_ROWS,
|
||||
IN_PLACE_THEN_SORTED_ROWS,
|
||||
TITLE_ORDER_ROWS,
|
||||
UNLOADED_ANSWER_ROWS,
|
||||
UNSORTED_SEED_ROWS,
|
||||
CONFIRMED_RENAME_ROWS,
|
||||
UNPLACED_CREATE_ROWS,
|
||||
} from "../../../tests/fixtures/environmentStateFormWrites.js";
|
||||
import { environmentStateViewOf } from "../../../tests/fixtures/environmentStateWrites.js";
|
||||
import { mergedCreatePlan } from "./environmentStateWriteCreateCalls.js";
|
||||
import { resortedMembers } from "./environmentStateWriteExplain.js";
|
||||
import type { WriteBody } from "./environmentStateWriteRules.js";
|
||||
|
||||
const planOf = (rows: readonly TimedCaptureRow[], tool: string) => {
|
||||
const plan = environmentStateViewOf(timedCaptureEvidence(rows)).writes.find(
|
||||
(candidate) => candidate.tool === tool,
|
||||
);
|
||||
if (plan === undefined) throw new Error(`No plan for ${tool}`);
|
||||
return plan;
|
||||
};
|
||||
|
||||
const BY_NAME = { rule: "sorted", field: "name", direction: "ascending" };
|
||||
|
||||
describe("create members a form sent empty", () => {
|
||||
it("requires only the members some recorded create filled in, and applies the others as they were sent", () => {
|
||||
expect(planOf(formLabelRows(), "post_labels")).toMatchObject({
|
||||
served: true,
|
||||
body: {
|
||||
accepted: ["name", "description", "color", "icon"],
|
||||
required: ["name"],
|
||||
blank: [
|
||||
{ member: "color", sent: "" },
|
||||
{ member: "icon", sent: "" },
|
||||
{ member: "description", sent: "" },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts either spelling of a member two creates sent empty differently, filling the earliest", () => {
|
||||
const body: WriteBody = {
|
||||
accepted: ["name", "note"],
|
||||
fixed: [],
|
||||
required: ["name"],
|
||||
identity: null,
|
||||
kinds: [],
|
||||
blank: [{ member: "note", sent: "" }],
|
||||
};
|
||||
const rules = [
|
||||
{ field: "id", rule: "identity", generator: "sequence" },
|
||||
{ field: "name", rule: "echo" },
|
||||
{ field: "note", rule: "echo" },
|
||||
] as const;
|
||||
const merged = mergedCreatePlan(
|
||||
{ rules: [...rules], body },
|
||||
{ id: 1, name: "One", note: "" },
|
||||
{
|
||||
entity: { id: 2, name: "Two", note: null },
|
||||
body: { name: "Two", note: null },
|
||||
rules: [...rules],
|
||||
contract: { ...body, blank: [{ member: "note", sent: null }] },
|
||||
},
|
||||
);
|
||||
expect(merged?.body).toMatchObject({
|
||||
required: ["name"],
|
||||
blank: [
|
||||
{ member: "note", sent: "" },
|
||||
{ member: "note", sent: null },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty values and defaults", () => {
|
||||
it("keeps accepting a recorded empty value after a later call that left the member out teaches its default", () => {
|
||||
expect(planOf(NOTE_DEFAULT_ROWS, "post_notes")).toMatchObject({
|
||||
served: true,
|
||||
body: {
|
||||
required: ["name"],
|
||||
defaults: [{ member: "note", value: "" }],
|
||||
blank: [{ member: "note", sent: null }],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("a create with only what a person must give", () => {
|
||||
it("serves a create whose minimal call left string members `null` and an empty flag spelled another way", () => {
|
||||
expect(planOf(MINIMAL_CONTACT_ROWS, "post_contacts")).toMatchObject({
|
||||
served: true,
|
||||
body: {
|
||||
required: ["lastName"],
|
||||
blank: expect.arrayContaining([
|
||||
{ member: "firstName", sent: null },
|
||||
{ member: "email", sent: null },
|
||||
{ member: "emailOptedOut", sent: false },
|
||||
{ member: "emailOptedOut", sent: null },
|
||||
]),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("collections kept sorted", () => {
|
||||
it("moves an updated entity by the field its collection's creates insert by", () => {
|
||||
expect(planOf(RENAMED_LABEL_ROWS, "patch_labels_by_id")).toMatchObject({
|
||||
served: true,
|
||||
insert: BY_NAME,
|
||||
});
|
||||
});
|
||||
|
||||
it("learns the sort from a rename that moves a folder, and inserts creates by it", () => {
|
||||
const byTitle = { rule: "sorted", field: "title", direction: "ascending" };
|
||||
expect(planOf(TITLE_ORDER_ROWS, "put_folders_by_id")).toMatchObject({
|
||||
served: true,
|
||||
insert: byTitle,
|
||||
});
|
||||
expect(planOf(TITLE_ORDER_ROWS, "post_folders")).toMatchObject({
|
||||
served: true,
|
||||
insert: byTitle,
|
||||
});
|
||||
});
|
||||
|
||||
it("learns the title order from a rename when the creates fit both id and title order", () => {
|
||||
const byTitle = { rule: "sorted", field: "title", direction: "ascending" };
|
||||
expect(planOf(ID_OR_TITLE_ROWS, "put_folders_by_id")).toMatchObject({
|
||||
served: true,
|
||||
insert: byTitle,
|
||||
});
|
||||
expect(planOf(ID_OR_TITLE_ROWS, "post_folders")).toMatchObject({
|
||||
insert: byTitle,
|
||||
});
|
||||
});
|
||||
|
||||
it("learns no sort from a rename that an earlier read of the creates' order rules out", () => {
|
||||
const byTitle = { rule: "sorted", field: "title", direction: "ascending" };
|
||||
expect(planOf(CREATION_ORDER_ROWS, "post_folders").insert).not.toEqual(
|
||||
byTitle,
|
||||
);
|
||||
expect(planOf(CREATION_ORDER_ROWS, "put_folders_by_id")).toMatchObject({
|
||||
served: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("learns the name order from a rename when a read sorted by its query placed the create", () => {
|
||||
const byName = { rule: "sorted", field: "name", direction: "ascending" };
|
||||
expect(planOf(SHELF_SORTED_ROWS, "put_books_by_id")).toMatchObject({
|
||||
served: true,
|
||||
insert: byName,
|
||||
});
|
||||
});
|
||||
|
||||
it("learns no sort from a rename that moving the renamed entity alone cannot reach", () => {
|
||||
const plan = planOf(UNSORTED_SEED_ROWS, "put_folders_by_id");
|
||||
expect(plan.served).toBe(false);
|
||||
expect(plan.insert).toBeNull();
|
||||
});
|
||||
|
||||
it("gives a rename no sort that only an unplaced create's guess shows", () => {
|
||||
expect(
|
||||
planOf(UNPLACED_CREATE_ROWS, "patch_labels_by_id").insert,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("learns no sort from a rename when an earlier confirmed rename left its entity out of that order", () => {
|
||||
const plan = planOf(CONFIRMED_RENAME_ROWS, "put_folders_by_id");
|
||||
expect(plan.served).toBe(false);
|
||||
expect(plan.insert).toBeNull();
|
||||
});
|
||||
|
||||
it("learns no sort from a read its query sorts, whose ties need not follow the collection's order", () => {
|
||||
expect(planOf(SHELF_TIE_ROWS, "put_books_by_id").insert).toBeNull();
|
||||
});
|
||||
|
||||
it("withholds a rename whose read only an unplaced create's guessed order explains", () => {
|
||||
expect(planOf(SHELF_GUESS_ROWS, "put_books_by_id").served).toBe(false);
|
||||
});
|
||||
|
||||
it("learns no sort from renames whose moves, made in turn as serving makes them, do not reach the list", () => {
|
||||
const plan = planOf(UNSORTED_PENDING_ROWS, "put_folders_by_id");
|
||||
expect(plan.served).toBe(false);
|
||||
expect(plan.insert).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Delta then Zulu", IN_PLACE_THEN_LAST_ROWS],
|
||||
["Zulu then Delta", IN_PLACE_THEN_SORTED_ROWS],
|
||||
])(
|
||||
"learns no sort that a list showing an earlier rename in place rules out (%s)",
|
||||
(_name, rows) => {
|
||||
const plan = planOf(rows, "patch_labels_by_id");
|
||||
expect(plan.served).toBe(false);
|
||||
expect(plan.insert).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("learns the sort from a rename after a read its query sorts by id showed an earlier rename", () => {
|
||||
expect(planOf(ID_SORTED_READ_ROWS, "put_books_by_id")).toMatchObject({
|
||||
served: true,
|
||||
insert: BY_NAME,
|
||||
});
|
||||
});
|
||||
|
||||
it("serves a bulk update whose recordings echo the empty values each call sent", () => {
|
||||
expect(planOf(BULK_EMPTY_TAG_ROWS, "patch_labels")).toMatchObject({
|
||||
served: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("moves each written entity in turn, against the others' values before their own writes", () => {
|
||||
const entity = (id: string, name: string) => ({ id, fields: { name } });
|
||||
const [a, b, c, d] = ["a", "b", "c", "d"].map((name, index) =>
|
||||
entity(String(index + 1), name),
|
||||
) as [ReturnType<typeof entity>, ...ReturnType<typeof entity>[]];
|
||||
const order = resortedMembers(
|
||||
{ rule: "sorted", field: "name", direction: "ascending" },
|
||||
[a, b!, c!, d!],
|
||||
[
|
||||
{ before: b!, after: entity("2", "h") },
|
||||
{ before: c!, after: entity("3", "i") },
|
||||
],
|
||||
);
|
||||
expect(order.map(({ fields }) => fields["name"])).toEqual([
|
||||
"a",
|
||||
"d",
|
||||
"h",
|
||||
"i",
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders a list envelope's ids of the listed entities from the state", () => {
|
||||
const view = environmentStateViewOf(
|
||||
timedCaptureEvidence(LISTED_ID_ROWS),
|
||||
).views.find((candidate) => candidate.tool === "get_tags");
|
||||
expect(view).toMatchObject({
|
||||
shape: "list",
|
||||
listed_ids: [{ member: "all", field: "id" }],
|
||||
});
|
||||
expect(planOf(LISTED_ID_ROWS, "post_tags")).toMatchObject({
|
||||
served: true,
|
||||
insert: BY_NAME,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("answers that show stored values otherwise than reads", () => {
|
||||
it("takes the later read's values where a create's answer had not loaded them", () => {
|
||||
expect(planOf(UNLOADED_ANSWER_ROWS, "post_tasks")).toMatchObject({
|
||||
served: true,
|
||||
observation: { read: { shape: "item" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("reads an empty list spelled `[]` in an update's answer and `null` in reads as unchanged", () => {
|
||||
expect(planOf(UNLOADED_ANSWER_ROWS, "patch_tasks_by_id")).toMatchObject({
|
||||
served: true,
|
||||
rules: [{ field: "done", rule: "echo" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("withholds a later create that stored an unruled field otherwise, without blocking checks of later writes", () => {
|
||||
expect(planOf(DERIVED_TEXT_ROWS, "post_tags")).toMatchObject({
|
||||
served: false,
|
||||
reason: "unexplained-change",
|
||||
});
|
||||
expect(planOf(DERIVED_TEXT_ROWS, "delete_tags_by_id")).toMatchObject({
|
||||
served: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
same,
|
||||
scalarText,
|
||||
type JsonObject,
|
||||
type ListedIds,
|
||||
type RecordedViewReason,
|
||||
type ReplayKey,
|
||||
type StateEntity,
|
||||
@@ -64,6 +65,10 @@ export interface StatePlan {
|
||||
readonly projected?: readonly number[];
|
||||
/** Item lines first recorded after the initial reads whose identity they show, decided against the lines left once variants are dropped. */
|
||||
readonly late?: readonly LateLine[];
|
||||
/** The members later plain item reads show of the entity they name. */
|
||||
readonly laterFields?: readonly string[];
|
||||
/** The member the tool's reads set `true` on an entity the application soft-deleted. */
|
||||
readonly deletedFlag?: StateItemView["deleted_flag"];
|
||||
/** The query keys that pick calls of the replayed reads out. */
|
||||
readonly projectedKeys?: readonly ReplayKey[];
|
||||
}
|
||||
@@ -94,6 +99,8 @@ export interface StateGateContext {
|
||||
id: string,
|
||||
) => StateEntity | undefined;
|
||||
readonly inCollection: (type: string, collection: string) => StateEntity[];
|
||||
/** Every field an initial entity of the type holds, from any view. */
|
||||
readonly heldFields?: (type: string) => ReadonlySet<string>;
|
||||
readonly volatileOf: (type: string) => ReadonlySet<string>;
|
||||
readonly idField: (type: string) => string;
|
||||
/** By path, the top-level members each response there mints; views of other paths keep them. */
|
||||
@@ -120,6 +127,13 @@ class FieldUnion {
|
||||
: new Set([...this.#required].filter((key) => keys.has(key)));
|
||||
}
|
||||
|
||||
/** Add a field some object showed without changing which fields every object carries. */
|
||||
note(key: string): void {
|
||||
if (this.#seen.has(key)) return;
|
||||
this.#seen.add(key);
|
||||
this.fields.push(key);
|
||||
}
|
||||
|
||||
get required(): string[] {
|
||||
return this.fields.filter((key) => this.#required?.has(key) === true);
|
||||
}
|
||||
@@ -194,6 +208,11 @@ export const checkItemPlan = (
|
||||
const object = body === undefined ? undefined : at(body, plan.pointer);
|
||||
if (isObject(object)) union.add(object);
|
||||
});
|
||||
// A field only later reads show, which no initial entity holds, is the view's too.
|
||||
const held = context.heldFields?.(plan.type);
|
||||
if (held !== undefined)
|
||||
for (const member of plan.laterFields ?? [])
|
||||
if (!held.has(member)) union.note(member);
|
||||
context.retain(union.bytes);
|
||||
const view = { tool: plan.tool.name, fields: union.fields };
|
||||
const envelope: { skeleton?: string; members?: string[] } = {};
|
||||
@@ -216,6 +235,9 @@ export const checkItemPlan = (
|
||||
fields: union.fields,
|
||||
required_fields: union.required,
|
||||
envelope_ordinal: plan.latest[0]!.ordinal,
|
||||
...(plan.deletedFlag === undefined
|
||||
? {}
|
||||
: { deleted_flag: plan.deletedFlag }),
|
||||
...declaredMembers(envelope.members, context.retain),
|
||||
checked_record_ordinals: plan.latest.map((record) => record.ordinal),
|
||||
...replayedReads(plan),
|
||||
@@ -303,6 +325,7 @@ const listReproduces = (
|
||||
readonly fields: string[];
|
||||
readonly pointer: string;
|
||||
readonly counters: string[];
|
||||
readonly listed_ids: ListedIds[];
|
||||
readonly whole: ReadonlySet<number>;
|
||||
readonly query: ListQuery | undefined;
|
||||
};
|
||||
@@ -368,13 +391,18 @@ export const checkListPlan = (
|
||||
plan: StatePlan,
|
||||
): StateView => {
|
||||
const union = new FieldUnion();
|
||||
const { whole, counters, minted } = listFacts(context, plan, union);
|
||||
const { whole, counters, minted, listedIds } = listFacts(
|
||||
context,
|
||||
plan,
|
||||
union,
|
||||
);
|
||||
context.retain(union.bytes);
|
||||
const base = {
|
||||
tool: plan.tool.name,
|
||||
fields: union.fields,
|
||||
pointer: plan.pointer,
|
||||
counters,
|
||||
listed_ids: listedIds,
|
||||
whole,
|
||||
};
|
||||
let query = listQuery(context, plan, whole);
|
||||
@@ -415,6 +443,7 @@ export const checkListPlan = (
|
||||
.filter((ordinal) => whole.has(ordinal)),
|
||||
}),
|
||||
counters,
|
||||
...(listedIds.length === 0 ? {} : { listed_ids: listedIds }),
|
||||
...(query === undefined ? {} : { query }),
|
||||
...declaredMembers(minted, context.retain),
|
||||
checked_record_ordinals: plan.latest.map((record) => record.ordinal),
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
timedCaptureEvidence,
|
||||
type TimedCaptureRow,
|
||||
} from "../../../tests/fixtures/analysisView.js";
|
||||
import {
|
||||
GHOST_DELETED_ROWS,
|
||||
LATER_FIELD_ROWS,
|
||||
LATER_MIXED_BULK_ROWS,
|
||||
MAX_SIZE_ROWS,
|
||||
SOFT_DELETED_QUERY_ROWS,
|
||||
SOFT_DELETED_ROWS,
|
||||
} from "../../../tests/fixtures/environmentStateLaterReads.js";
|
||||
import { environmentStateViewOf } from "../../../tests/fixtures/environmentStateWrites.js";
|
||||
import { markedDeleted } from "./environmentStateRules.js";
|
||||
|
||||
const viewOf = (rows: readonly TimedCaptureRow[]) =>
|
||||
environmentStateViewOf(timedCaptureEvidence(rows));
|
||||
|
||||
const planOf = (rows: readonly TimedCaptureRow[], tool: string) => {
|
||||
const plan = viewOf(rows).writes.find((candidate) => candidate.tool === tool);
|
||||
if (plan === undefined) throw new Error(`No plan for ${tool}`);
|
||||
return plan;
|
||||
};
|
||||
|
||||
describe("deletes the application answers for afterwards", () => {
|
||||
it("confirms a delete by an item read that shows the entity marked deleted", () => {
|
||||
expect(planOf(SOFT_DELETED_ROWS, "delete_contacts_by_id")).toMatchObject({
|
||||
served: true,
|
||||
checked_record_ordinals: [5, 7],
|
||||
});
|
||||
});
|
||||
|
||||
it("withholds a soft delete when its read changes another field", () => {
|
||||
const rows: readonly TimedCaptureRow[] = SOFT_DELETED_ROWS.map((row) =>
|
||||
row[0] === "GET" &&
|
||||
row[1] === "/api/contacts/2" &&
|
||||
typeof row[2] === "object" &&
|
||||
row[2] !== null &&
|
||||
!Array.isArray(row[2]) &&
|
||||
row[2]["deleted"] === true
|
||||
? [row[0], row[1], { ...row[2], name: "Changed by deletion" }]
|
||||
: row,
|
||||
);
|
||||
expect(planOf(rows, "delete_contacts_by_id")).toMatchObject({
|
||||
served: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("learns on the item view the flag later reads set on a soft-deleted contact", () => {
|
||||
const item = (rows: readonly TimedCaptureRow[]) =>
|
||||
viewOf(rows).views.find((view) => view.tool === "get_contacts_by_id");
|
||||
expect(item(SOFT_DELETED_ROWS)).toMatchObject({
|
||||
shape: "item",
|
||||
deleted_flag: "deleted",
|
||||
});
|
||||
// Reads with a query, such as a cache-busting parameter, show the flag as well.
|
||||
expect(item(SOFT_DELETED_QUERY_ROWS)).toMatchObject({
|
||||
deleted_flag: "deleted",
|
||||
});
|
||||
expect(item(MAX_SIZE_ROWS)).not.toHaveProperty("deleted_flag");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ deleted: true }, true],
|
||||
[{ isDeleted: true }, true],
|
||||
[{ is_deleted: true }, true],
|
||||
[{ deleted: false }, false],
|
||||
[{ deleted: "true" }, false],
|
||||
[{ deleted_at: "2026-10-26T10:00:00Z" }, false],
|
||||
])("reads %j as soft-deleted: %s", (object, expected) => {
|
||||
expect(markedDeleted(object)).toBe(expected);
|
||||
});
|
||||
|
||||
it("drops a held entity a read shows marked deleted, so a later list without it confirms another delete", () => {
|
||||
expect(planOf(GHOST_DELETED_ROWS, "delete_contacts_by_id")).toMatchObject({
|
||||
served: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("checks a list read whose page size is named `maxSize`", () => {
|
||||
expect(planOf(MAX_SIZE_ROWS, "delete_contacts_by_id")).toMatchObject({
|
||||
served: true,
|
||||
checked_record_ordinals: [4, 6],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fields only later reads show", () => {
|
||||
it("adds a field only created entities show to the item view, without requiring it", () => {
|
||||
const view = viewOf(LATER_FIELD_ROWS).views.find(
|
||||
(candidate) => candidate.tool === "get_tasks_by_id",
|
||||
);
|
||||
expect(view).toMatchObject({
|
||||
shape: "item",
|
||||
fields: ["id", "title", "done", "subscription"],
|
||||
required_fields: ["id", "title", "done"],
|
||||
});
|
||||
});
|
||||
|
||||
it("serves an update whose answers show a field initial entities lack", () => {
|
||||
expect(planOf(LATER_FIELD_ROWS, "patch_tasks_by_id")).toMatchObject({
|
||||
served: true,
|
||||
checked_record_ordinals: [9, 11, 12],
|
||||
});
|
||||
});
|
||||
|
||||
it("replays a bulk update whose answer shows a later-only field on created entities only", () => {
|
||||
expect(planOf(LATER_MIXED_BULK_ROWS, "patch_tasks")).toMatchObject({
|
||||
served: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,12 +12,14 @@ import {
|
||||
collectionPathOf,
|
||||
isObject,
|
||||
listItems,
|
||||
listsIds,
|
||||
pointerTo,
|
||||
pointerTokens,
|
||||
recordedItemCount,
|
||||
same,
|
||||
scalarText,
|
||||
type JsonObject,
|
||||
type ListedIds,
|
||||
} from "./environmentStateRules.js";
|
||||
|
||||
/** The latest recordings that list their whole collection in state order, and the counters that equal the item count on each of them, or on every line when none does. */
|
||||
@@ -29,10 +31,12 @@ export const listFacts = (
|
||||
readonly whole: ReadonlySet<number>;
|
||||
readonly counters: string[];
|
||||
readonly minted: string[];
|
||||
readonly listedIds: ListedIds[];
|
||||
} => {
|
||||
const field = context.idField(plan.type);
|
||||
const whole = new Set<number>();
|
||||
const counting = new Map<number, string[]>();
|
||||
const listing = new Set<string>();
|
||||
const names = mintedFor(context.minted, plan.latest);
|
||||
const minted = new Set<string>();
|
||||
for (const record of plan.latest)
|
||||
@@ -44,6 +48,14 @@ export const listFacts = (
|
||||
minted.add(name);
|
||||
items.forEach((item) => union.add(item));
|
||||
const parent = plan.pointer === "" || !isObject(body) ? {} : body;
|
||||
// A member listing the ids of two or more items does so by design, not by chance.
|
||||
if (items.length >= 2)
|
||||
for (const [name, value] of Object.entries(parent))
|
||||
if (
|
||||
name !== pointerTokens(plan.pointer)[0] &&
|
||||
listsIds(value, items, field)
|
||||
)
|
||||
listing.add(name);
|
||||
const counted = itemTotals(parent, plan.pointer).filter(
|
||||
([, value]) => recordedItemCount(value) !== undefined,
|
||||
);
|
||||
@@ -72,7 +84,12 @@ export const listFacts = (
|
||||
// Counter presence is per envelope. Keep every observed page counter;
|
||||
// renderStateList updates only one the selected base actually counted.
|
||||
const counters = [...new Set([...counting.values()].flat())].sort(compare);
|
||||
return { whole, counters, minted: [...minted].sort(compare) };
|
||||
return {
|
||||
whole,
|
||||
counters,
|
||||
minted: [...minted].sort(compare),
|
||||
listedIds: [...listing].sort(compare).map((member) => ({ member, field })),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -46,6 +46,9 @@ const LIMIT = new Set([
|
||||
"count",
|
||||
"itemsperpage",
|
||||
"limit",
|
||||
"max_results",
|
||||
"maxresults",
|
||||
"maxsize",
|
||||
"page_size",
|
||||
"pagesize",
|
||||
"per_page",
|
||||
@@ -601,7 +604,7 @@ export const queryTotalCount = (
|
||||
|
||||
/** A query answer: the base line's envelope with the selected entities, counters at the number listed and totals at the number matched. */
|
||||
export const renderQueryList = (
|
||||
view: Pick<StateListView, "pointer" | "counters"> & {
|
||||
view: Pick<StateListView, "pointer" | "counters" | "listed_ids"> & {
|
||||
readonly query: Pick<ListQuery, "totals">;
|
||||
},
|
||||
base: JsonValue,
|
||||
@@ -615,5 +618,5 @@ export const renderQueryList = (
|
||||
? body
|
||||
: withCounter(body, name, formatItemCount(total, recorded!));
|
||||
},
|
||||
renderStateList(view, base, items),
|
||||
renderStateList(view, base, items, total === items.length),
|
||||
);
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
/** Query parameters that page or bound a list without filtering it. */
|
||||
const PAGING_PARAMETERS = new Set([
|
||||
"limit",
|
||||
"max_results",
|
||||
"maxresults",
|
||||
"maxsize",
|
||||
"offset",
|
||||
"page",
|
||||
"per_page",
|
||||
@@ -44,6 +47,9 @@ export const startsAtBeginning = (query: string | undefined): boolean => {
|
||||
/** Paging parameters that bound how many items a page holds. */
|
||||
const PAGE_SIZE_PARAMETERS = new Set([
|
||||
"limit",
|
||||
"max_results",
|
||||
"maxresults",
|
||||
"maxsize",
|
||||
"per_page",
|
||||
"perpage",
|
||||
"page_size",
|
||||
|
||||
@@ -155,6 +155,8 @@ export type StateItemView = EntityViewPlan &
|
||||
ReplayedReads & {
|
||||
readonly shape: "item";
|
||||
readonly envelope_ordinal: number;
|
||||
/** The member its reads set `true` on an entity the application soft-deleted and still answers by id. */
|
||||
readonly deleted_flag?: (typeof DELETED_FLAGS)[number] | undefined;
|
||||
};
|
||||
|
||||
/** A query parameter that asks for some fields only (`fields`, `fields[...]`, `select`, `$select`). */
|
||||
@@ -189,10 +191,46 @@ export type StateListView = EntityViewPlan &
|
||||
/** With recorded membership, the checked lines that each list their whole collection. */
|
||||
readonly all_record_ordinals?: number[] | undefined;
|
||||
readonly counters: string[];
|
||||
/** Top-level envelope members that hold the listed entities' values of an identity field, in list order. */
|
||||
readonly listed_ids?: ListedIds[] | undefined;
|
||||
/** The query the view's other lines follow, when one explains any of them. */
|
||||
readonly query?: ListQuery | undefined;
|
||||
};
|
||||
|
||||
/** Members an application sets `true` on an entity it has soft-deleted but still answers for by id. */
|
||||
export const DELETED_FLAGS = ["deleted", "isDeleted", "is_deleted"] as const;
|
||||
|
||||
/** The member an item read's object sets `true` to mark the entity soft-deleted, if it does. */
|
||||
export const deletedFlag = (
|
||||
object: JsonValue | undefined,
|
||||
): (typeof DELETED_FLAGS)[number] | undefined =>
|
||||
isObject(object)
|
||||
? DELETED_FLAGS.find((name) => ownMember(object, name) === true)
|
||||
: undefined;
|
||||
|
||||
/** Whether an item read's object is one the application has soft-deleted, which says the entity is gone. */
|
||||
export const markedDeleted = (object: JsonValue | undefined): boolean =>
|
||||
deletedFlag(object) !== undefined;
|
||||
|
||||
/** An envelope member listing an identity field of each listed entity, as Paperless's `all` lists ids. */
|
||||
export interface ListedIds {
|
||||
readonly member: string;
|
||||
readonly field: string;
|
||||
}
|
||||
|
||||
/** Whether a value lists the items' values of a field, in their order. */
|
||||
export const listsIds = (
|
||||
value: JsonValue | undefined,
|
||||
items: readonly JsonObject[],
|
||||
field: string,
|
||||
): boolean =>
|
||||
Array.isArray(value) &&
|
||||
value.length === items.length &&
|
||||
items.every((item, index) => {
|
||||
const id = ownMember(item, field);
|
||||
return id !== undefined && same(value[index], id);
|
||||
});
|
||||
|
||||
/** Whether a list view answers on a checked line with every entity of the collection. */
|
||||
export const listsAll = (view: StateListView, ordinal: number): boolean =>
|
||||
view.membership === "all" ||
|
||||
@@ -782,15 +820,18 @@ export const missingRequiredFields = (
|
||||
|
||||
/**
|
||||
* A list response rendered from `base`: its array replaced by `items`, and set to their number each counter that
|
||||
* counted the items `base` listed; one that did not, such as a partial page's total, keeps its recorded value.
|
||||
* counted the items `base` listed; one that did not, such as a partial page's total, keeps its recorded value. Each
|
||||
* member that listed the ids of the items `base` listed lists theirs, unless `items` are a page of more matches.
|
||||
*/
|
||||
export const renderStateList = (
|
||||
view: Pick<StateListView, "pointer" | "counters">,
|
||||
view: Pick<StateListView, "pointer" | "counters" | "listed_ids">,
|
||||
base: JsonValue,
|
||||
items: readonly JsonObject[],
|
||||
whole = true,
|
||||
): JsonValue => {
|
||||
const listed = listItems(base, view.pointer)?.length;
|
||||
return view.counters.reduce(
|
||||
const shown = listItems(base, view.pointer);
|
||||
const listed = shown?.length;
|
||||
const counted = view.counters.reduce(
|
||||
(body, name) => {
|
||||
const observed = counterAt(base, name);
|
||||
if (listed === undefined || recordedItemCount(observed) !== listed)
|
||||
@@ -799,4 +840,20 @@ export const renderStateList = (
|
||||
},
|
||||
replaceAt(base, view.pointer, [...items]),
|
||||
);
|
||||
if (!whole || shown === undefined || !isObject(counted)) return counted;
|
||||
const lists = (view.listed_ids ?? []).filter(
|
||||
({ member, field }) =>
|
||||
isObject(base) &&
|
||||
listsIds(ownMember(base, member), shown, field) &&
|
||||
items.every((item) => ownMember(item, field) !== undefined),
|
||||
);
|
||||
if (lists.length === 0) return counted;
|
||||
const body: JsonObject = { ...counted };
|
||||
for (const { member, field } of lists)
|
||||
setOwnMember(
|
||||
body,
|
||||
member,
|
||||
items.map((item) => ownMember(item, field)!),
|
||||
);
|
||||
return body;
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
UnsignedAnalysisView,
|
||||
} from "./analysisView.js";
|
||||
import { EnvironmentStateAccumulator } from "./EnvironmentStateAccumulator.js";
|
||||
import { DELETED_FLAGS } from "./environmentStateRules.js";
|
||||
import { isClockZone } from "./environmentStateWriteValues.js";
|
||||
import { CLOCK_PATTERNS } from "./environmentStateWriteClockFormats.js";
|
||||
import {
|
||||
@@ -168,6 +169,12 @@ const viewSchema = z.discriminatedUnion("shape", [
|
||||
.describe(
|
||||
"Record whose response, with the entity object replaced, every item response shares.",
|
||||
),
|
||||
deleted_flag: z
|
||||
.enum(DELETED_FLAGS)
|
||||
.optional()
|
||||
.describe(
|
||||
"Member its reads set true on an entity the application soft-deleted.",
|
||||
),
|
||||
...replayedReadFields,
|
||||
}),
|
||||
z.strictObject({
|
||||
@@ -189,6 +196,12 @@ const viewSchema = z.discriminatedUnion("shape", [
|
||||
.describe(
|
||||
"Envelope members that count the listed entities: a top-level member's name, or a JSON pointer to one in a top-level object beside the list (`/pageInfo/totalRows`); a top-level name starting with `/` is written as its pointer (`/~1total`).",
|
||||
),
|
||||
listed_ids: z
|
||||
.array(z.strictObject({ member: z.string(), field: z.string() }))
|
||||
.optional()
|
||||
.describe(
|
||||
"Top-level envelope members listing each listed entity's identity field in list order, as Paperless's `all` does.",
|
||||
),
|
||||
query: listQuerySchema
|
||||
.optional()
|
||||
.describe(
|
||||
@@ -362,6 +375,12 @@ const writePlanSchema = z.strictObject({
|
||||
ordinal: ordinalSchema,
|
||||
pointer: z.string(),
|
||||
shape: z.enum(["item", "list"]),
|
||||
unloaded: z
|
||||
.array(z.string())
|
||||
.exactOptional()
|
||||
.describe(
|
||||
"JSON pointers into the entity to values the response had not loaded, which answers keep as the response showed them.",
|
||||
),
|
||||
})
|
||||
.exactOptional()
|
||||
.describe(
|
||||
@@ -397,10 +416,13 @@ const writePlanSchema = z.strictObject({
|
||||
defaults: z
|
||||
.array(z.strictObject({ member: z.string(), value: jsonValueSchema }))
|
||||
.optional(),
|
||||
blank: z
|
||||
.array(z.strictObject({ member: z.string(), sent: 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), 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).",
|
||||
"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), the value a create stores for an accepted member a call leaves out, as a call that left it out showed (defaults), and members a recorded create sent empty, which a call that leaves them out is applied as sending (blank).",
|
||||
),
|
||||
rules: z
|
||||
.array(fieldRuleSchema)
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ownMember,
|
||||
at,
|
||||
isObject,
|
||||
markedDeleted,
|
||||
stateValueRetentionBytes,
|
||||
scalarText,
|
||||
type StateEntity,
|
||||
@@ -88,6 +89,7 @@ export class UnseenEntities {
|
||||
const idField = this.facts.idField(view.type);
|
||||
if (
|
||||
!isObject(object) ||
|
||||
markedDeleted(object) ||
|
||||
idField === undefined ||
|
||||
scalarText(ownMember(object, idField)) !== id
|
||||
)
|
||||
|
||||
@@ -12,15 +12,17 @@ import {
|
||||
scalarText,
|
||||
type JsonObject,
|
||||
type StateEntity,
|
||||
type StateView,
|
||||
} from "./environmentStateRules.js";
|
||||
import type {
|
||||
FieldRule,
|
||||
GeneratedRule,
|
||||
JsonKind,
|
||||
StateWritePlan,
|
||||
WriteBody,
|
||||
WriteCall,
|
||||
WriteResponse,
|
||||
import {
|
||||
emptySpellings,
|
||||
type FieldRule,
|
||||
type GeneratedRule,
|
||||
type JsonKind,
|
||||
type StateWritePlan,
|
||||
type WriteBody,
|
||||
type WriteCall,
|
||||
type WriteResponse,
|
||||
} from "./environmentStateWriteRules.js";
|
||||
import {
|
||||
stampProblem,
|
||||
@@ -148,6 +150,13 @@ export const bodyProblem = (
|
||||
}
|
||||
if (!body.accepted.includes(member))
|
||||
return `Member ${member} is not one the capture showed stored (${bodyContract(body)}).`;
|
||||
// A recorded call sent this member empty, as a form does for a field left as it was.
|
||||
if (
|
||||
body.blank?.some(
|
||||
(entry) => entry.member === member && same(entry.sent, value),
|
||||
) === true
|
||||
)
|
||||
continue;
|
||||
const recorded = ownMember(shapes.entity, member);
|
||||
const kind =
|
||||
recorded === undefined || recorded === null
|
||||
@@ -320,15 +329,36 @@ const copiedShapes = (
|
||||
}),
|
||||
);
|
||||
|
||||
/** A create's call with the members it left out that recorded calls sent empty, as the earliest sent them; one a call that left it out showed a default for keeps that default. */
|
||||
const withBlanks = (body: WriteBody, call: WriteCall): WriteCall => {
|
||||
const given = call.body ?? {};
|
||||
const left = (body.blank ?? []).filter(
|
||||
({ member }, index, blank) =>
|
||||
!Object.hasOwn(given, member) &&
|
||||
!(body.defaults ?? []).some((entry) => entry.member === member) &&
|
||||
blank.findIndex((entry) => entry.member === member) === index,
|
||||
);
|
||||
return left.length === 0
|
||||
? call
|
||||
: {
|
||||
...call,
|
||||
body: {
|
||||
...Object.fromEntries(left.map(({ member, sent }) => [member, sent])),
|
||||
...given,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** The fields of a created entity: the observed entity with every ruled field taken from the call or generated. */
|
||||
export const createdFields = (
|
||||
plan: Pick<StateWritePlan, "rules" | "body">,
|
||||
observed: JsonObject,
|
||||
call: WriteCall,
|
||||
sentCall: WriteCall,
|
||||
generate: GenerateValue,
|
||||
referenced?: ReferencedEntity,
|
||||
): Applied<JsonObject> => {
|
||||
if (plan.body === null) return err("The tool has no modelled body.");
|
||||
const call = withBlanks(plan.body, sentCall);
|
||||
const problem = bodyProblem(plan.body, call, {
|
||||
entity: { ...copiedShapes(plan.rules, observed), ...observed },
|
||||
});
|
||||
@@ -491,6 +521,27 @@ export const optionalFields = (
|
||||
);
|
||||
};
|
||||
|
||||
/** Fields an item view of the type shows that no initial entity of the type holds, which only later reads showed. */
|
||||
export const laterItemFields = (
|
||||
views: Iterable<StateView>,
|
||||
entities: readonly StateEntity[],
|
||||
type: string | null,
|
||||
): Set<string> => {
|
||||
const fields = new Set<string>();
|
||||
for (const view of views)
|
||||
if (view.shape === "item" && view.type === type)
|
||||
for (const field of view.fields)
|
||||
if (!view.required_fields.includes(field)) fields.add(field);
|
||||
for (const entity of entities)
|
||||
if (entity.type === type)
|
||||
for (const held of [
|
||||
entity.fields,
|
||||
...entity.view_fields.map((view) => view.fields),
|
||||
])
|
||||
for (const field of Object.keys(held)) fields.delete(field);
|
||||
return fields;
|
||||
};
|
||||
|
||||
/** 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,
|
||||
@@ -500,8 +551,14 @@ export const writtenResponse = (
|
||||
readonly id: string;
|
||||
/** Generated clocks keep the response's spelling independently of the stored entity's. */
|
||||
readonly clocks?: readonly string[];
|
||||
/** Members the call sent, which the answer shows as stored. */
|
||||
readonly sent?: readonly string[];
|
||||
/** Pointers into the entity to values the recorded answer had not loaded, which the answer keeps as recorded. */
|
||||
readonly unloaded?: readonly string[];
|
||||
},
|
||||
optional: readonly string[] = [],
|
||||
/** Fields only later reads showed, which the answer leaves out when the entity does not hold them. */
|
||||
later: ReadonlySet<string> = new Set(),
|
||||
): Applied<JsonValue | undefined> => {
|
||||
if (response.shape === "empty") return ok(undefined);
|
||||
if (response.shape === "recorded" || recorded === undefined)
|
||||
@@ -522,7 +579,7 @@ export const writtenResponse = (
|
||||
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) => !held(field) && !optional.includes(field),
|
||||
(field) => !held(field) && !optional.includes(field) && !later.has(field),
|
||||
);
|
||||
if (missing.length > 0)
|
||||
return err(
|
||||
@@ -532,32 +589,95 @@ export const writtenResponse = (
|
||||
...Object.keys(shown).filter(held),
|
||||
...optional.filter((field) => !Object.hasOwn(shown, field) && held(field)),
|
||||
];
|
||||
return ok(
|
||||
replaceAt(
|
||||
recorded,
|
||||
pointer,
|
||||
Object.fromEntries(
|
||||
fields.map((field) => {
|
||||
const value = ownMember(written.fields!, field)!;
|
||||
const clock =
|
||||
written.clocks?.includes(field) === true
|
||||
? readClock(ownMember(shown, field))
|
||||
: undefined;
|
||||
return [
|
||||
field,
|
||||
clock === undefined ? value : respellClock(value, clock.format),
|
||||
];
|
||||
}),
|
||||
),
|
||||
),
|
||||
const entity = Object.fromEntries(
|
||||
fields.map((field): [string, JsonValue] => {
|
||||
const value = ownMember(written.fields!, field)!;
|
||||
const answered = ownMember(shown, field);
|
||||
// An answer spells an empty list or map the call did not send as its recording did.
|
||||
if (
|
||||
answered !== undefined &&
|
||||
written.sent?.includes(field) !== true &&
|
||||
emptySpellings(answered, value)
|
||||
)
|
||||
return [field, answered];
|
||||
const clock =
|
||||
written.clocks?.includes(field) === true
|
||||
? readClock(answered)
|
||||
: undefined;
|
||||
return [
|
||||
field,
|
||||
clock === undefined ? value : respellClock(value, clock.format),
|
||||
];
|
||||
}),
|
||||
);
|
||||
const answer = (written.unloaded ?? []).reduce<JsonObject>(
|
||||
(rendered, path) => {
|
||||
const tokens = pointerTokens(path);
|
||||
const kept = memberAt(shown, tokens);
|
||||
if (kept !== undefined) return withMemberAt(rendered, tokens, kept);
|
||||
// A member the answer left out of an object it showed stays out.
|
||||
return isObject(memberAt(shown, tokens.slice(0, -1)))
|
||||
? withoutMemberAt(rendered, tokens)
|
||||
: rendered;
|
||||
},
|
||||
entity,
|
||||
);
|
||||
return ok(replaceAt(recorded, pointer, answer));
|
||||
};
|
||||
|
||||
/** The shared bulk renderer: fixed acknowledgements, or one first-template element per written entity. */
|
||||
/** The value under these member names, nested objects included. */
|
||||
const memberAt = (
|
||||
value: JsonValue | undefined,
|
||||
tokens: readonly string[],
|
||||
): JsonValue | undefined =>
|
||||
tokens.reduce<JsonValue | undefined>(
|
||||
(inner, token) => (isObject(inner) ? ownMember(inner, token) : undefined),
|
||||
value,
|
||||
);
|
||||
|
||||
/** A copy with the value under these member names replaced, when the object holds every one of them. */
|
||||
const withMemberAt = (
|
||||
object: JsonObject,
|
||||
tokens: readonly string[],
|
||||
value: JsonValue,
|
||||
): JsonObject => {
|
||||
const [first, ...rest] = tokens;
|
||||
if (first === undefined || !Object.hasOwn(object, first)) return object;
|
||||
if (rest.length === 0) return { ...object, [first]: value };
|
||||
const inner = object[first];
|
||||
return isObject(inner)
|
||||
? { ...object, [first]: withMemberAt(inner, rest, value) }
|
||||
: object;
|
||||
};
|
||||
|
||||
/** A copy without the value under these member names, when the object holds every one of them. */
|
||||
const withoutMemberAt = (
|
||||
object: JsonObject,
|
||||
tokens: readonly string[],
|
||||
): JsonObject => {
|
||||
const [first, ...rest] = tokens;
|
||||
if (first === undefined || !Object.hasOwn(object, first)) return object;
|
||||
if (rest.length === 0) {
|
||||
const { [first]: _dropped, ...kept } = object;
|
||||
return kept;
|
||||
}
|
||||
const inner = object[first];
|
||||
return isObject(inner)
|
||||
? { ...object, [first]: withoutMemberAt(inner, rest) }
|
||||
: object;
|
||||
};
|
||||
|
||||
/** The shared bulk renderer: fixed acknowledgements, or one first-template element per written entity, each with the single answer's optional and later fields. */
|
||||
export const writtenBulkResponse = (
|
||||
response: WriteResponse,
|
||||
recorded: JsonValue | undefined,
|
||||
written: readonly { readonly fields: JsonObject; readonly id: string }[],
|
||||
written: readonly {
|
||||
readonly fields: JsonObject;
|
||||
readonly id: string;
|
||||
readonly sent?: readonly string[];
|
||||
}[],
|
||||
optional: readonly string[] = [],
|
||||
later: ReadonlySet<string> = new Set(),
|
||||
): Applied<JsonValue | undefined> => {
|
||||
if (response.shape === "empty") return ok(undefined);
|
||||
if (response.shape === "recorded") return ok(recorded);
|
||||
@@ -570,6 +690,8 @@ export const writtenBulkResponse = (
|
||||
{ ...response, pointer: "" },
|
||||
template,
|
||||
entity,
|
||||
optional,
|
||||
later,
|
||||
);
|
||||
if (!element.ok) return element;
|
||||
elements.push(element.value ?? null);
|
||||
|
||||
@@ -16,7 +16,10 @@ import {
|
||||
} from "./environmentStateWriteAddress.js";
|
||||
import { bodyTarget } from "./environmentStateWriteChecks.js";
|
||||
import { responseShapeOf } from "./environmentStateWriteExplain.js";
|
||||
import { writtenBulkResponse } from "./environmentStateWriteApply.js";
|
||||
import {
|
||||
optionalFields,
|
||||
writtenBulkResponse,
|
||||
} from "./environmentStateWriteApply.js";
|
||||
import { deleteEntity } from "./environmentStateWriteDelete.js";
|
||||
import {
|
||||
built,
|
||||
@@ -229,14 +232,29 @@ export const applyBulk = (
|
||||
: result?.kind === "applied"
|
||||
? result.responseFields
|
||||
: undefined;
|
||||
return fields === undefined ? [] : [{ id: target.id, fields }];
|
||||
return fields === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: target.id,
|
||||
fields,
|
||||
sent: Object.keys(named[index]!.body ?? {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
const fits =
|
||||
planned.shape === response.shape &&
|
||||
planned.pointer === response.pointer &&
|
||||
bulkResponseFits(planned, recorded, named);
|
||||
// Replay renders as serving does: optional and later-only fields stay out for entities without them.
|
||||
const render = (exemplar: JsonValue | undefined) =>
|
||||
writtenBulkResponse(planned, exemplar, written);
|
||||
writtenBulkResponse(
|
||||
planned,
|
||||
exemplar,
|
||||
written,
|
||||
optionalFields(draft.plan),
|
||||
context.laterFields(draft.plan.target ?? ""),
|
||||
);
|
||||
const rendered =
|
||||
!fits || written.length !== named.length
|
||||
? undefined
|
||||
|
||||
@@ -5,9 +5,11 @@ import {
|
||||
ownMember,
|
||||
at,
|
||||
decodedSegment,
|
||||
deletedFlag,
|
||||
isObject,
|
||||
listItems,
|
||||
listsAll,
|
||||
markedDeleted,
|
||||
missingRequiredFields,
|
||||
pathSegmentMatches,
|
||||
renderStateEntity,
|
||||
@@ -46,24 +48,37 @@ interface RemovedTarget {
|
||||
}
|
||||
|
||||
/** The gate's copy of the state: entities in collection order and documents, changed by modelled writes. */
|
||||
/** A write since a list in collection order last showed its entity, with the values it replaced (`null` for a create). */
|
||||
export interface PendingWrite {
|
||||
readonly target: string;
|
||||
readonly before: JsonObject | null;
|
||||
}
|
||||
|
||||
export class SimulatedState {
|
||||
readonly #entities = new Map<string, StateEntity>();
|
||||
readonly #collections = new Map<string, string[]>();
|
||||
readonly #documents = new Map<string, JsonValue>();
|
||||
readonly #removed: RemovedTarget[] = [];
|
||||
readonly #removed: StateEntity[] = [];
|
||||
readonly #served = new ServedOrder();
|
||||
/** Each held id's targets, of any type. */
|
||||
readonly #named = new Map<string, Set<string>>();
|
||||
/** The order a type's collections keep their entities in, as its creates insert them. */
|
||||
#sortOf: (type: string) => InsertRule | undefined = () => undefined;
|
||||
/** The values each entity put since `track` replaced, `null` for one a put added. */
|
||||
readonly #prior = new Map<string, JsonObject | null>();
|
||||
|
||||
constructor(
|
||||
entities: readonly StateEntity[],
|
||||
documents: readonly StateDocument[],
|
||||
) {
|
||||
for (const entity of entities) this.put(entity);
|
||||
for (const entity of entities) {
|
||||
if (markedDeleted(entity.fields)) this.#removed.push(entity);
|
||||
else this.put(entity);
|
||||
}
|
||||
this.#served.reset(
|
||||
entities.map(({ type, collection, id }) =>
|
||||
entityTarget(type, collection, id),
|
||||
),
|
||||
entities
|
||||
.filter((entity) => !markedDeleted(entity.fields))
|
||||
.map(({ type, collection, id }) => entityTarget(type, collection, id)),
|
||||
);
|
||||
for (const document of documents)
|
||||
this.#documents.set(document.tool, document.value);
|
||||
@@ -100,12 +115,21 @@ export class SimulatedState {
|
||||
return this.#served.known(type);
|
||||
}
|
||||
|
||||
/** Replace an entity in place, or add it at `index` of its collection (the end when omitted). */
|
||||
/** Keep each type's collections in the order its creates insert by, which a change to the field sorted by moves an entity in. */
|
||||
keepSorted(sortOf: (type: string) => InsertRule | undefined): void {
|
||||
this.#sortOf = sortOf;
|
||||
}
|
||||
|
||||
/** Replace an entity, moving it when its collection is sorted by a field it changed, or add it at `index` of its collection (the end when omitted). */
|
||||
put(entity: StateEntity, index?: number): void {
|
||||
const key = entityTarget(entity.type, entity.collection, entity.id);
|
||||
const known = this.#entities.has(key);
|
||||
const held = this.#entities.get(key);
|
||||
if (!this.#prior.has(key)) this.#prior.set(key, held?.fields ?? null);
|
||||
this.#entities.set(key, entity);
|
||||
if (known) return;
|
||||
if (held !== undefined) {
|
||||
this.#resort(held, entity);
|
||||
return;
|
||||
}
|
||||
this.#named.set(
|
||||
entity.id,
|
||||
(this.#named.get(entity.id) ?? new Set()).add(key),
|
||||
@@ -119,6 +143,114 @@ export class SimulatedState {
|
||||
this.#served.remember(entity.type, entity.collection);
|
||||
}
|
||||
|
||||
/** Start recording the values the next puts replace. */
|
||||
track(): void {
|
||||
this.#prior.clear();
|
||||
}
|
||||
|
||||
/** The values an entity held before its first put since `track`: `null` when that put added it, undefined when none did. */
|
||||
priorFields(key: string): JsonObject | null | undefined {
|
||||
return this.#prior.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* A collection's entity keys in the order serving gives when each of `writes` moves its entity in turn by `rule`,
|
||||
* against the others' values before their own writes, or a create inserts it; undefined when that misses an entity.
|
||||
*/
|
||||
replayedOrder(
|
||||
type: string,
|
||||
collection: string,
|
||||
rule: Extract<InsertRule, { rule: "sorted" }>,
|
||||
writes: readonly PendingWrite[],
|
||||
): string[] | undefined {
|
||||
const before =
|
||||
this.#collections.get(collectionTarget(type, collection)) ?? [];
|
||||
const held = new Set(before);
|
||||
const ours = writes.filter((write) => held.has(write.target));
|
||||
const first = new Map<string, JsonObject | null>();
|
||||
for (const write of ours)
|
||||
if (!first.has(write.target)) first.set(write.target, write.before);
|
||||
const order = before
|
||||
.filter((key) => first.get(key) !== null)
|
||||
.map((key) => ({
|
||||
key,
|
||||
fields: first.get(key) ?? this.#entities.get(key)!.fields,
|
||||
}));
|
||||
for (const [index, { target: key, before: prior }] of ours.entries()) {
|
||||
const fields =
|
||||
ours.slice(index + 1).find((later) => later.target === key)?.before ??
|
||||
this.#entities.get(key)!.fields;
|
||||
const at = order.findIndex((member) => member.key === key);
|
||||
if ((prior === null) !== at < 0) continue;
|
||||
if (at >= 0) order.splice(at, 1);
|
||||
const moved =
|
||||
prior === null ||
|
||||
!same(ownMember(prior, rule.field), ownMember(fields, rule.field));
|
||||
order.splice(
|
||||
moved
|
||||
? insertIndex(
|
||||
rule,
|
||||
order.map((member) => member.fields),
|
||||
fields,
|
||||
)
|
||||
: at,
|
||||
0,
|
||||
{ key, fields },
|
||||
);
|
||||
}
|
||||
return order.length === before.length
|
||||
? order.map((member) => member.key)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Put a collection's entities in the order `replayedOrder` gives, returning the order they had. */
|
||||
replay(
|
||||
type: string,
|
||||
collection: string,
|
||||
rule: Extract<InsertRule, { rule: "sorted" }>,
|
||||
writes: readonly PendingWrite[],
|
||||
): string[] {
|
||||
const before =
|
||||
this.#collections.get(collectionTarget(type, collection)) ?? [];
|
||||
const order = this.replayedOrder(type, collection, rule, writes);
|
||||
if (order !== undefined) this.restore(type, collection, order);
|
||||
return before;
|
||||
}
|
||||
|
||||
/** Put a collection's entities back in an order `replay` returned. */
|
||||
restore(type: string, collection: string, keys: readonly string[]): void {
|
||||
this.#served.arrange(keys);
|
||||
this.#collections.set(collectionTarget(type, collection), [...keys]);
|
||||
}
|
||||
|
||||
/** Move an entity whose sorted field changed to where its collection's order now puts it. */
|
||||
#resort(before: StateEntity, after: StateEntity): void {
|
||||
const rule = this.#sortOf(after.type);
|
||||
if (
|
||||
rule?.rule !== "sorted" ||
|
||||
same(
|
||||
ownMember(before.fields, rule.field),
|
||||
ownMember(after.fields, rule.field),
|
||||
)
|
||||
)
|
||||
return;
|
||||
const key = entityTarget(after.type, after.collection, after.id);
|
||||
const collection = collectionTarget(after.type, after.collection);
|
||||
const others = (this.#collections.get(collection) ?? []).filter(
|
||||
(member) => member !== key,
|
||||
);
|
||||
if (others.length === 0) return;
|
||||
const at = insertIndex(
|
||||
rule,
|
||||
others.map((member) => this.#entities.get(member)!.fields),
|
||||
after.fields,
|
||||
);
|
||||
this.#served.remove(key);
|
||||
this.#served.place(key, others, at);
|
||||
others.splice(at, 0, key);
|
||||
this.#collections.set(collection, others);
|
||||
}
|
||||
|
||||
remove(entity: StateEntity): void {
|
||||
const key = entityTarget(entity.type, entity.collection, entity.id);
|
||||
this.#entities.delete(key);
|
||||
@@ -137,11 +269,20 @@ export class SimulatedState {
|
||||
target.id === entity.id,
|
||||
);
|
||||
if (index >= 0) this.#removed.splice(index, 1);
|
||||
this.#removed.push({
|
||||
type: entity.type,
|
||||
collection: entity.collection,
|
||||
id: entity.id,
|
||||
});
|
||||
this.#removed.push(entity);
|
||||
}
|
||||
|
||||
deleted(
|
||||
type: string,
|
||||
collection: string,
|
||||
id: string,
|
||||
): StateEntity | undefined {
|
||||
return this.#removed.find(
|
||||
(entity) =>
|
||||
entity.type === type &&
|
||||
entity.collection === collection &&
|
||||
entity.id === id,
|
||||
);
|
||||
}
|
||||
|
||||
/** Removed targets of this type the state does not hold again. */
|
||||
@@ -369,6 +510,20 @@ export const checkItemRead = (
|
||||
return check.body === undefined || entity === undefined
|
||||
? undefined
|
||||
: { pass: false, targets, unheld: [] };
|
||||
// Serving keeps a removed entity's fields and changes only the soft-delete flag.
|
||||
const flag = deletedFlag(object);
|
||||
if (flag !== undefined) {
|
||||
const removed = state.deleted(view.type, collection, id);
|
||||
if (entity !== undefined) return { pass: false, targets, unheld: [] };
|
||||
if (removed === undefined) return { pass: true, targets, unheld: [] };
|
||||
const deleted = { ...removed, fields: { ...removed.fields, [flag]: true } };
|
||||
const shown = renderStateEntity(deleted, view, check.volatile, object);
|
||||
return {
|
||||
pass: same(replaceAt(check.base, view.pointer, shown), check.body),
|
||||
targets,
|
||||
unheld: [],
|
||||
};
|
||||
}
|
||||
if (entity === undefined) return { pass: false, targets, unheld: targets };
|
||||
// Serving refuses an entity lacking a field every recording carried, so no read can confirm one.
|
||||
if (missingRequiredFields(entity, view, check.volatile).length > 0)
|
||||
|
||||
@@ -64,7 +64,8 @@ import {
|
||||
import {
|
||||
laterObservation,
|
||||
newEntityIn,
|
||||
respelledInstant,
|
||||
loadedLater,
|
||||
unloadedPointers,
|
||||
withRead,
|
||||
type Observed,
|
||||
} from "./environmentStateWriteObserve.js";
|
||||
@@ -141,8 +142,7 @@ const withReadBack = (
|
||||
Object.entries(later.object).some(
|
||||
([field, value]) =>
|
||||
Object.hasOwn(own.object, field) &&
|
||||
!same(own.object[field], value) &&
|
||||
!respelledInstant(own.object[field], value),
|
||||
!loadedLater(own.object[field], value),
|
||||
))
|
||||
)
|
||||
return own;
|
||||
@@ -150,10 +150,18 @@ const withReadBack = (
|
||||
const object = withRead(own.object, later.object, shape);
|
||||
if (same(object, own.object)) return own;
|
||||
if (shape === "item") context.observed(later.ordinal);
|
||||
// Answers keep the values the response had not loaded, which the state holds as the item read shows them.
|
||||
const unloaded =
|
||||
shape === "item" ? unloadedPointers(own.object, later.object) : [];
|
||||
return {
|
||||
...own,
|
||||
object,
|
||||
read: { ordinal: later.ordinal, pointer: later.pointer, shape },
|
||||
read: {
|
||||
ordinal: later.ordinal,
|
||||
pointer: later.pointer,
|
||||
shape,
|
||||
...(unloaded.length === 0 ? {} : { unloaded }),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -518,7 +526,31 @@ const agreesLater = (
|
||||
call: { readonly body: JsonObject; readonly observed: JsonObject },
|
||||
): boolean =>
|
||||
followsCreateRules(context, draft, { ...created, observed: call.observed }) &&
|
||||
!contradictsDefault(draft.plan.rules, call, created.fields);
|
||||
!contradictsDefault(draft.plan.rules, call, created.fields) &&
|
||||
!unruledDiffers(draft.plan.rules, call.observed, {
|
||||
written: created.fields,
|
||||
volatile: context.volatileOf(created.type),
|
||||
});
|
||||
|
||||
/** Whether the call stored a field no rule writes and no read leaves unchecked otherwise than the plan keeps it, as the exemplar stored it. */
|
||||
const unruledDiffers = (
|
||||
rules: readonly FieldRule[],
|
||||
observed: JsonObject,
|
||||
plan: {
|
||||
readonly written: JsonObject;
|
||||
readonly volatile: ReadonlySet<string>;
|
||||
},
|
||||
): boolean => {
|
||||
const { written, volatile } = plan;
|
||||
const ruled = new Set(rules.map((rule) => rule.field));
|
||||
return Object.keys(observed).some(
|
||||
(field) =>
|
||||
!ruled.has(field) &&
|
||||
!volatile.has(field) &&
|
||||
Object.hasOwn(written, field) &&
|
||||
!same(observed[field], ownMember(written, field)),
|
||||
);
|
||||
};
|
||||
|
||||
/** Whether an echoed member the call left out was stored otherwise than its learned default. */
|
||||
const contradictsDefault = (
|
||||
|
||||
@@ -5,12 +5,14 @@ import { fittedClockFormat } from "./environmentStateWriteClockFormats.js";
|
||||
import { composedValue } from "./environmentStateWriteComposed.js";
|
||||
import {
|
||||
alikeRules,
|
||||
isBlankValue,
|
||||
type FieldRule,
|
||||
type WriteBody,
|
||||
} from "./environmentStateWriteRules.js";
|
||||
import { slugOf, type Ampersand } from "./environmentStateWriteValues.js";
|
||||
|
||||
type Default = { readonly member: string; readonly value: JsonValue };
|
||||
type Blank = NonNullable<WriteBody["blank"]>[number];
|
||||
|
||||
/** 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 {
|
||||
@@ -350,6 +352,22 @@ export const mergedCreatePlan = (
|
||||
};
|
||||
};
|
||||
|
||||
/** Every empty value recorded calls sent a member, the earliest first. */
|
||||
const joinedBlanks = (
|
||||
held: readonly Blank[],
|
||||
added: readonly Blank[],
|
||||
): Blank[] => {
|
||||
const blank = [...held];
|
||||
for (const entry of added)
|
||||
if (
|
||||
!blank.some(
|
||||
({ member, sent }) => member === entry.member && same(sent, entry.sent),
|
||||
)
|
||||
)
|
||||
blank.push(entry);
|
||||
return blank;
|
||||
};
|
||||
|
||||
/** 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,
|
||||
@@ -375,6 +393,7 @@ const mergedBody = (
|
||||
const added = contract.accepted.filter(
|
||||
(member) => !body.accepted.includes(member),
|
||||
);
|
||||
const blank = joinedBlanks(body.blank ?? [], contract.blank ?? []);
|
||||
return {
|
||||
...body,
|
||||
accepted: [...body.accepted, ...added],
|
||||
@@ -382,10 +401,12 @@ const mergedBody = (
|
||||
...body.fixed,
|
||||
...contract.fixed.filter((entry) => !fixed.has(entry.member)),
|
||||
],
|
||||
required: body.required.filter((member) =>
|
||||
Object.hasOwn(later.body, member),
|
||||
required: body.required.filter(
|
||||
(member) =>
|
||||
Object.hasOwn(later.body, member) && !isBlankValue(later.body[member]),
|
||||
),
|
||||
identity: body.identity ?? contract.identity,
|
||||
kinds: [...body.kinds, ...storedKinds(added, exemplar, later.entity)],
|
||||
...(blank.length === 0 ? {} : { blank }),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -163,6 +163,8 @@ export interface EffectContext {
|
||||
readonly isWrite: (record: StateRecordFacts) => boolean;
|
||||
readonly viewOf: (ordinal: number) => StateView | undefined;
|
||||
readonly idField: (type: string) => string;
|
||||
/** Fields of a type whose values change on their own, which no read checks. */
|
||||
readonly volatileOf: (type: string) => ReadonlySet<string>;
|
||||
/** Mark a later read as a create's or update's observation, so it does not check that write. */
|
||||
readonly observed: (ordinal: number) => void;
|
||||
/** Whether an unmodelled write may have changed the entity since the state last saw it. */
|
||||
@@ -171,6 +173,8 @@ export interface EffectContext {
|
||||
readonly initialDocument: (tool: string) => StateDocument | undefined;
|
||||
/** The entities of a type the initial state held, whose values a sequence never reuses. */
|
||||
readonly startedWith: (type: string) => readonly StateEntity[];
|
||||
/** Fields the type's item views show that no initial entity holds, which an answer leaves out for an entity without them. */
|
||||
readonly laterFields: (type: string) => ReadonlySet<string>;
|
||||
/** Whether another tool's modelled write to the target awaits a check, which a later read must still make. */
|
||||
readonly pending: (target: string) => boolean;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
type StateEntity,
|
||||
} from "./environmentStateRules.js";
|
||||
import {
|
||||
emptySpellings,
|
||||
isBlankValue,
|
||||
siblingTrimmedClocks,
|
||||
type FieldRule,
|
||||
type InsertRule,
|
||||
@@ -442,17 +444,24 @@ export const explainCreate = (
|
||||
rules.filter((rule) => !stranded.has(rule.field)),
|
||||
evidence.entity,
|
||||
);
|
||||
// A member sent empty is one a form sends for a field left as it was, which a caller may leave out.
|
||||
const blank = Object.entries(evidence.call.body).flatMap(([member, sent]) =>
|
||||
isBlankValue(sent) ? [{ member, sent }] : [],
|
||||
);
|
||||
const shaped = bodyOf(
|
||||
evidence.call.body,
|
||||
new Set(
|
||||
[...stored].filter(
|
||||
(member) => !referenced.has(member) && !pinned.has(member),
|
||||
),
|
||||
),
|
||||
Object.keys(evidence.call.body).filter(
|
||||
(member) => !isBlankValue(evidence.call.body[member]),
|
||||
),
|
||||
);
|
||||
return {
|
||||
rules,
|
||||
body: bodyOf(
|
||||
evidence.call.body,
|
||||
new Set(
|
||||
[...stored].filter(
|
||||
(member) => !referenced.has(member) && !pinned.has(member),
|
||||
),
|
||||
),
|
||||
Object.keys(evidence.call.body),
|
||||
),
|
||||
body: blank.length === 0 ? shaped : { ...shaped, blank },
|
||||
// A template rebuilds only its generated value, so other text it keeps ties it like a constant.
|
||||
tied: [
|
||||
...[...constants, ...templates].map((field) =>
|
||||
@@ -567,6 +576,14 @@ export const explainUpdate = (
|
||||
if (kept !== undefined) rules.push(kept);
|
||||
continue;
|
||||
}
|
||||
// An empty list or map one answer spells `null` and another `[]` or `{}` is unchanged.
|
||||
if (
|
||||
emptySpellings(
|
||||
ownMember(evidence.before, field),
|
||||
ownMember(evidence.after, field),
|
||||
)
|
||||
)
|
||||
continue;
|
||||
const rule = heldSlugRule(
|
||||
changeRule(evidence, field, slugs),
|
||||
body,
|
||||
@@ -647,7 +664,7 @@ export const compareValues = (
|
||||
};
|
||||
|
||||
/** Whether the members are strictly ordered by the field in the direction. */
|
||||
const orderedBy = (
|
||||
export const orderedBy = (
|
||||
members: readonly JsonObject[],
|
||||
field: string,
|
||||
direction: "ascending" | "descending",
|
||||
@@ -750,6 +767,41 @@ export const insertIndex = (
|
||||
return index < 0 ? members.length : index;
|
||||
};
|
||||
|
||||
/**
|
||||
* A sorted collection's members after writes move each entity whose sorted field changed to where its new value sorts,
|
||||
* one after another, as replay applies them; other written entities keep their places.
|
||||
*/
|
||||
export const resortedMembers = <
|
||||
Entity extends { readonly id: string; readonly fields: JsonObject },
|
||||
>(
|
||||
rule: Extract<InsertRule, { rule: "sorted" }>,
|
||||
members: readonly Entity[],
|
||||
writes: readonly { readonly before: Entity; readonly after: Entity }[],
|
||||
): Entity[] => {
|
||||
const order = [...members];
|
||||
for (const { before, after } of writes) {
|
||||
const at = order.findIndex((member) => member.id === before.id);
|
||||
if (at < 0) continue;
|
||||
order.splice(at, 1);
|
||||
const moved = !same(
|
||||
ownMember(before.fields, rule.field),
|
||||
ownMember(after.fields, rule.field),
|
||||
);
|
||||
order.splice(
|
||||
moved
|
||||
? insertIndex(
|
||||
rule,
|
||||
order.map((member) => member.fields),
|
||||
after.fields,
|
||||
)
|
||||
: at,
|
||||
0,
|
||||
after,
|
||||
);
|
||||
}
|
||||
return order;
|
||||
};
|
||||
|
||||
/** The object in a document value, at the root or in one member, that holds most of a body's members. */
|
||||
export const documentPointerOf = (
|
||||
value: JsonValue,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
listItems,
|
||||
pointerTo,
|
||||
projectsFields,
|
||||
same,
|
||||
scalarText,
|
||||
type JsonObject,
|
||||
type StateEntity,
|
||||
@@ -23,6 +24,8 @@ export interface ObservedRead {
|
||||
readonly ordinal: number;
|
||||
readonly pointer: string;
|
||||
readonly shape: "item" | "list";
|
||||
/** Pointers into the entity to values the response had not loaded and the item read shows loaded, or members the read adds inside an object the response showed. */
|
||||
readonly unloaded?: string[];
|
||||
}
|
||||
|
||||
/** Whether two clocks agree at the coarser recorded fraction precision, retaining all digits through nanoseconds. */
|
||||
@@ -44,6 +47,55 @@ export const respelledInstant = (
|
||||
);
|
||||
};
|
||||
|
||||
/** Go's zero `time.Time`, which a response shows for a time it has not loaded. */
|
||||
const ZERO_TIME = /^0001-01-01T00:00:00(?:\.0+)?(?:Z|[+-]00:?00)$/u;
|
||||
|
||||
/** A value a create's response holds where it has not loaded the stored one: empty, zero, false or Go's zero time. */
|
||||
const unloaded = (value: JsonValue | undefined): boolean =>
|
||||
value === null ||
|
||||
value === "" ||
|
||||
value === 0 ||
|
||||
value === false ||
|
||||
(typeof value === "string" && ZERO_TIME.test(value)) ||
|
||||
(Array.isArray(value)
|
||||
? value.length === 0
|
||||
: isObject(value) && Object.keys(value).length === 0);
|
||||
|
||||
/** Whether a later item read shows what a create's response showed, respelled or loaded: the same value, the same instant, a value for an unloaded one, or an object whose members each are. */
|
||||
export const loadedLater = (
|
||||
response: JsonValue | undefined,
|
||||
read: JsonValue | undefined,
|
||||
): boolean =>
|
||||
same(response, read) ||
|
||||
respelledInstant(response, read) ||
|
||||
(read !== undefined && unloaded(response)) ||
|
||||
(isObject(response) &&
|
||||
isObject(read) &&
|
||||
Object.keys(response).every((key) =>
|
||||
loadedLater(response[key], ownMember(read, key)),
|
||||
));
|
||||
|
||||
/** Pointers to the values a create's response had not loaded where a later read shows others, and to members a read adds inside an object the response showed, within nested objects too. */
|
||||
export const unloadedPointers = (
|
||||
response: JsonObject,
|
||||
read: JsonObject,
|
||||
prefix = "",
|
||||
): string[] => [
|
||||
...Object.entries(response).flatMap(([key, value]) => {
|
||||
const loaded = ownMember(read, key);
|
||||
const pointer = `${prefix}${pointerTo(key)}`;
|
||||
if (loaded === undefined || same(value, loaded)) return [];
|
||||
if (isObject(value) && isObject(loaded) && Object.keys(value).length > 0)
|
||||
return unloadedPointers(value, loaded, pointer);
|
||||
return unloaded(value) ? [pointer] : [];
|
||||
}),
|
||||
...(prefix === ""
|
||||
? []
|
||||
: Object.keys(read)
|
||||
.filter((key) => !Object.hasOwn(response, key))
|
||||
.map((key) => `${prefix}${pointerTo(key)}`)),
|
||||
];
|
||||
|
||||
/** A create's response object with a read's fields: an item read's values win, a list read only adds fields the response lacks. */
|
||||
export const withRead = (
|
||||
response: JsonObject,
|
||||
|
||||
@@ -24,6 +24,15 @@ export class ServedOrder {
|
||||
this.#keys.splice(anchor, 0, key);
|
||||
}
|
||||
|
||||
/** Put these keys, in this order, in the places they already hold among the others. */
|
||||
arrange(keys: readonly string[]): void {
|
||||
const wanted = new Set(keys);
|
||||
let next = 0;
|
||||
this.#keys = this.#keys.map((key) =>
|
||||
wanted.has(key) ? keys[next++]! : key,
|
||||
);
|
||||
}
|
||||
|
||||
remove(key: string): void {
|
||||
this.#keys = this.#keys.filter((member) => member !== key);
|
||||
}
|
||||
|
||||
@@ -241,8 +241,34 @@ export interface WriteBody {
|
||||
readonly defaults?:
|
||||
| { readonly member: string; readonly value: JsonValue }[]
|
||||
| undefined;
|
||||
/** Members a recorded create sent empty, which a call may leave out: one left out is applied as sent that value. */
|
||||
readonly blank?:
|
||||
| { readonly member: string; readonly sent: JsonValue }[]
|
||||
| undefined;
|
||||
}
|
||||
|
||||
/** Whether two values both say a container is empty, as `null`, `[]` or `{}`, which encoders spell differently from one answer to another. */
|
||||
export const emptySpellings = (
|
||||
left: JsonValue | undefined,
|
||||
right: JsonValue | undefined,
|
||||
): boolean =>
|
||||
[left, right].every(
|
||||
(value) =>
|
||||
value === null ||
|
||||
(Array.isArray(value)
|
||||
? value.length === 0
|
||||
: typeof value === "object" && Object.keys(value).length === 0),
|
||||
);
|
||||
|
||||
/** A value a form sends for a field left empty: `""`, `null`, `false`, `[]` or `{}`. */
|
||||
export const isBlankValue = (value: JsonValue | undefined): boolean =>
|
||||
value === "" ||
|
||||
value === null ||
|
||||
value === false ||
|
||||
(Array.isArray(value)
|
||||
? value.length === 0
|
||||
: typeof value === "object" && Object.keys(value).length === 0);
|
||||
|
||||
/**
|
||||
* How a write names its entities when its path does not end in one: an action segment after the
|
||||
* item's id, or ids in the body of a write at the collection, as an array of one JSON kind (`ids`)
|
||||
@@ -284,6 +310,8 @@ export interface StateWritePlan {
|
||||
readonly ordinal: number;
|
||||
readonly pointer: string;
|
||||
readonly shape: "item" | "list";
|
||||
/** Pointers into the entity to values the response had not loaded, which answers keep as it showed them. */
|
||||
readonly unloaded?: string[];
|
||||
};
|
||||
} | null;
|
||||
readonly response: WriteResponse | null;
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { at, isObject, type StateEntity } from "./environmentStateRules.js";
|
||||
import {
|
||||
at,
|
||||
isObject,
|
||||
type StateEntity,
|
||||
type StateView,
|
||||
} from "./environmentStateRules.js";
|
||||
import { laterItemFields } from "./environmentStateWriteApply.js";
|
||||
import type { WriteDraft, WriteIo } from "./environmentStateWriteEffects.js";
|
||||
import type { StateWritePlan } from "./environmentStateWriteRules.js";
|
||||
|
||||
/** Whether an update or delete that answers with the entity can answer for an initial entity: one holds, or an update's rules write, every field its recorded response shows. */
|
||||
/** Whether an update or delete that answers with the entity can answer for an initial entity: one holds, or an update's rules write, every field its recorded response shows that some initial entity holds. */
|
||||
const renders = (
|
||||
plan: StateWritePlan,
|
||||
input: { readonly io: WriteIo; readonly entities: readonly StateEntity[] },
|
||||
input: {
|
||||
readonly io: WriteIo;
|
||||
readonly entities: readonly StateEntity[];
|
||||
readonly views: ReadonlyMap<string, StateView>;
|
||||
},
|
||||
): boolean => {
|
||||
const { response } = plan;
|
||||
if (
|
||||
@@ -26,13 +36,21 @@ const renders = (
|
||||
)
|
||||
: [],
|
||||
);
|
||||
const later = laterItemFields(
|
||||
input.views.values(),
|
||||
input.entities,
|
||||
plan.target,
|
||||
);
|
||||
return (
|
||||
shown === undefined ||
|
||||
input.entities.some(
|
||||
(entity) =>
|
||||
entity.type === plan.target &&
|
||||
shown.every(
|
||||
(field) => Object.hasOwn(entity.fields, field) || written.has(field),
|
||||
(field) =>
|
||||
Object.hasOwn(entity.fields, field) ||
|
||||
written.has(field) ||
|
||||
later.has(field),
|
||||
),
|
||||
)
|
||||
);
|
||||
@@ -56,7 +74,11 @@ const unstamped = (plan: StateWritePlan, draft: WriteDraft): boolean =>
|
||||
/** A replayed write's plan, served only when it was called, nothing contradicted it, a read confirmed it and its answers can render. */
|
||||
export const writeVerdict = (
|
||||
draft: WriteDraft,
|
||||
input: { readonly io: WriteIo; readonly entities: readonly StateEntity[] },
|
||||
input: {
|
||||
readonly io: WriteIo;
|
||||
readonly entities: readonly StateEntity[];
|
||||
readonly views: ReadonlyMap<string, StateView>;
|
||||
},
|
||||
): StateWritePlan => {
|
||||
const plan = draft.plan;
|
||||
if (plan.reason === null) {
|
||||
|
||||
@@ -6,5 +6,5 @@ export const PACKAGE_METADATA = {
|
||||
serverSdkVersion: "2.3.1",
|
||||
clientSdkVersion: "2.3.1",
|
||||
coreSdkVersion: "2.3.1",
|
||||
skillVersion: "50",
|
||||
skillVersion: "52",
|
||||
} as const;
|
||||
|
||||
+652
@@ -0,0 +1,652 @@
|
||||
import type { JsonValue } from "../../src/domain/jsonValue.js";
|
||||
import type { TimedCaptureRow } from "./analysisView.js";
|
||||
import { bookClock } from "./environmentStateWrites.js";
|
||||
|
||||
type Entity = Record<string, JsonValue>;
|
||||
|
||||
const label = (
|
||||
id: number,
|
||||
name: string,
|
||||
fields: { readonly description?: string; readonly color?: string } = {},
|
||||
): Entity => ({
|
||||
id,
|
||||
name,
|
||||
description: fields.description ?? "",
|
||||
color: fields.color ?? "",
|
||||
icon: "",
|
||||
});
|
||||
|
||||
const KITCHEN = label(1, "Kitchen", { description: "Pots", color: "#aa3300" });
|
||||
const TOOLS = label(2, "Tools");
|
||||
|
||||
/** What a label form sends: every member, those left as they were empty. */
|
||||
const formBody = (entity: Entity): Entity => {
|
||||
const { id: _id, ...sent } = entity;
|
||||
return sent;
|
||||
};
|
||||
|
||||
/** Two label creates through a form that sends every member, the members left empty differing between them, in a collection kept sorted by name. */
|
||||
export const formLabelRows = (
|
||||
icons: readonly [JsonValue, JsonValue] = ["", ""],
|
||||
): TimedCaptureRow[] => {
|
||||
const garden = label(3, "Garden", { description: "Seeds" });
|
||||
const attic = label(4, "Attic", { color: "#00aa44" });
|
||||
return [
|
||||
["GET", "/api/labels", [KITCHEN, TOOLS]],
|
||||
["GET", "/api/labels/1", KITCHEN],
|
||||
["GET", "/api/labels/2", TOOLS],
|
||||
[
|
||||
"POST",
|
||||
"/api/labels",
|
||||
garden,
|
||||
201,
|
||||
{ ...formBody(garden), icon: icons[0] },
|
||||
],
|
||||
["GET", "/api/labels/3", garden],
|
||||
["GET", "/api/labels", [garden, KITCHEN, TOOLS]],
|
||||
["POST", "/api/labels", attic, 201, { ...formBody(attic), icon: icons[1] }],
|
||||
["GET", "/api/labels/4", attic],
|
||||
["GET", "/api/labels", [attic, garden, KITCHEN, TOOLS]],
|
||||
];
|
||||
};
|
||||
|
||||
/** The form label creates, then a rename that moves a label to the end of the name order, read back. */
|
||||
export const RENAMED_LABEL_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const rows = formLabelRows();
|
||||
const zinc = { ...KITCHEN, name: "Zinc" };
|
||||
const [attic, garden] = [
|
||||
label(4, "Attic", { color: "#00aa44" }),
|
||||
label(3, "Garden", { description: "Seeds" }),
|
||||
];
|
||||
return [
|
||||
...rows,
|
||||
["PATCH", "/api/labels/1", zinc, 200, { name: "Zinc" }],
|
||||
["GET", "/api/labels/1", zinc],
|
||||
["GET", "/api/labels", [attic, garden, TOOLS, zinc]],
|
||||
];
|
||||
})();
|
||||
|
||||
const page = (results: readonly Entity[]): Entity => ({
|
||||
count: results.length,
|
||||
next: null,
|
||||
previous: null,
|
||||
all: results.map((entity) => entity["id"]!),
|
||||
results: [...results],
|
||||
});
|
||||
|
||||
/** Tag creates whose list answers carry `all`, the listed ids in list order, as Paperless-ngx's do. */
|
||||
export const LISTED_ID_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const harbour = label(3, "Harbour");
|
||||
const quarry = label(4, "Quarry");
|
||||
return [
|
||||
["GET", "/api/tags/", page([KITCHEN, TOOLS])],
|
||||
["GET", "/api/tags/1/", KITCHEN],
|
||||
["POST", "/api/tags/", harbour, 201, { name: "Harbour" }],
|
||||
["GET", "/api/tags/3/", harbour],
|
||||
["GET", "/api/tags/", page([harbour, KITCHEN, TOOLS])],
|
||||
["POST", "/api/tags/", quarry, 201, { name: "Quarry" }],
|
||||
["GET", "/api/tags/4/", quarry],
|
||||
["GET", "/api/tags/", page([harbour, KITCHEN, quarry, TOOLS])],
|
||||
];
|
||||
})();
|
||||
|
||||
const folder = (id: number, title: string, version = 1): Entity => ({
|
||||
id,
|
||||
title,
|
||||
version,
|
||||
});
|
||||
|
||||
/**
|
||||
* One seeded folder, two creates that sort after it, and a rename that sorts first, as Grafana lists folders by
|
||||
* title: no read before the rename shows whether the folders are kept by title or in creation order.
|
||||
*/
|
||||
export const TITLE_ORDER_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const rota = folder(1, "Lighthouse Rota");
|
||||
const alpha = folder(2, "Scratch Alpha");
|
||||
const beta = folder(3, "Scratch Beta");
|
||||
const renamed = folder(3, "Beta Renamed", 2);
|
||||
return [
|
||||
["GET", "/api/folders", [rota]],
|
||||
["GET", "/api/folders/1", rota],
|
||||
["POST", "/api/folders", alpha, 200, { title: "Scratch Alpha" }],
|
||||
["GET", "/api/folders/2", alpha],
|
||||
["GET", "/api/folders", [rota, alpha]],
|
||||
["POST", "/api/folders", beta, 200, { title: "Scratch Beta" }],
|
||||
["GET", "/api/folders/3", beta],
|
||||
["GET", "/api/folders", [rota, alpha, beta]],
|
||||
[
|
||||
"PUT",
|
||||
"/api/folders/3",
|
||||
renamed,
|
||||
200,
|
||||
{ title: "Beta Renamed", version: 1 },
|
||||
],
|
||||
["GET", "/api/folders/3", renamed],
|
||||
["GET", "/api/folders", [renamed, rota, alpha]],
|
||||
];
|
||||
})();
|
||||
|
||||
const OWNER = {
|
||||
id: 1,
|
||||
name: "Rea Admin",
|
||||
created: "2026-10-01T09:00:00Z",
|
||||
};
|
||||
|
||||
/** A create answer before the application loads what it stored: the owner's unset values and an empty list spelled `[]` where reads spell `null`. */
|
||||
const unloaded = (entity: Entity, at: number): Entity => ({
|
||||
...entity,
|
||||
assignees: [],
|
||||
owner: { id: 1, name: "", created: "0001-01-01T00:00:00Z" },
|
||||
created: bookClock(at).replace("Z", ".123456789Z"),
|
||||
});
|
||||
|
||||
const task = (id: number, title: string, at: number): Entity => ({
|
||||
id,
|
||||
title,
|
||||
done: false,
|
||||
assignees: null,
|
||||
owner: OWNER,
|
||||
created: bookClock(at),
|
||||
});
|
||||
|
||||
/** Task creates whose answers show the owner and assignees unloaded, then a done flag whose answer spells empty assignees `[]`. */
|
||||
export const UNLOADED_ANSWER_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const seed = task(1, "Trim the wicks", 0);
|
||||
const first = task(2, "Order lamp oil", 2);
|
||||
const second = task(3, "Paint the rail", 5);
|
||||
const done = { ...first, done: true };
|
||||
return [
|
||||
["GET", "/api/tasks", [seed]],
|
||||
["GET", "/api/tasks/1", seed],
|
||||
[
|
||||
"POST",
|
||||
"/api/tasks",
|
||||
unloaded(first, 2),
|
||||
201,
|
||||
{ title: "Order lamp oil" },
|
||||
],
|
||||
["GET", "/api/tasks/2", first],
|
||||
["GET", "/api/tasks", [seed, first]],
|
||||
[
|
||||
"POST",
|
||||
"/api/tasks",
|
||||
unloaded(second, 5),
|
||||
201,
|
||||
{ title: "Paint the rail" },
|
||||
],
|
||||
["GET", "/api/tasks/3", second],
|
||||
["GET", "/api/tasks", [seed, first, second]],
|
||||
["PATCH", "/api/tasks/2", { ...done, assignees: [] }, 200, { done: true }],
|
||||
["GET", "/api/tasks/2", done],
|
||||
["GET", "/api/tasks", [seed, done, second]],
|
||||
];
|
||||
})();
|
||||
|
||||
/** Task creates whose answers show the owner by id alone, which their item reads show in full. */
|
||||
export const PARTIAL_OWNER_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const seed = task(1, "Trim the wicks", 0);
|
||||
const first = task(2, "Order lamp oil", 2);
|
||||
const second = task(3, "Paint the rail", 5);
|
||||
const answered = (entity: Entity): Entity => ({
|
||||
...entity,
|
||||
owner: { id: 1 },
|
||||
});
|
||||
return [
|
||||
["GET", "/api/tasks", [seed]],
|
||||
["GET", "/api/tasks/1", seed],
|
||||
["POST", "/api/tasks", answered(first), 201, { title: "Order lamp oil" }],
|
||||
["GET", "/api/tasks/2", first],
|
||||
["GET", "/api/tasks", [seed, first]],
|
||||
["POST", "/api/tasks", answered(second), 201, { title: "Paint the rail" }],
|
||||
["GET", "/api/tasks/3", second],
|
||||
["GET", "/api/tasks", [seed, first, second]],
|
||||
];
|
||||
})();
|
||||
|
||||
/** A tag as the application stores it: a text color it derives from the color, which no rule rebuilds. */
|
||||
const painted = (id: number, name: string, color: string, text: string) => ({
|
||||
id,
|
||||
name,
|
||||
color,
|
||||
text_color: text,
|
||||
});
|
||||
|
||||
/** Two tag creates whose derived text colors differ, then deletes of both, each read back as gone. */
|
||||
export const DERIVED_TEXT_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const seed = painted(1, "Inbox", "#2a9d8f", "#ffffff");
|
||||
const light = painted(2, "Light", "#a8dadc", "#000000");
|
||||
const dark = painted(3, "Dark", "#264653", "#ffffff");
|
||||
const { id: _light, text_color: _lightText, ...lightSent } = light;
|
||||
const { id: _dark, text_color: _darkText, ...darkSent } = dark;
|
||||
return [
|
||||
["GET", "/api/tags", [seed]],
|
||||
["GET", "/api/tags/1", seed],
|
||||
["POST", "/api/tags", light, 201, lightSent],
|
||||
["GET", "/api/tags/2", light],
|
||||
["GET", "/api/tags", [seed, light]],
|
||||
["POST", "/api/tags", dark, 201, darkSent],
|
||||
["GET", "/api/tags/3", dark],
|
||||
["GET", "/api/tags", [seed, light, dark]],
|
||||
["DELETE", "/api/tags/2", "", 204],
|
||||
["GET", "/api/tags/2", { detail: "Not found." }, 404],
|
||||
["DELETE", "/api/tags/3", "", 204],
|
||||
["GET", "/api/tags/3", { detail: "Not found." }, 404],
|
||||
["GET", "/api/tags", [seed]],
|
||||
];
|
||||
})();
|
||||
|
||||
const contact = (
|
||||
id: number,
|
||||
fields: {
|
||||
readonly firstName: string | null;
|
||||
readonly lastName: string;
|
||||
readonly email: string | null;
|
||||
},
|
||||
): Entity => ({
|
||||
id,
|
||||
...fields,
|
||||
emailOptedOut: fields.email === null ? null : false,
|
||||
});
|
||||
|
||||
/** A contact form's full create, then one with only the last name, whose empty members store as sent: `null` where the first stored a string, and an opt-out flag `null` where it stored `false`. */
|
||||
export const MINIMAL_CONTACT_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const seed = contact(1, {
|
||||
firstName: "Ada",
|
||||
lastName: "Finch",
|
||||
email: "ada@example.test",
|
||||
});
|
||||
const full = contact(2, {
|
||||
firstName: "Tomas",
|
||||
lastName: "Vey",
|
||||
email: "tomas@example.test",
|
||||
});
|
||||
const minimal = contact(3, {
|
||||
firstName: null,
|
||||
lastName: "Okafor",
|
||||
email: null,
|
||||
});
|
||||
const sent = ({ id: _id, ...members }: Entity) => members;
|
||||
return [
|
||||
["GET", "/api/contacts", [seed]],
|
||||
["GET", "/api/contacts/1", seed],
|
||||
["POST", "/api/contacts", full, 201, sent(full)],
|
||||
["GET", "/api/contacts/2", full],
|
||||
["GET", "/api/contacts", [seed, full]],
|
||||
["POST", "/api/contacts", minimal, 201, sent(minimal)],
|
||||
["GET", "/api/contacts/3", minimal],
|
||||
["GET", "/api/contacts", [seed, full, minimal]],
|
||||
];
|
||||
})();
|
||||
|
||||
/** The form label creates, then a bulk rename naming each label and its new name in an array, read back. */
|
||||
export const BULK_RENAMED_LABEL_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const rows = formLabelRows();
|
||||
const [attic, garden] = [
|
||||
label(4, "Attic", { color: "#00aa44" }),
|
||||
label(3, "Garden", { description: "Seeds" }),
|
||||
];
|
||||
const zinc = { ...KITCHEN, name: "Zinc" };
|
||||
return [
|
||||
...rows,
|
||||
["PATCH", "/api/labels", [zinc], 200, [{ id: 1, name: "Zinc" }]],
|
||||
["GET", "/api/labels/1", zinc],
|
||||
["GET", "/api/labels", [attic, garden, TOOLS, zinc]],
|
||||
];
|
||||
})();
|
||||
|
||||
/** Notes created with text, then sent `null`, then left out and stored `""`, each read back. */
|
||||
export const NOTE_DEFAULT_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const note = (id: number, name: string, text: string | null) => ({
|
||||
id,
|
||||
name,
|
||||
note: text,
|
||||
});
|
||||
const seed = note(1, "Seed", "kept");
|
||||
const first = note(2, "First", "text");
|
||||
const second = note(3, "Second", null);
|
||||
const third = note(4, "Third", "");
|
||||
return [
|
||||
["GET", "/api/notes", [seed]],
|
||||
["GET", "/api/notes/1", seed],
|
||||
["POST", "/api/notes", first, 201, { name: "First", note: "text" }],
|
||||
["GET", "/api/notes/2", first],
|
||||
["GET", "/api/notes", [seed, first]],
|
||||
["POST", "/api/notes", second, 201, { name: "Second", note: null }],
|
||||
["GET", "/api/notes/3", second],
|
||||
["GET", "/api/notes", [seed, first, second]],
|
||||
["POST", "/api/notes", third, 201, { name: "Third" }],
|
||||
["GET", "/api/notes/4", third],
|
||||
["GET", "/api/notes", [seed, first, second, third]],
|
||||
];
|
||||
})();
|
||||
|
||||
/**
|
||||
* Two seeded folders and two creates whose ids and titles both ascend, so either could be the order kept, then a
|
||||
* rename that sorts first by title.
|
||||
*/
|
||||
export const ID_OR_TITLE_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const [alpha, bravo] = [folder(1, "Alpha"), folder(2, "Bravo")];
|
||||
const [charlie, delta] = [folder(3, "Charlie"), folder(4, "Delta")];
|
||||
const renamed = folder(4, "Aardvark", 2);
|
||||
return [
|
||||
["GET", "/api/folders", [alpha, bravo]],
|
||||
["GET", "/api/folders/1", alpha],
|
||||
["POST", "/api/folders", charlie, 200, { title: "Charlie" }],
|
||||
["GET", "/api/folders/3", charlie],
|
||||
["GET", "/api/folders", [alpha, bravo, charlie]],
|
||||
["POST", "/api/folders", delta, 200, { title: "Delta" }],
|
||||
["GET", "/api/folders/4", delta],
|
||||
["GET", "/api/folders", [alpha, bravo, charlie, delta]],
|
||||
["PUT", "/api/folders/4", renamed, 200, { title: "Aardvark", version: 1 }],
|
||||
["GET", "/api/folders/4", renamed],
|
||||
["GET", "/api/folders", [renamed, alpha, bravo, charlie]],
|
||||
];
|
||||
})();
|
||||
|
||||
/**
|
||||
* Two seeded folders and two creates kept in creation order, which a list read after both shows (`Charlie` after
|
||||
* `Zulu`), then a rename whose read happens to follow title order.
|
||||
*/
|
||||
export const CREATION_ORDER_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const [alpha, bravo] = [folder(1, "Alpha"), folder(2, "Bravo")];
|
||||
const [zulu, charlie] = [folder(3, "Zulu"), folder(4, "Charlie")];
|
||||
const renamed = folder(3, "Aardvark", 2);
|
||||
return [
|
||||
["GET", "/api/folders", [alpha, bravo]],
|
||||
["GET", "/api/folders/1", alpha],
|
||||
["POST", "/api/folders", zulu, 200, { title: "Zulu" }],
|
||||
["GET", "/api/folders/3", zulu],
|
||||
["GET", "/api/folders", [alpha, bravo, zulu]],
|
||||
["POST", "/api/folders", charlie, 200, { title: "Charlie" }],
|
||||
["GET", "/api/folders/4", charlie],
|
||||
["GET", "/api/folders", [alpha, bravo, zulu, charlie]],
|
||||
["PUT", "/api/folders/3", renamed, 200, { title: "Aardvark", version: 1 }],
|
||||
["GET", "/api/folders/3", renamed],
|
||||
["GET", "/api/folders", [renamed, alpha, bravo, charlie]],
|
||||
];
|
||||
})();
|
||||
|
||||
/** Two label creates that send and echo empty tags, one `null` and one `[]`, as an application storing what it is sent does. */
|
||||
export const ECHOED_EMPTY_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const tagged = (entity: Entity, tags: JsonValue): Entity => ({
|
||||
...entity,
|
||||
tags,
|
||||
});
|
||||
const [kitchen, tools] = [tagged(KITCHEN, null), tagged(TOOLS, null)];
|
||||
const garden = tagged(label(3, "Garden"), null);
|
||||
const attic = tagged(label(4, "Attic"), []);
|
||||
return [
|
||||
["GET", "/api/labels", [kitchen, tools]],
|
||||
["GET", "/api/labels/1", kitchen],
|
||||
["GET", "/api/labels/2", tools],
|
||||
["POST", "/api/labels", garden, 201, formBody(garden)],
|
||||
["GET", "/api/labels/3", garden],
|
||||
["GET", "/api/labels", [garden, kitchen, tools]],
|
||||
["POST", "/api/labels", attic, 201, formBody(attic)],
|
||||
["GET", "/api/labels/4", attic],
|
||||
["GET", "/api/labels", [attic, garden, kitchen, tools]],
|
||||
];
|
||||
})();
|
||||
|
||||
/** Two bulk label renames that send the empty tags each label holds as the form does, `null` on one and `[]` on the other, and echo them. */
|
||||
export const BULK_EMPTY_TAG_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const tagged = (entity: Entity, tags: JsonValue): Entity => ({
|
||||
...entity,
|
||||
tags,
|
||||
});
|
||||
const [kitchen, tools] = [tagged(KITCHEN, null), tagged(TOOLS, [])];
|
||||
const [larder, shed] = [
|
||||
{ ...kitchen, name: "Larder" },
|
||||
{ ...tools, name: "Shed" },
|
||||
];
|
||||
return [
|
||||
["GET", "/api/labels", [kitchen, tools]],
|
||||
["GET", "/api/labels/1", kitchen],
|
||||
["GET", "/api/labels/2", tools],
|
||||
[
|
||||
"PATCH",
|
||||
"/api/labels",
|
||||
[larder],
|
||||
200,
|
||||
[{ id: 1, name: "Larder", tags: null }],
|
||||
],
|
||||
["GET", "/api/labels/1", larder],
|
||||
["PATCH", "/api/labels", [shed], 200, [{ id: 2, name: "Shed", tags: [] }]],
|
||||
["GET", "/api/labels/2", shed],
|
||||
["GET", "/api/labels", [larder, shed]],
|
||||
];
|
||||
})();
|
||||
|
||||
/**
|
||||
* Two seeded books kept in id order, which their names follow too, a create a read sorted by shelf places by its tie
|
||||
* with a seeded book, then a rename an unsorted read lists in name order.
|
||||
*/
|
||||
export const SHELF_SORTED_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const shelved = (id: number, name: string, shelf: string): Entity => ({
|
||||
id,
|
||||
name,
|
||||
shelf,
|
||||
});
|
||||
const [alpha, bravo] = [shelved(1, "Alpha", "b"), shelved(2, "Bravo", "a")];
|
||||
const charlie = shelved(3, "Charlie", "a");
|
||||
const renamed = shelved(3, "Aardvark", "a");
|
||||
return [
|
||||
["GET", "/api/books", [alpha, bravo]],
|
||||
["GET", "/api/books?sort=-id", [bravo, alpha]],
|
||||
["GET", "/api/books/1", alpha],
|
||||
["GET", "/api/books/2", bravo],
|
||||
["POST", "/api/books", charlie, 201, { name: "Charlie", shelf: "a" }],
|
||||
["GET", "/api/books/3", charlie],
|
||||
["GET", "/api/books?sort=shelf", [bravo, charlie, alpha]],
|
||||
["PUT", "/api/books/3", renamed, 200, { name: "Aardvark", shelf: "a" }],
|
||||
["GET", "/api/books/3", renamed],
|
||||
["GET", "/api/books", [renamed, alpha, bravo]],
|
||||
];
|
||||
})();
|
||||
|
||||
/** Seeded folders listed out of title order, then a rename after which a list read happens to show title order. */
|
||||
export const UNSORTED_SEED_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const [charlie, bravo, alpha] = [
|
||||
folder(1, "Charlie"),
|
||||
folder(2, "Bravo"),
|
||||
folder(3, "Alpha"),
|
||||
];
|
||||
const renamed = folder(1, "Zulu", 2);
|
||||
return [
|
||||
["GET", "/api/folders", [charlie, bravo, alpha]],
|
||||
["GET", "/api/folders/1", charlie],
|
||||
["PUT", "/api/folders/1", renamed, 200, { title: "Zulu", version: 1 }],
|
||||
["GET", "/api/folders/1", renamed],
|
||||
["GET", "/api/folders", [alpha, bravo, renamed]],
|
||||
];
|
||||
})();
|
||||
|
||||
/** Labels listed by name but not by id, a create no list shows placed, then a rename read back by id only. */
|
||||
export const UNPLACED_CREATE_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const [alpha, bravo, delta] = [
|
||||
label(3, "Alpha"),
|
||||
label(1, "Bravo"),
|
||||
label(2, "Delta"),
|
||||
];
|
||||
const charlie = label(4, "Charlie");
|
||||
const zulu = label(1, "Zulu");
|
||||
return [
|
||||
["GET", "/api/labels", [alpha, bravo, delta]],
|
||||
["GET", "/api/labels/1", bravo],
|
||||
["POST", "/api/labels", charlie, 201, formBody(charlie)],
|
||||
["GET", "/api/labels/4", charlie],
|
||||
["PATCH", "/api/labels/1", zulu, 200, { name: "Zulu" }],
|
||||
["GET", "/api/labels/1", zulu],
|
||||
];
|
||||
})();
|
||||
|
||||
/** A rename confirmed in place by an unsorted list, then a second rename whose list is sorted by title: serving would move only the second. */
|
||||
export const CONFIRMED_RENAME_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const [delta, bravo, alpha] = [
|
||||
folder(1, "Delta"),
|
||||
folder(2, "Bravo"),
|
||||
folder(3, "Alpha"),
|
||||
];
|
||||
const aardvark = folder(3, "Aardvark", 2);
|
||||
const echo = folder(2, "Echo", 2);
|
||||
return [
|
||||
["GET", "/api/folders", [delta, bravo, alpha]],
|
||||
["GET", "/api/folders/1", delta],
|
||||
["PUT", "/api/folders/3", aardvark, 200, { title: "Aardvark", version: 1 }],
|
||||
["GET", "/api/folders/3", aardvark],
|
||||
["GET", "/api/folders", [delta, bravo, aardvark]],
|
||||
["PUT", "/api/folders/2", echo, 200, { title: "Echo", version: 1 }],
|
||||
["GET", "/api/folders/2", echo],
|
||||
["GET", "/api/folders", [aardvark, delta, echo]],
|
||||
];
|
||||
})();
|
||||
|
||||
/** Books kept in id order, which a `?sort=shelf` read breaks ties of by name, then a rename that read shows reordered. */
|
||||
export const SHELF_TIE_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const shelved = (id: number, name: string): Entity => ({
|
||||
id,
|
||||
name,
|
||||
shelf: "a",
|
||||
});
|
||||
const [alpha, bravo] = [shelved(1, "Alpha"), shelved(2, "Bravo")];
|
||||
const zulu = shelved(1, "Zulu");
|
||||
return [
|
||||
["GET", "/api/books", [alpha, bravo]],
|
||||
["GET", "/api/books?sort=-id", [bravo, alpha]],
|
||||
["GET", "/api/books/1", alpha],
|
||||
["GET", "/api/books/2", bravo],
|
||||
["PUT", "/api/books/1", zulu, 200, { name: "Zulu", shelf: "a" }],
|
||||
["GET", "/api/books/1", zulu],
|
||||
["GET", "/api/books?sort=shelf", [bravo, zulu]],
|
||||
["GET", "/api/books", [zulu, bravo]],
|
||||
];
|
||||
})();
|
||||
|
||||
/** Books listed by name but not by id, a create no read shows placed, then a rename only a read sorted by shelf shows moved. */
|
||||
export const SHELF_GUESS_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const shelved = (id: number, name: string, shelf: string): Entity => ({
|
||||
id,
|
||||
name,
|
||||
shelf,
|
||||
});
|
||||
const [alpha, bravo, delta] = [
|
||||
shelved(3, "Alpha", "b"),
|
||||
shelved(1, "Bravo", "a"),
|
||||
shelved(2, "Delta", "a"),
|
||||
];
|
||||
const charlie = shelved(4, "Charlie", "c");
|
||||
const zulu = shelved(1, "Zulu", "a");
|
||||
return [
|
||||
["GET", "/api/books", [alpha, bravo, delta]],
|
||||
["GET", "/api/books?sort=-id", [alpha, delta, bravo]],
|
||||
["GET", "/api/books/1", bravo],
|
||||
["POST", "/api/books", charlie, 201, { name: "Charlie", shelf: "c" }],
|
||||
["GET", "/api/books/4", charlie],
|
||||
["PUT", "/api/books/1", zulu, 200, { name: "Zulu", shelf: "a" }],
|
||||
["GET", "/api/books/1", zulu],
|
||||
["GET", "/api/books?sort=shelf", [delta, zulu, alpha, charlie]],
|
||||
];
|
||||
})();
|
||||
|
||||
/** Two renames a list sorted by name shows moved, the first read back between them only by a list its query sorts by id. */
|
||||
export const ID_SORTED_READ_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const book = (id: number, name: string): Entity => ({ id, name });
|
||||
const [charlie, bravo, alpha] = [
|
||||
book(1, "Charlie"),
|
||||
book(2, "Bravo"),
|
||||
book(3, "Alpha"),
|
||||
];
|
||||
const zulu = book(1, "Zulu");
|
||||
const yankee = book(2, "Yankee");
|
||||
return [
|
||||
["GET", "/api/books", [charlie, bravo, alpha]],
|
||||
["GET", "/api/books?sort=-id", [alpha, bravo, charlie]],
|
||||
["GET", "/api/books/1", charlie],
|
||||
["PUT", "/api/books/1", zulu, 200, { name: "Zulu" }],
|
||||
["GET", "/api/books/1", zulu],
|
||||
["GET", "/api/books?sort=id", [zulu, bravo, alpha]],
|
||||
["PUT", "/api/books/2", yankee, 200, { name: "Yankee" }],
|
||||
["GET", "/api/books/2", yankee],
|
||||
["GET", "/api/books", [alpha, yankee, zulu]],
|
||||
];
|
||||
})();
|
||||
|
||||
/** Folders listed out of title order, then two renames before a list sorted by title that moving each in turn cannot reach. */
|
||||
export const UNSORTED_PENDING_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const [echo, foxtrot, bravo, delta] = [
|
||||
folder(1, "Echo"),
|
||||
folder(2, "Foxtrot"),
|
||||
folder(3, "Bravo"),
|
||||
folder(4, "Delta"),
|
||||
];
|
||||
const charlie = folder(1, "Charlie", 2);
|
||||
const alpha = folder(2, "Alpha", 2);
|
||||
return [
|
||||
["GET", "/api/folders", [echo, foxtrot, bravo, delta]],
|
||||
["GET", "/api/folders/1", echo],
|
||||
["PUT", "/api/folders/1", charlie, 200, { title: "Charlie", version: 1 }],
|
||||
["GET", "/api/folders/1", charlie],
|
||||
["PUT", "/api/folders/2", alpha, 200, { title: "Alpha", version: 1 }],
|
||||
["GET", "/api/folders/2", alpha],
|
||||
["GET", "/api/folders", [alpha, bravo, charlie, delta]],
|
||||
];
|
||||
})();
|
||||
|
||||
/** A label renamed twice: a list shows the first rename in place, a list in name order the second. */
|
||||
const renamedTwice = (
|
||||
names: readonly [string, string, string],
|
||||
first: string,
|
||||
second: string,
|
||||
): readonly TimedCaptureRow[] => {
|
||||
const seeds = names.map((name, index) => label(index + 1, name));
|
||||
const [, two, three] = seeds;
|
||||
const renamed = { ...seeds[0]!, name: first };
|
||||
const again = { ...seeds[0]!, name: second };
|
||||
return [
|
||||
["GET", "/api/labels", seeds],
|
||||
["GET", "/api/labels/1", seeds[0]!],
|
||||
["PATCH", "/api/labels/1", renamed, 200, { name: first }],
|
||||
["GET", "/api/labels/1", renamed],
|
||||
["GET", "/api/labels", [renamed, two!, three!]],
|
||||
["PATCH", "/api/labels/1", again, 200, { name: second }],
|
||||
["GET", "/api/labels/1", again],
|
||||
["GET", "/api/labels", [two!, three!, again]],
|
||||
];
|
||||
};
|
||||
|
||||
/** Bravo renamed Delta and listed in place, then renamed Zulu and listed last. */
|
||||
export const IN_PLACE_THEN_LAST_ROWS = renamedTwice(
|
||||
["Bravo", "Alpha", "Charlie"],
|
||||
"Delta",
|
||||
"Zulu",
|
||||
);
|
||||
|
||||
/** Alpha renamed Zulu and listed in place, then renamed Delta and listed last. */
|
||||
export const IN_PLACE_THEN_SORTED_ROWS = renamedTwice(
|
||||
["Alpha", "Bravo", "Charlie"],
|
||||
"Zulu",
|
||||
"Delta",
|
||||
);
|
||||
|
||||
/** A user create that sends `active: false` and is answered so, whose read shows the account active. */
|
||||
export const ACTIVE_ON_READ_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const user = (id: number, name: string, active: boolean): Entity => ({
|
||||
id,
|
||||
name,
|
||||
active,
|
||||
});
|
||||
const root = user(1, "Root", true);
|
||||
return [
|
||||
["GET", "/api/users", [root]],
|
||||
["GET", "/api/users/1", root],
|
||||
[
|
||||
"POST",
|
||||
"/api/users",
|
||||
user(2, "Ada", false),
|
||||
201,
|
||||
{ name: "Ada", active: false },
|
||||
],
|
||||
["GET", "/api/users/2", user(2, "Ada", true)],
|
||||
["GET", "/api/users", [root, user(2, "Ada", true)]],
|
||||
];
|
||||
})();
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
import type { JsonValue } from "../../src/domain/jsonValue.js";
|
||||
import type { TimedCaptureRow } from "./analysisView.js";
|
||||
|
||||
type Entity = Record<string, JsonValue>;
|
||||
|
||||
const contact = (id: number, name: string): Entity => ({
|
||||
id,
|
||||
name,
|
||||
deleted: false,
|
||||
});
|
||||
|
||||
const page = (list: readonly Entity[]): Entity => ({
|
||||
total: list.length,
|
||||
list: [...list],
|
||||
});
|
||||
|
||||
const ADA = contact(1, "Ada Finch");
|
||||
const MINA = contact(2, "Mina Okafor");
|
||||
const RUI = contact(3, "Rui Castellanos");
|
||||
|
||||
/** A contact deleted, then read by id as EspoCRM answers for soft-deleted records: still found, marked `deleted`. */
|
||||
export const SOFT_DELETED_ROWS: readonly TimedCaptureRow[] = [
|
||||
["GET", "/api/contacts?maxSize=20&offset=0", page([ADA, MINA, RUI])],
|
||||
["GET", "/api/contacts/1", ADA],
|
||||
["GET", "/api/contacts/2", MINA],
|
||||
["GET", "/api/contacts/3", RUI],
|
||||
["DELETE", "/api/contacts/2", true],
|
||||
["GET", "/api/contacts/2", { ...MINA, deleted: true }],
|
||||
["DELETE", "/api/contacts/3", true],
|
||||
["GET", "/api/contacts/3", { ...RUI, deleted: true }],
|
||||
];
|
||||
|
||||
/** Contacts deleted by calls that answer with the record already marked deleted, read back so too. */
|
||||
export const SOFT_DELETED_ANSWER_ROWS: readonly TimedCaptureRow[] = [
|
||||
["GET", "/api/contacts?maxSize=20&offset=0", page([ADA, MINA, RUI])],
|
||||
["GET", "/api/contacts/1", ADA],
|
||||
["GET", "/api/contacts/2", MINA],
|
||||
["GET", "/api/contacts/3", RUI],
|
||||
["DELETE", "/api/contacts/2", { ...MINA, deleted: true }],
|
||||
["GET", "/api/contacts/2", { ...MINA, deleted: true }],
|
||||
["DELETE", "/api/contacts/3", { ...RUI, deleted: true }],
|
||||
["GET", "/api/contacts/3", { ...RUI, deleted: true }],
|
||||
];
|
||||
|
||||
/** Contacts deleted by calls that answer with the record as it was, then read back marked deleted. */
|
||||
export const STALE_DELETE_ANSWER_ROWS: readonly TimedCaptureRow[] = [
|
||||
["GET", "/api/contacts?maxSize=20&offset=0", page([ADA, MINA, RUI])],
|
||||
["GET", "/api/contacts/1", ADA],
|
||||
["GET", "/api/contacts/2", MINA],
|
||||
["GET", "/api/contacts/3", RUI],
|
||||
["DELETE", "/api/contacts/2", MINA],
|
||||
["GET", "/api/contacts/2", { ...MINA, deleted: true }],
|
||||
["DELETE", "/api/contacts/3", RUI],
|
||||
["GET", "/api/contacts/3", { ...RUI, deleted: true }],
|
||||
];
|
||||
|
||||
/** Contacts removed by one bulk call that answers them as they were, then read back marked deleted. */
|
||||
export const STALE_BULK_DELETE_ROWS: readonly TimedCaptureRow[] = [
|
||||
["GET", "/api/contacts?maxSize=20&offset=0", page([ADA, MINA, RUI])],
|
||||
["GET", "/api/contacts/1", ADA],
|
||||
["GET", "/api/contacts/2", MINA],
|
||||
["GET", "/api/contacts/3", RUI],
|
||||
["DELETE", "/api/contacts", [MINA, RUI], 200, [{ id: 2 }, { id: 3 }]],
|
||||
["GET", "/api/contacts/2", { ...MINA, deleted: true }],
|
||||
["GET", "/api/contacts/3", { ...RUI, deleted: true }],
|
||||
];
|
||||
|
||||
/** Live contacts that omit `deleted`, one deleted and read back only with a query showing it marked, then a list without it. */
|
||||
export const OMITTED_FLAG_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const live = (entity: Entity): Entity => {
|
||||
const { deleted: _deleted, ...rest } = entity;
|
||||
return rest;
|
||||
};
|
||||
const [ada, mina, rui] = [live(ADA), live(MINA), live(RUI)];
|
||||
return [
|
||||
["GET", "/api/contacts?maxSize=20&offset=0", page([ada, mina, rui])],
|
||||
["GET", "/api/contacts?maxSize=2&offset=0", page([ada, mina])],
|
||||
["GET", "/api/contacts/1", ada],
|
||||
["GET", "/api/contacts/2", mina],
|
||||
["DELETE", "/api/contacts/2", true],
|
||||
["GET", "/api/contacts/2?_=1761520000", { ...mina, deleted: true }],
|
||||
["GET", "/api/contacts?maxSize=50&offset=0", page([ada, rui])],
|
||||
];
|
||||
})();
|
||||
|
||||
/** The same deletes read back only with a query, as a client that adds a cache-busting parameter does. */
|
||||
export const SOFT_DELETED_QUERY_ROWS: readonly TimedCaptureRow[] = [
|
||||
["GET", "/api/contacts?maxSize=20&offset=0", page([ADA, MINA, RUI])],
|
||||
["GET", "/api/contacts/1", ADA],
|
||||
["GET", "/api/contacts/2", MINA],
|
||||
["GET", "/api/contacts/3", RUI],
|
||||
["DELETE", "/api/contacts/2", true],
|
||||
["GET", "/api/contacts/2?_=1761520000", { ...MINA, deleted: true }],
|
||||
["DELETE", "/api/contacts/3", true],
|
||||
["GET", "/api/contacts/3?_=1761520001", { ...RUI, deleted: true }],
|
||||
];
|
||||
|
||||
/** A held contact read back marked deleted though no delete of it was recorded, then a list without it after another delete. */
|
||||
export const GHOST_DELETED_ROWS: readonly TimedCaptureRow[] = [
|
||||
["GET", "/api/contacts?maxSize=20&offset=0", page([ADA, MINA, RUI])],
|
||||
["GET", "/api/contacts?maxSize=2&offset=0", page([ADA, MINA])],
|
||||
["GET", "/api/contacts/1", ADA],
|
||||
["GET", "/api/contacts/2", MINA],
|
||||
["GET", "/api/contacts/3", RUI],
|
||||
["DELETE", "/api/contacts/3", true],
|
||||
["GET", "/api/contacts/2", { ...MINA, deleted: true }],
|
||||
["GET", "/api/contacts?maxSize=50&offset=0", page([ADA])],
|
||||
];
|
||||
|
||||
/** Contacts deleted, then listed with a page size named `maxSize` other than the initial list's. */
|
||||
export const MAX_SIZE_ROWS: readonly TimedCaptureRow[] = [
|
||||
["GET", "/api/contacts?maxSize=20&offset=0", page([ADA, MINA, RUI])],
|
||||
["GET", "/api/contacts?maxSize=2&offset=0", page([ADA, MINA])],
|
||||
["GET", "/api/contacts/1", ADA],
|
||||
["DELETE", "/api/contacts/2", true],
|
||||
["GET", "/api/contacts?maxSize=50&offset=0", page([ADA, RUI])],
|
||||
["DELETE", "/api/contacts/3", true],
|
||||
["GET", "/api/contacts?maxSize=50&offset=0", page([ADA])],
|
||||
];
|
||||
|
||||
const task = (id: number, title: string, extra: Entity = {}): Entity => ({
|
||||
id,
|
||||
title,
|
||||
done: false,
|
||||
...extra,
|
||||
});
|
||||
|
||||
/** Its subscription: shown only on tasks created while recording, as Vikunja subscribes their creator. */
|
||||
const subscribed = (id: number): Entity => ({
|
||||
subscription: { id: 100 + id, entity: "task", entity_id: id },
|
||||
});
|
||||
|
||||
/**
|
||||
* Seeded tasks without a `subscription`, created tasks whose item reads show one, and updates of the created tasks,
|
||||
* whose answers show it too.
|
||||
*/
|
||||
export const LATER_FIELD_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const seed = task(1, "Trim the wicks");
|
||||
const first = task(2, "Order lamp oil");
|
||||
const second = task(3, "Paint the rail");
|
||||
const created = (entity: Entity) => ({
|
||||
...entity,
|
||||
...subscribed(Number(entity["id"])),
|
||||
});
|
||||
const done = (entity: Entity) => ({ ...created(entity), done: true });
|
||||
return [
|
||||
["GET", "/api/tasks", [seed]],
|
||||
["GET", "/api/tasks/1", seed],
|
||||
["POST", "/api/tasks", first, 201, { title: "Order lamp oil" }],
|
||||
["GET", "/api/tasks/2", created(first)],
|
||||
["GET", "/api/tasks", [seed, first]],
|
||||
["POST", "/api/tasks", second, 201, { title: "Paint the rail" }],
|
||||
["GET", "/api/tasks/3", created(second)],
|
||||
["GET", "/api/tasks", [seed, first, second]],
|
||||
["PATCH", "/api/tasks/2", done(first), 200, { done: true }],
|
||||
["GET", "/api/tasks/2", done(first)],
|
||||
["PATCH", "/api/tasks/3", done(second), 200, { done: true }],
|
||||
["GET", "/api/tasks/3", done(second)],
|
||||
[
|
||||
"GET",
|
||||
"/api/tasks",
|
||||
[seed, { ...first, done: true }, { ...second, done: true }],
|
||||
],
|
||||
];
|
||||
})();
|
||||
|
||||
/** The same seeded and created tasks, then one bulk update marking both created tasks done, whose answer shows their subscriptions. */
|
||||
export const LATER_BULK_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const seed = task(1, "Trim the wicks");
|
||||
const [first, second] = [
|
||||
task(2, "Order lamp oil"),
|
||||
task(3, "Paint the rail"),
|
||||
];
|
||||
const created = (entity: Entity) => ({
|
||||
...entity,
|
||||
...subscribed(Number(entity["id"])),
|
||||
});
|
||||
const done = (entity: Entity) => ({ ...created(entity), done: true });
|
||||
return [
|
||||
["GET", "/api/tasks", [seed]],
|
||||
["GET", "/api/tasks/1", seed],
|
||||
["POST", "/api/tasks", first, 201, { title: "Order lamp oil" }],
|
||||
["GET", "/api/tasks/2", created(first)],
|
||||
["POST", "/api/tasks", second, 201, { title: "Paint the rail" }],
|
||||
["GET", "/api/tasks/3", created(second)],
|
||||
["GET", "/api/tasks", [seed, first, second]],
|
||||
[
|
||||
"PATCH",
|
||||
"/api/tasks",
|
||||
[done(first), done(second)],
|
||||
200,
|
||||
[
|
||||
{ id: 2, done: true },
|
||||
{ id: 3, done: true },
|
||||
],
|
||||
],
|
||||
["GET", "/api/tasks/2", done(first)],
|
||||
["GET", "/api/tasks/3", done(second)],
|
||||
[
|
||||
"GET",
|
||||
"/api/tasks",
|
||||
[seed, { ...first, done: true }, { ...second, done: true }],
|
||||
],
|
||||
];
|
||||
})();
|
||||
|
||||
/** A recorded bulk update of a created task and a seeded one, whose answer shows the subscription on the created task only. */
|
||||
export const LATER_MIXED_BULK_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const seed = task(1, "Trim the wicks");
|
||||
const first = task(2, "Order lamp oil");
|
||||
const created = { ...first, ...subscribed(2) };
|
||||
const done = { ...created, done: true };
|
||||
const seedDone = { ...seed, done: true };
|
||||
return [
|
||||
["GET", "/api/tasks", [seed]],
|
||||
["GET", "/api/tasks/1", seed],
|
||||
["POST", "/api/tasks", first, 201, { title: "Order lamp oil" }],
|
||||
["GET", "/api/tasks/2", created],
|
||||
["GET", "/api/tasks", [seed, first]],
|
||||
[
|
||||
"PATCH",
|
||||
"/api/tasks",
|
||||
[done, seedDone],
|
||||
200,
|
||||
[
|
||||
{ id: 2, done: true },
|
||||
{ id: 1, done: true },
|
||||
],
|
||||
],
|
||||
["GET", "/api/tasks/2", done],
|
||||
["GET", "/api/tasks/1", seedDone],
|
||||
["GET", "/api/tasks", [seedDone, { ...first, done: true }]],
|
||||
];
|
||||
})();
|
||||
Reference in New Issue
Block a user