mirror of
https://github.com/morluto/rea.git
synced 2026-11-05 06:15:59 +00:00
feat(environment): serve writes whose calls send different members
Recordings now vary what a type's two creates and updates send, and REA withheld writes where a later call left out what the first sent. An update plan's required members narrow to those every call sent; a member one create sent and another left for the application to number is a sequence that takes the value a call sends; a PUT's left-out member the application cleared becomes that member's default; and an update's clock is held as the read after it spells it. The skill spaces creates (skill 54). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7d01a5d5f3
commit
6300ec804a
18 files changed
+475
-17
No files matched your search
@@ -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: "53"
|
||||
version: "54"
|
||||
---
|
||||
|
||||
# REA
|
||||
|
||||
@@ -153,8 +153,9 @@ client with it. Match `wait_for` locators to exactly one element.
|
||||
with varied free text. After each write, and before the next one, read the
|
||||
object back by id and then its list: create 1, read 1, list, create 2,
|
||||
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`).
|
||||
4. Leave more than a second between two creates and between a create and its
|
||||
first edit (`wait_for_timeout` with `duration_ms: 1100`), so lists sorted
|
||||
by creation time show them in order.
|
||||
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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Implementation plan
|
||||
|
||||
1. **Update members.** `refinedBody` keeps only the required members a later update also sent.
|
||||
2. **Numbered unless sent.** `sent` on sequence rules and their schema; the create merge adopts it for an echoed member
|
||||
and a sequence from a call that left the member out; serving and replay take the sent value when there is one.
|
||||
3. **Cleared by a PUT.** `explainUpdate` records a `PUT`'s left-out member cleared to an empty value as a default;
|
||||
update refinement keeps defaults from later calls; `updatedFields` applies them to members a call leaves out.
|
||||
4. **Update clocks.** `restampedFields` respells clocks from the first later read as it does nested clocks.
|
||||
5. **Skill, tests and docs.**
|
||||
@@ -0,0 +1,50 @@
|
||||
# Serve writes whose calls send different members
|
||||
|
||||
Issue: https://github.com/morluto/rea/issues/4933
|
||||
|
||||
## Outcome
|
||||
|
||||
A fresh recording round of seven applications, main against #7332, showed the recording skill now varies what the two
|
||||
creates and two updates of a type send, as it asks. Where the second call left out a member the first sent, REA
|
||||
withheld the write:
|
||||
|
||||
- **Updates.** An update plan required every member its first call sent and never narrowed that, so EspoCRM's second
|
||||
contact update, which changed only an email address, was refused in both recordings of the branch.
|
||||
- **Members numbered when left out.** Linkding stores a bundle's `order` as sent, or one past the others when a call
|
||||
leaves it out. One create showed the first and another the second, which the create merge took as a disagreement.
|
||||
|
||||
The same recordings and the 130 earlier captures showed two more update gaps:
|
||||
|
||||
- **Whole-object PUTs.** Paperless clears a tag's, correspondent's or document type's `owner` when a `PUT` leaves it
|
||||
out, and Homebox an item's `assetId`, so those updates were `unexplained-change`.
|
||||
- **Update clocks read at another precision.** Vikunja answers a task update with `done_at` to the nanosecond while its
|
||||
reads spell it to the second, so the read after the update contradicted it.
|
||||
|
||||
The round also found two creates of EspoCRM contacts in one second, which a list sorted newest first shows in an order
|
||||
the application breaks ties in. The skill now spaces creates.
|
||||
|
||||
## Behaviour
|
||||
|
||||
- **Update members.** A later update narrows the plan's required members to those every call sent.
|
||||
- **Numbered unless sent.** A member one create sent and stored as sent, and another left out and the application
|
||||
numbered one past the others, is a `sequence` with `sent`: serving stores the value a call sends, else the next
|
||||
number.
|
||||
- **Cleared by a PUT.** A field a `PUT` left out that the application cleared to an empty value (`""`, `null`, `false`,
|
||||
`[]` or `{}`) is that member's default (`body.defaults`): serving stores it for an update that leaves the member out.
|
||||
- **Update clocks.** The state holds an update's clock as the first read of the entity after it spells the same instant
|
||||
at a coarser precision, as it already does for nested clocks; answers keep the update's own spelling.
|
||||
- **Skill.** Recordings leave more than a second between two creates, as between a create and its first edit.
|
||||
|
||||
Plans and views that need none of this are unchanged.
|
||||
|
||||
## Not covered
|
||||
|
||||
- Vikunja numbers tasks per project. Creates into projects the recording did not list cannot be numbered, so its task
|
||||
creates stay withheld.
|
||||
- Linkding renumbers bundles' `order` after a delete, which no rule models.
|
||||
- Homebox item creates expand sent tag ids into tag objects and pin `parentId`; they stay withheld.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- No write tool of the recorded captures goes from served to withheld.
|
||||
- The writes the diagnosis named are served where their recordings show nothing else against them.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Narrow an update plan's required members to those every call sent.
|
||||
- [x] Serve a member the value a call sends, else the next number, when calls showed both.
|
||||
- [x] Learn and serve the value a `PUT` stores for a member it leaves out.
|
||||
- [x] Hold an update's clock as the read after it spells it.
|
||||
- [x] Ask recordings to space creates.
|
||||
- [x] Add view, serving and unit tests.
|
||||
- [x] Document the rules.
|
||||
- [x] Measure the recorded captures.
|
||||
- [x] Run static, formatting, documentation and test gates.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Verification
|
||||
|
||||
Executed on macOS with Node v24.22.1 on this branch, on #7332 at `7d01a5d5` (main `5aa23c3d`), and on Ubuntu 24.04 VMs
|
||||
with snap Chromium for the recording round.
|
||||
|
||||
## Diagnosis
|
||||
|
||||
Claude Code (`claude-sonnet-5-5`) built an environment of each of seven applications from the same prompt, once with
|
||||
REA main `5aa23c3d` (skill 50) and once with #7332 at `7d01a5d5` (skill 53). Each bundle's write tasks ran with
|
||||
`fireworks_ai/gpt-oss-120b` and `fireworks_ai/glm-5p3-flash`, which scored alike:
|
||||
|
||||
| Application | Write tasks | Main | #7332 |
|
||||
| ----------- | ----------- | ------- | ------- |
|
||||
| Miniflux | 3 | 3/3 | 3/3 |
|
||||
| Grafana | 2 | 2/2 | 2/2 |
|
||||
| Vikunja | 3 + 2 | 0/3 0/2 | 1/3 2/2 |
|
||||
| Paperless | 3 | 1/3 | 2/3 |
|
||||
| Linkding | 2 | 0/2 | 2/2 |
|
||||
| Homebox | 2 | 2/2 | 0/2 |
|
||||
| EspoCRM | 3 | 1/3 | 0/3 |
|
||||
| Total | 20 | 9/20 | 12/20 |
|
||||
|
||||
Served writes were 14 and 25. Recorded again, Homebox and EspoCRM gave main 2/2 and 1/3 and #7332 2/2 and 3/3: the
|
||||
first losses were the agents' runs, not REA. Built with both versions, none of the 18 recordings served fewer writes
|
||||
with #7332 (44 and 53 in all).
|
||||
|
||||
Replaying #7332's recordings with failing checks and refusals logged gave the causes this branch takes: EspoCRM's
|
||||
second contact update left out `addressCity` and was refused for lacking a required member (both recordings); Linkding's
|
||||
second bundle create left `order` to the application, which the merge took as disagreeing with the first create's
|
||||
echo; Vikunja's task update stamped `done_at` to the nanosecond where the read after it showed seconds. Over the
|
||||
earlier captures, 14 withheld updates were `PUT`s whose application cleared a member they left out (Paperless `owner`,
|
||||
Homebox `assetId`).
|
||||
|
||||
## Gates
|
||||
|
||||
- `npm run -s check:fast`, `format:check`, `scan:todos`, `docs:check`, `verify:test-discovery`, `mcp-catalog:check` and
|
||||
`jscpd`: passed. `npx knip` reports `staticFileUrlPath`, `ObjcClassImportBindingCoverage` and
|
||||
`AndroidManifestComponentInventory`, as main `5aa23c3d` does.
|
||||
- `npx vitest run --project domain --project services`: 11,694 passed, 8 failed (`appleApplication.test.ts`), which fail
|
||||
the same way on main `5aa23c3d`.
|
||||
- `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: 80 passed. `outputSchemaProfile`, `toolSchemaValidity`,
|
||||
`mcpSchemaValidation`, `outputSchemaSemanticConstraints` and `analysisViewMcp`: 54 passed; the compact catalog stays
|
||||
under its frame.
|
||||
- The seven new view and serving tests fail on #7332 at `7d01a5d5` and pass here.
|
||||
|
||||
## Write plans over recorded captures
|
||||
|
||||
The 130 captures #7332 measured and the round's 18 recordings, built by #7332 at `7d01a5d5` and by this branch: 694
|
||||
plans, 386 served and 398. No write goes from served to withheld and every view is unchanged. Newly served:
|
||||
|
||||
- Paperless correspondent, document type and tag updates in four recordings, whose `PUT` clears `owner`.
|
||||
- EspoCRM's contact update and Linkding's bundle create in #7332's recordings of the round.
|
||||
- Grafana's folder update and delete in a fresh recording whose second update sent fewer members.
|
||||
- Homebox's item update in the published capture, whose `PUT` clears `assetId`.
|
||||
|
||||
Vikunja's task update reads back confirmed, but stays withheld: the task creates before it are not modelled (per-project
|
||||
numbers), so their entities carry the creates' answer spellings into later reads.
|
||||
|
||||
## Not verified
|
||||
|
||||
- No recording round ran with this branch, so its write tasks are not measured.
|
||||
- The skill's spacing of creates.
|
||||
- Vikunja per-project task numbers, Linkding's renumbering after a delete and Homebox item creates, which stay withheld.
|
||||
@@ -796,7 +796,9 @@ past those deleted before the capture. Two `made-object` rules agree in their
|
||||
clocks and own members, and a count only the later call shows starts at the
|
||||
first object's 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 member a create sent empty (`""`, `null`, `false`, `[]` or
|
||||
(`body.defaults`). A member one call sent and stored as sent, and another left
|
||||
out and the application numbered one past the others, takes the value a call
|
||||
sends, else that number (a `sequence` with `sent`). 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
|
||||
@@ -859,13 +861,17 @@ is unchanged) must be a clock, a nested clock, an increment, a slug, a fresh `un
|
||||
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
|
||||
an item action a `set` of a scalar to the value it showed, or the plan is
|
||||
an item action a `set` of a scalar to the value it showed, or, for a `PUT`, a
|
||||
member it left out that the application cleared to an empty value, which calls
|
||||
that leave it out then store (`body.defaults`), or the plan is
|
||||
`unexplained-change`; an increment, slug, stamp, derived
|
||||
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 and members a create sent empty.
|
||||
leaves a parameter out and the catalog advertises as required. Every member every call sent is required, except by `PATCH` and
|
||||
`POST` updates and members a create sent empty. The state holds an update's
|
||||
clock as the first read of the entity after it spells the same instant, at a
|
||||
coarser precision too.
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
FEWER_MEMBERS_ROWS,
|
||||
NUMBERED_UNLESS_SENT_ROWS,
|
||||
PUT_CLEARS_ROWS,
|
||||
} from "../../../tests/fixtures/environmentStateVariedCalls.js";
|
||||
import { servedFrom } from "../../../tests/fixtures/environmentStateServing.js";
|
||||
|
||||
describe("served writes whose calls send different members", () => {
|
||||
it("numbers a created bundle that leaves its order out, and keeps one a call sends", () => {
|
||||
const bundles = servedFrom(NUMBERED_UNLESS_SENT_ROWS);
|
||||
expect(
|
||||
bundles.answer("post_bundles", { body: { name: "Echo" } }).body,
|
||||
).toMatchObject({ name: "Echo", order: 2 });
|
||||
expect(
|
||||
bundles.answer("post_bundles", { body: { name: "Foxtrot", order: 9 } })
|
||||
.body,
|
||||
).toMatchObject({
|
||||
name: "Foxtrot",
|
||||
order: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears a member a PUT leaves out, as the application did, and keeps one it sends", () => {
|
||||
const tags = servedFrom(PUT_CLEARS_ROWS);
|
||||
expect(
|
||||
tags.answer("put_tags_by_id", { id: "2", body: { name: "Receipts" } })
|
||||
.body,
|
||||
).toMatchObject({
|
||||
name: "Receipts",
|
||||
owner: null,
|
||||
});
|
||||
expect(tags.answer("get_tags_by_id", { id: "2" }).body).toMatchObject({
|
||||
owner: null,
|
||||
});
|
||||
expect(
|
||||
tags.answer("put_tags_by_id", {
|
||||
id: "1",
|
||||
body: { name: "Mail", owner: 3 },
|
||||
}).body,
|
||||
).toMatchObject({
|
||||
name: "Mail",
|
||||
owner: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("serves an update that sends only the member every recorded update sent", () => {
|
||||
const contacts = servedFrom(FEWER_MEMBERS_ROWS);
|
||||
expect(
|
||||
contacts.answer("put_contacts_by_id", {
|
||||
id: "1",
|
||||
body: { email: "ada.f@example.test" },
|
||||
}).body,
|
||||
).toMatchObject({ name: "Ada Finch", email: "ada.f@example.test" });
|
||||
expect(
|
||||
contacts.refusal("put_contacts_by_id", {
|
||||
id: "2",
|
||||
body: { city: "Kumasi" },
|
||||
}),
|
||||
).toMatchObject({
|
||||
message: expect.stringContaining("email"),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
timedCaptureEvidence,
|
||||
type TimedCaptureRow,
|
||||
} from "../../../tests/fixtures/analysisView.js";
|
||||
import {
|
||||
DONE_CLOCK_ROWS,
|
||||
FEWER_MEMBERS_ROWS,
|
||||
NUMBERED_UNLESS_SENT_ROWS,
|
||||
PUT_CLEARS_ROWS,
|
||||
} from "../../../tests/fixtures/environmentStateVariedCalls.js";
|
||||
import { environmentStateViewOf } from "../../../tests/fixtures/environmentStateWrites.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;
|
||||
};
|
||||
|
||||
describe("writes whose calls send different members", () => {
|
||||
it("serves an update a later call sends fewer members to, requiring only those every call sent", () => {
|
||||
const plan = planOf(FEWER_MEMBERS_ROWS, "put_contacts_by_id");
|
||||
expect(plan).toMatchObject({ served: true });
|
||||
expect(plan.body?.required).toEqual(["email"]);
|
||||
expect(plan.body?.accepted).toEqual(
|
||||
expect.arrayContaining(["name", "city", "email"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("serves a create whose member one call sent and another left for the application to number", () => {
|
||||
const plan = planOf(NUMBERED_UNLESS_SENT_ROWS, "post_bundles");
|
||||
expect(plan).toMatchObject({ served: true });
|
||||
expect(plan.rules).toContainEqual({
|
||||
field: "order",
|
||||
rule: "sequence",
|
||||
scope: "collection",
|
||||
sent: true,
|
||||
});
|
||||
expect(plan.body?.required).toEqual(["name"]);
|
||||
});
|
||||
|
||||
it("confirms an update whose read shows the clock it stamped to the second", () => {
|
||||
expect(planOf(DONE_CLOCK_ROWS, "patch_tasks_by_id")).toMatchObject({
|
||||
served: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("serves a PUT that clears a member it left out, as a default", () => {
|
||||
const plan = planOf(PUT_CLEARS_ROWS, "put_tags_by_id");
|
||||
expect(plan).toMatchObject({ served: true });
|
||||
expect(plan.body?.defaults).toEqual([{ member: "owner", value: null }]);
|
||||
});
|
||||
});
|
||||
@@ -246,6 +246,7 @@ const fieldRuleSchema = z.discriminatedUnion("rule", [
|
||||
rule: z.literal("sequence"),
|
||||
scope: z.enum(["collection", "type"]),
|
||||
start: z.number().int().exactOptional(),
|
||||
sent: z.literal(true).exactOptional(),
|
||||
}),
|
||||
z.strictObject({ ...ruled, rule: z.literal("unique") }),
|
||||
z.strictObject({
|
||||
|
||||
@@ -229,8 +229,11 @@ const ruledValue = (
|
||||
switch (rule.rule) {
|
||||
case "reference":
|
||||
return referencedValue(rule, body, referenced);
|
||||
case "identity":
|
||||
case "sequence":
|
||||
return rule.sent === true && Object.hasOwn(body, rule.field)
|
||||
? ok(body[rule.field]!)
|
||||
: generate(rule);
|
||||
case "identity":
|
||||
case "unique":
|
||||
case "renewed":
|
||||
case "clock":
|
||||
@@ -489,6 +492,9 @@ export const updatedFields = (
|
||||
setOwnMember(written, rule.field, value.value);
|
||||
}
|
||||
}
|
||||
// A member the call leaves out takes the default a whole-object update showed.
|
||||
for (const { member, value } of plan.body.defaults ?? [])
|
||||
if (!Object.hasOwn(sent, member)) setOwnMember(written, member, value);
|
||||
const unspelled = derive(
|
||||
plan.rules.filter(
|
||||
(rule) => rule.rule !== "slug" || Object.hasOwn(sent, rule.from),
|
||||
|
||||
@@ -228,11 +228,21 @@ export const refinedBody = (
|
||||
const accepted = later.accepted.filter(
|
||||
(member) => !body.accepted.includes(member),
|
||||
);
|
||||
const held = body.defaults ?? [];
|
||||
const defaults = [
|
||||
...held,
|
||||
...(later.defaults ?? []).filter(
|
||||
(entry) => !held.some((kept) => kept.member === entry.member),
|
||||
),
|
||||
];
|
||||
return {
|
||||
...body,
|
||||
...(defaults.length === 0 ? {} : { defaults }),
|
||||
// A later call may be the first to show the body repeating the path's id.
|
||||
identity: body.identity ?? later.identity,
|
||||
accepted: [...body.accepted, ...accepted],
|
||||
// A member is required only when every call sent it.
|
||||
required: body.required.filter((member) => later.required.includes(member)),
|
||||
fixed: [
|
||||
...body.fixed,
|
||||
...later.fixed.filter((entry) => !fixed.has(entry.member)),
|
||||
|
||||
@@ -322,6 +322,31 @@ const shownAlone = (
|
||||
return sent ? undefined : { rule: own };
|
||||
};
|
||||
|
||||
/** A member one call sent and stored as sent, and another left out and the application numbered: the value a call sends, else the next number. */
|
||||
const sentOrNumbered = (
|
||||
before: FieldRule,
|
||||
after: FieldRule,
|
||||
field: string,
|
||||
calls: FieldCalls,
|
||||
agree: (held: FieldRule) => FieldRule | undefined,
|
||||
): FieldRule | undefined => {
|
||||
const laterSent = Object.hasOwn(calls.later.body, field);
|
||||
if (before.rule === "echo")
|
||||
return after.rule === "sequence" && !laterSent
|
||||
? { ...after, sent: true }
|
||||
: undefined;
|
||||
if (before.rule !== "sequence") return undefined;
|
||||
if (after.rule === "echo")
|
||||
return before.sent === true || !calls.sentBefore.has(field)
|
||||
? { ...before, sent: true }
|
||||
: undefined;
|
||||
if (before.sent !== true || after.rule !== "sequence" || laterSent)
|
||||
return undefined;
|
||||
const { sent: _sent, ...numbered } = before;
|
||||
const merged = agree(numbered);
|
||||
return merged?.rule === "sequence" ? { ...merged, sent: true } : undefined;
|
||||
};
|
||||
|
||||
/** One field's rule in the merged plan, and the default it gives a member; undefined when the calls disagree on it. */
|
||||
const mergedField = (
|
||||
field: string,
|
||||
@@ -334,13 +359,18 @@ const mergedField = (
|
||||
if (!inExemplar || !Object.hasOwn(later.entity, field))
|
||||
return shownAlone(inExemplar, calls);
|
||||
if (before !== undefined && after !== undefined) {
|
||||
const rule = agreeing(before, after, {
|
||||
const values = {
|
||||
field,
|
||||
later: later.entity[field],
|
||||
earlier: calls.earlier,
|
||||
exemplar,
|
||||
laterEntity: later.entity,
|
||||
});
|
||||
};
|
||||
const rule =
|
||||
agreeing(before, after, values) ??
|
||||
sentOrNumbered(before, after, field, calls, (held) =>
|
||||
agreeing(held, after, values),
|
||||
);
|
||||
return rule === undefined ? undefined : { rule };
|
||||
}
|
||||
// The plan echoes a member this call left out: what it stored there is the member's default.
|
||||
|
||||
@@ -522,6 +522,8 @@ export const explainUpdate = (
|
||||
const rules: FieldRule[] = [];
|
||||
const echoed = new Set<string>();
|
||||
const unexplained: string[] = [];
|
||||
// A whole-object PUT clears a member it leaves out: that value is the member's default.
|
||||
const defaults: { readonly member: string; readonly value: JsonValue }[] = [];
|
||||
// A member the application replaces with a value it generates is accepted with any value.
|
||||
const generated = new Set<string>();
|
||||
let identity: string | null = null;
|
||||
@@ -593,8 +595,14 @@ export const explainUpdate = (
|
||||
body,
|
||||
knownEntities,
|
||||
);
|
||||
if (rule === undefined) unexplained.push(field);
|
||||
else rules.push(rule);
|
||||
if (rule !== undefined) rules.push(rule);
|
||||
else if (
|
||||
evidence.method === "PUT" &&
|
||||
isBlankValue(ownMember(evidence.after, field)) &&
|
||||
!isBlankValue(ownMember(evidence.before, field))
|
||||
)
|
||||
defaults.push({ member: field, value: evidence.after[field]! });
|
||||
else unexplained.push(field);
|
||||
}
|
||||
rules.push(...pairedStamps(evidence, rules));
|
||||
// A member a reference reads names an entity, which any held one may be.
|
||||
@@ -614,7 +622,11 @@ export const explainUpdate = (
|
||||
);
|
||||
return {
|
||||
rules,
|
||||
body: { ...shaped, identity },
|
||||
body: {
|
||||
...shaped,
|
||||
identity,
|
||||
...(defaults.length === 0 ? {} : { defaults }),
|
||||
},
|
||||
unexplained,
|
||||
unstamped: sameSecondClocks(evidence),
|
||||
};
|
||||
|
||||
@@ -53,12 +53,13 @@ export type GeneratedRule =
|
||||
readonly rule: "identity";
|
||||
readonly generator: "sequence" | "uuid" | "token";
|
||||
}
|
||||
/** One past the largest value its scope holds, and never below `start`, a value the first create took above them. */
|
||||
/** One past the largest value its scope holds, and never below `start`, a value the first create took above them; with `sent`, the value a call sends instead. */
|
||||
| {
|
||||
readonly field: string;
|
||||
readonly rule: "sequence";
|
||||
readonly scope: "collection" | "type";
|
||||
readonly start?: number;
|
||||
readonly sent?: true;
|
||||
}
|
||||
| { readonly field: string; readonly rule: "unique" }
|
||||
/** The value kept with each UUID at the pointers (`*` for any array index) replaced by a fresh one, as rewritten child rows are. */
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "./environmentStateRules.js";
|
||||
import {
|
||||
laterObservation,
|
||||
respelledInstant,
|
||||
type ObservationSearch,
|
||||
} from "./environmentStateWriteObserve.js";
|
||||
import { singleQueryValues } from "../recordedTransportUrl.js";
|
||||
@@ -649,7 +650,10 @@ export const pinnedEchoes = (
|
||||
);
|
||||
};
|
||||
|
||||
/** An update's fields as stored: each nested clock as the first later read of the entity shows it restamped, else as the call showed it. */
|
||||
/**
|
||||
* An update's fields as stored: each nested clock as the first later read of the entity shows it restamped, and each
|
||||
* clock as that read spells the same instant at a coarser precision, else as the call showed it.
|
||||
*/
|
||||
export const restampedFields = (
|
||||
search: ObservationSearch,
|
||||
target: Parameters<typeof laterObservation>[1],
|
||||
@@ -659,9 +663,21 @@ export const restampedFields = (
|
||||
const nested = rules.flatMap((rule) =>
|
||||
rule.rule === "nested-clock" ? [rule] : [],
|
||||
);
|
||||
if (nested.length === 0) return shown;
|
||||
const clocks = rules.flatMap((rule) =>
|
||||
rule.rule === "clock" ? [rule.field] : [],
|
||||
);
|
||||
if (nested.length === 0 && clocks.length === 0) return shown;
|
||||
const later = laterObservation(search, target);
|
||||
const fields = { ...shown };
|
||||
for (const field of clocks) {
|
||||
const read = ownMember(later?.object, field);
|
||||
if (
|
||||
read !== undefined &&
|
||||
!same(read, ownMember(shown, field)) &&
|
||||
respelledInstant(ownMember(shown, field), read)
|
||||
)
|
||||
setOwnMember(fields, field, read);
|
||||
}
|
||||
for (const rule of nested) {
|
||||
const read = storedRestamp(
|
||||
ownMember(shown, rule.field),
|
||||
|
||||
@@ -6,5 +6,5 @@ export const PACKAGE_METADATA = {
|
||||
serverSdkVersion: "2.3.1",
|
||||
clientSdkVersion: "2.3.1",
|
||||
coreSdkVersion: "2.3.1",
|
||||
skillVersion: "53",
|
||||
skillVersion: "54",
|
||||
} as const;
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import type { TimedCaptureRow } from "./analysisView.js";
|
||||
import { bookClock } from "./environmentStateWrites.js";
|
||||
import type { JsonValue } from "../../src/domain/jsonValue.js";
|
||||
|
||||
type Entity = { readonly [key: string]: JsonValue };
|
||||
|
||||
/** Two contact updates, the second sending only the email the first sent with a name and a city. */
|
||||
export const FEWER_MEMBERS_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const contact = (
|
||||
id: number,
|
||||
name: string,
|
||||
city: string,
|
||||
email: string,
|
||||
): Entity => ({ id, name, city, email });
|
||||
const [ada, mina] = [
|
||||
contact(1, "Ada Finch", "Porto", "ada@example.test"),
|
||||
contact(2, "Mina Okafor", "Accra", "mina@example.test"),
|
||||
];
|
||||
const adaMoved = contact(
|
||||
1,
|
||||
"Ada Finch-Lowe",
|
||||
"Lisbon",
|
||||
"ada.lowe@example.test",
|
||||
);
|
||||
const minaMail = { ...mina, email: "mina.o@example.test" };
|
||||
return [
|
||||
["GET", "/api/contacts", [ada, mina]],
|
||||
["GET", "/api/contacts/1", ada],
|
||||
["GET", "/api/contacts/2", mina],
|
||||
[
|
||||
"PUT",
|
||||
"/api/contacts/1",
|
||||
adaMoved,
|
||||
200,
|
||||
{
|
||||
name: "Ada Finch-Lowe",
|
||||
city: "Lisbon",
|
||||
email: "ada.lowe@example.test",
|
||||
},
|
||||
],
|
||||
["GET", "/api/contacts/1", adaMoved],
|
||||
["PUT", "/api/contacts/2", minaMail, 200, { email: "mina.o@example.test" }],
|
||||
["GET", "/api/contacts/2", minaMail],
|
||||
["GET", "/api/contacts", [adaMoved, minaMail]],
|
||||
];
|
||||
})();
|
||||
|
||||
/** Bundle creates: the first sends its order, the second leaves it to the application, which numbers it next. */
|
||||
export const NUMBERED_UNLESS_SENT_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const bundle = (id: number, name: string, order: number): Entity => ({
|
||||
id,
|
||||
name,
|
||||
order,
|
||||
});
|
||||
const [alpha, bravo] = [bundle(1, "Alpha", 0), bundle(2, "Bravo", 1)];
|
||||
const [charlie, delta] = [bundle(3, "Charlie", 5), bundle(4, "Delta", 6)];
|
||||
return [
|
||||
["GET", "/api/bundles", [alpha, bravo]],
|
||||
["GET", "/api/bundles/1", alpha],
|
||||
["GET", "/api/bundles/2", bravo],
|
||||
["POST", "/api/bundles", charlie, 201, { name: "Charlie", order: 5 }],
|
||||
["GET", "/api/bundles/3", charlie],
|
||||
["GET", "/api/bundles", [alpha, bravo, charlie]],
|
||||
["POST", "/api/bundles", delta, 201, { name: "Delta" }],
|
||||
["GET", "/api/bundles/4", delta],
|
||||
["GET", "/api/bundles", [alpha, bravo, charlie, delta]],
|
||||
];
|
||||
})();
|
||||
|
||||
/** Tasks marked done: each answer stamps `done_at` to the nanosecond, the read after it to the second. */
|
||||
export const DONE_CLOCK_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const task = (id: number, title: string): Entity => ({
|
||||
id,
|
||||
title,
|
||||
done: false,
|
||||
done_at: "0001-01-01T00:00:00Z",
|
||||
});
|
||||
const [trim, oil] = [task(1, "Trim the wicks"), task(2, "Order lamp oil")];
|
||||
const done = (entity: Entity, at: number, fraction: string): Entity => ({
|
||||
...entity,
|
||||
done: true,
|
||||
done_at: bookClock(at).replace("Z", fraction),
|
||||
});
|
||||
return [
|
||||
["GET", "/api/tasks", [trim, oil]],
|
||||
["GET", "/api/tasks/1", trim],
|
||||
["GET", "/api/tasks/2", oil],
|
||||
[
|
||||
"PATCH",
|
||||
"/api/tasks/1",
|
||||
done(trim, 3, ".001234567Z"),
|
||||
200,
|
||||
{ done: true },
|
||||
],
|
||||
["GET", "/api/tasks/1", done(trim, 3, "Z")],
|
||||
["PATCH", "/api/tasks/2", done(oil, 5, ".002345678Z"), 200, { done: true }],
|
||||
["GET", "/api/tasks/2", done(oil, 5, "Z")],
|
||||
];
|
||||
})();
|
||||
|
||||
/** Tag PUTs: one leaves the owner out and the application clears it, the other sends it and keeps it. */
|
||||
export const PUT_CLEARS_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
const tag = (id: number, name: string, owner: number | null): Entity => ({
|
||||
id,
|
||||
name,
|
||||
owner,
|
||||
});
|
||||
const [inbox, bills] = [tag(1, "Inbox", 3), tag(2, "Bills", 3)];
|
||||
const [triage, paid] = [tag(1, "Triage", null), tag(2, "Paid", 3)];
|
||||
return [
|
||||
["GET", "/api/tags", [inbox, bills]],
|
||||
["GET", "/api/tags/1", inbox],
|
||||
["GET", "/api/tags/2", bills],
|
||||
["PUT", "/api/tags/1", triage, 200, { name: "Triage" }],
|
||||
["GET", "/api/tags/1", triage],
|
||||
["PUT", "/api/tags/2", paid, 200, { name: "Paid", owner: 3 }],
|
||||
["GET", "/api/tags/2", paid],
|
||||
["GET", "/api/tags", [triage, paid]],
|
||||
];
|
||||
})();
|
||||
Reference in new issue
Block a user