* 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>
* feat(environment): serve query numbers, counted values and made objects
- A query parameter accepts a number or boolean, which serving sends as its
JSON text, as agents send page sizes and flags.
- A sequence may start at the value its first create took above every value
its type held (Vikunja's per-project task index), and a later create's
composed, template or started-sequence rule is adopted when it also spells
the first create's value.
- A made-object rule explains an object the application makes with each
created entity: members the request stamped, members holding the entity's
own id, and counts one past the held objects' values.
- A create's answer names the id of the one object a one-element array member
holds, so bulk creates link to their type.
- The skill reads each listed object by id before writing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(environment): let a linked create take path values it stores
A create linked to its type's own collection, whose path has no parameters,
accepts any path values when its plan stores each one in a field, as a Vikunja
task stores its project; replay and serving apply it alike.
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>
* docs(environment): verify query values, counted values and made objects
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>
* docs(environment): record the call-values checks at #7274 93821024
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>
* docs(environment): record the call-values checks at #7274 45832406
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): check made objects as recorded and keep their ids and clocks honest
- A made object's clock counts as a write clock, so a capture with no clock
anchor withholds the create instead of stamping 1970.
- Members holding the entity's identity follow the id written, generated or
sent, through the type's id field.
- A later create's made object is checked as recorded, before its own members
are rewritten, so one naming another entity withholds the plan.
- A later create's template is adopted only when it spells that create's value
too, not just when the first create's text holds its source value.
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>
* docs(environment): count six withheld plans that explain more, not seven
The bulk task create counted among them is served on this branch.
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>
* chore(environment): put back the call-values hunks main's merge took its side for, and bump the skill to 53
The merge of main kept main's side where it overlapped this branch, so it
could commit without a conflict. This restores this branch's side next to
main's: made-object agreement beside the & slug spellings, sequence starts
in the earlier evidence (now built by earlierOf), the combined value
imports, and recording step 2 beside main's UI-first step 3. Main is at
skill 50 and #7274 at 52, so this branch takes 53.
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>
* 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>
* fix(environment): keep a later create's made object only with clocks its request stamped
followsMadeObjects only checked that a made object's clocks parsed, so a
later create whose object showed an older clock still passed, while serving
stamps the write's clock. Its clocks must now be in the rule's format and
within the stamp window of that create's request.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): accept sent values either way, check defaults against every call, and store update clocks as reads spell them
A sequence that takes sent values now accepts one a later create sent
after the first left the member to be numbered. A default an update learned
must agree with every update that left its member out, else the plan is
withheld. An update plan whose read-back spells a stamped clock at a coarser
precision keeps that read as observation.read, and serving stores the
update's clocks in its spelling while answers keep the response's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): check recorded member and sequence values
* fix(environment): compare soft-deleted read fields
* fix(environment): reject contradictory learned sequences
* fix(environment): preserve initially deleted item observations
* fix(environment): index stored deleted entities separately
* fix(environment): replay lists that include deleted records
* fix(environment): count a sequence number a create sends, as replay does
Serving now records a number a call sends for a `sequence` with `sent` in the
same count history it generates from, so after that entity is deleted the next
generated number still follows it, as replay's takeSequences does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): make no count of a made-object member earlier creates held alike
A count only a later create shows starts at the exemplar's value, which is
right only while the exemplar is the one create accepted: creates accepted
after it showed the member unchanged, which a count would have moved. The
merge now refuses such a count, as the top-level sequence check does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): check PUT defaults against reads, and respell update clocks by serving's rule
A default a PUT learns must also agree with what the read after an earlier or
the current call showed for the member its answer left out, so a call whose
read showed the member kept withholds a later clearing default.
Replay and serving now choose an update clock's stored spelling by one rule,
readSpellsCoarser: the read after it at a coarser precision in the same zone
and separator. A read in another zone or separator is no longer adopted by
replay, which serving could not store, and a create's plain clocks stay as
answered, as serving makes them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): keep the largest sequence value in replay, and store update clocks by the kept read
Replay now keeps the largest value a sequence took, as serving's commitWrite
does, so a lower sent value no longer lowers the next number.
Serving stores every update's clocks in the spelling of the read the plan
keeps as observation.read; replay now does the same through
readSpelledClocks, instead of adopting each update's own read, so a later
update whose read spells its clock otherwise contradicts the plan. The plain
clock branch of restampedFields, which no caller used any more, is gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): keep a left-out default from numbering, a read's sub-millisecond digits, and references named by member
A member a call left out and the application stored a fixed value for is no
longer turned into a numbered sequence by a later call that left it out.
Serving shortens an update's clock to the read's precision from the answer's
own digits, keeping microseconds. A PUT that names a reference field's entity
by its member supplies that field, for the default check and in serving.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): count a reference a later PUT names toward the default an earlier one cleared
The default check now reads the reference rules the current call shows as
well as the plan's, so a PUT naming the owner by ownerId after one that
cleared it is served, as in the other order.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): number a left-out member past every sent value, and never fill it with a sent blank
A later create the application numbered at or below a value an earlier create
sent no longer turns the echo into a numbered sequence, since serving counts
past every sent value. A member a numbered sequence covers is not filled with
an empty value another call sent when a call leaves it out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): count sent values in the sequence's scope before numbering a left-out member
The largest value earlier creates sent is kept by type and by collection, and
a collection-scoped sequence adopted later checks only its own collection, so
a number sent in another collection no longer withholds a create numbered
within its own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
786 lines
28 KiB
TypeScript
786 lines
28 KiB
TypeScript
import type { JsonValue } from "../jsonValue.js";
|
|
import { err, ok, type Result } from "../result.js";
|
|
import {
|
|
ownMember,
|
|
setOwnMember,
|
|
at,
|
|
isObject,
|
|
pointerTo,
|
|
pointerTokens,
|
|
replaceAt,
|
|
same,
|
|
scalarText,
|
|
type JsonObject,
|
|
type StateEntity,
|
|
type StateView,
|
|
} from "./environmentStateRules.js";
|
|
import {
|
|
emptySpellings,
|
|
type FieldRule,
|
|
type GeneratedRule,
|
|
type JsonKind,
|
|
type StateWritePlan,
|
|
type WriteBody,
|
|
type WriteCall,
|
|
type WriteResponse,
|
|
} from "./environmentStateWriteRules.js";
|
|
import {
|
|
stampProblem,
|
|
nestedClockValue,
|
|
} from "./environmentStateWriteStamps.js";
|
|
import { composedValue } from "./environmentStateWriteComposed.js";
|
|
import {
|
|
referencedValue,
|
|
type ReferencedEntity,
|
|
} from "./environmentStateWriteReferences.js";
|
|
import {
|
|
echoed,
|
|
integerOf,
|
|
readClock,
|
|
respellClock,
|
|
isUuid,
|
|
replaceDelimited,
|
|
slugOf,
|
|
spelledLike,
|
|
} from "./environmentStateWriteValues.js";
|
|
|
|
/** The value with each UUID at the pointers (`*` for any array index) replaced by `fresh(uuid)`, others kept. */
|
|
export const renewUuids = (
|
|
value: JsonValue,
|
|
pointers: readonly string[],
|
|
fresh: (uuid: string) => string,
|
|
): JsonValue => {
|
|
const renewed = new Set(pointers);
|
|
const renew = (item: JsonValue, pointer: string): JsonValue => {
|
|
if (isUuid(item)) return renewed.has(pointer) ? fresh(item) : item;
|
|
if (Array.isArray(item))
|
|
return item.map((child) => renew(child, `${pointer}/*`));
|
|
return isObject(item)
|
|
? Object.fromEntries(
|
|
Object.entries(item).map(([key, child]) => [
|
|
key,
|
|
renew(child, `${pointer}${pointerTo(key)}`),
|
|
]),
|
|
)
|
|
: item;
|
|
};
|
|
return renew(value, "");
|
|
};
|
|
|
|
/** A refusal message naming what the call did wrong; the state is left unchanged. */
|
|
type Applied<T> = Result<T, string>;
|
|
|
|
/** A generated value or a typed refusal when the store cannot safely provide one. */
|
|
export type GenerateValue = (
|
|
rule: GeneratedRule,
|
|
) => Applied<JsonValue | undefined>;
|
|
|
|
const kindOf = (value: JsonValue | undefined): string =>
|
|
value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
|
|
|
|
/** The JSON types `stored` holds members in, for those `observed` shows no value of. */
|
|
export const storedKinds = (
|
|
members: readonly string[],
|
|
observed: JsonObject,
|
|
stored: JsonObject,
|
|
): WriteBody["kinds"] =>
|
|
members.flatMap((member) => {
|
|
const value = ownMember(stored, member);
|
|
return (ownMember(observed, member) ?? null) === null &&
|
|
value !== undefined &&
|
|
value !== null
|
|
? [{ member, kind: kindOf(value) as JsonKind }]
|
|
: [];
|
|
});
|
|
|
|
/** Members a refusal names, each cut to `LISTED_NAME_CHARS`; the plan in the state Evidence holds every one whole. */
|
|
const LISTED_MEMBERS = 12;
|
|
const LISTED_NAME_CHARS = 120;
|
|
|
|
/** Captured member names quoted, so a separator in one cannot read as another, and bounded in count and length. */
|
|
const listed = (members: readonly string[]): string => {
|
|
if (members.length === 0) return "none";
|
|
const shown = members
|
|
.slice(0, LISTED_MEMBERS)
|
|
.map((member) =>
|
|
JSON.stringify(
|
|
member.length > LISTED_NAME_CHARS
|
|
? `${member.slice(0, LISTED_NAME_CHARS)}…`
|
|
: member,
|
|
),
|
|
)
|
|
.join(", ");
|
|
return members.length > LISTED_MEMBERS
|
|
? `${shown}, … (${String(members.length)} members; all in the state Evidence)`
|
|
: shown;
|
|
};
|
|
|
|
/** What a body may hold, for refusal messages. */
|
|
const bodyContract = (body: WriteBody): string =>
|
|
`accepted members: ${listed(body.accepted)}; members accepted only with their recorded value: ${listed(body.fixed.map(({ member }) => member))}; required: ${listed(body.required)}`;
|
|
|
|
/** Recorded values up to this many characters are shown where a served write names them; a longer one is only named. */
|
|
const SHOWN_VALUE_CHARS = 120;
|
|
|
|
/** A recorded value as JSON text, when short enough to show. */
|
|
export const shownValue = (value: JsonValue): string | undefined => {
|
|
const text = JSON.stringify(value);
|
|
return text.length <= SHOWN_VALUE_CHARS ? text : undefined;
|
|
};
|
|
|
|
/** Why a call's body does not fit the members the capture showed, if it does not; `shapes` gives each accepted member's JSON type, else the body's recorded kind. */
|
|
export const bodyProblem = (
|
|
body: WriteBody,
|
|
call: WriteCall,
|
|
shapes: { readonly entity: JsonObject; readonly id?: string },
|
|
): string | undefined => {
|
|
const sent = call.body ?? {};
|
|
const fixed = new Map(body.fixed.map((entry) => [entry.member, entry.sent]));
|
|
const kinds = new Map(body.kinds.map((entry) => [entry.member, entry.kind]));
|
|
for (const [member, value] of Object.entries(sent)) {
|
|
if (member === body.identity) {
|
|
if (scalarText(value) !== shapes.id)
|
|
return `Member ${member} must repeat the target's identity ${shapes.id ?? ""}.`;
|
|
continue;
|
|
}
|
|
if (fixed.has(member)) {
|
|
if (!same(fixed.get(member), value))
|
|
return `Member ${member} is accepted only with the value the capture sent, ${shownValue(fixed.get(member)!) ?? "too long to show here"}, because the application stored it differently or other recorded fields go with it (${bodyContract(body)}).`;
|
|
continue;
|
|
}
|
|
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
|
|
? kinds.get(member)
|
|
: kindOf(recorded);
|
|
if (kind !== undefined && kind !== kindOf(value))
|
|
return `Member ${member} must be a JSON ${kind}, as the capture stored it.`;
|
|
}
|
|
const missing = body.required.filter(
|
|
(member) => !Object.hasOwn(sent, member),
|
|
);
|
|
if (missing.length > 0)
|
|
return `The call lacks required members ${listed(missing)} (${bodyContract(body)}).`;
|
|
return undefined;
|
|
};
|
|
|
|
/** The value at a JSON pointer inside a body, following array indexes and members. */
|
|
export const valueAt = (
|
|
body: JsonValue,
|
|
pointer: string,
|
|
): JsonValue | undefined => {
|
|
let value: JsonValue | undefined = body;
|
|
for (const token of pointerTokens(pointer)) {
|
|
if (Array.isArray(value)) value = value[Number(token)];
|
|
else if (isObject(value) && Object.hasOwn(value, token))
|
|
value = value[token];
|
|
else return undefined;
|
|
}
|
|
return value;
|
|
};
|
|
|
|
/** A path value spelled as the observed value: a number when the observed value is one. */
|
|
const pathValue = (observed: JsonValue | undefined, text: string): JsonValue =>
|
|
typeof observed === "number" && integerOf(text) !== undefined
|
|
? Number(text)
|
|
: text;
|
|
|
|
/** A generated field from a recorded call, reproduced with the same nested-clock renderer serving uses. */
|
|
export const recordedGeneratedValue = (
|
|
rule: GeneratedRule,
|
|
base: JsonValue | undefined,
|
|
value: JsonValue | undefined,
|
|
): Applied<JsonValue | undefined> => {
|
|
if (rule.rule !== "nested-clock") return ok(value);
|
|
const clock = isObject(value)
|
|
? ownMember(value, rule.members[0]!)
|
|
: undefined;
|
|
if (
|
|
clock === undefined ||
|
|
JSON.stringify(readClock(clock)?.format) !== JSON.stringify(rule.format)
|
|
)
|
|
return err(`The recorded ${rule.field} has no clock in the plan's format.`);
|
|
const rebuilt = nestedClockValue(base, rule, clock);
|
|
return rebuilt.ok && same(rebuilt.value, value)
|
|
? rebuilt
|
|
: err(
|
|
`The recorded ${rule.field} differs from its nested-clock rendering.`,
|
|
);
|
|
};
|
|
|
|
/** One field's value under a rule that reads the call or generates. */
|
|
const ruledValue = (
|
|
rule: FieldRule,
|
|
observed: JsonObject,
|
|
call: WriteCall,
|
|
generate: GenerateValue,
|
|
referenced?: ReferencedEntity,
|
|
): Applied<JsonValue | undefined> => {
|
|
const body = call.body ?? {};
|
|
switch (rule.rule) {
|
|
case "reference":
|
|
return referencedValue(rule, body, referenced);
|
|
case "sequence":
|
|
return rule.sent === true && Object.hasOwn(body, rule.field)
|
|
? ok(body[rule.field]!)
|
|
: generate(rule);
|
|
case "identity":
|
|
case "unique":
|
|
case "renewed":
|
|
case "clock":
|
|
case "nested-clock":
|
|
case "held-clock":
|
|
case "made-object":
|
|
return generate(rule);
|
|
case "echo":
|
|
return ok(
|
|
Object.hasOwn(body, rule.field)
|
|
? echoed(rule, body[rule.field]!)
|
|
: undefined,
|
|
);
|
|
case "copy": {
|
|
const value = valueAt(body, rule.from);
|
|
return value === undefined
|
|
? err(
|
|
`The call has no value at ${rule.from} in its body, which ${rule.field} copies.`,
|
|
)
|
|
: ok(value);
|
|
}
|
|
case "path": {
|
|
const value = call.pathValues.get(rule.from);
|
|
return value === undefined
|
|
? err(
|
|
`The call names no ${rule.from} path value, which ${rule.field} takes.`,
|
|
)
|
|
: ok(pathValue(ownMember(observed, rule.field), value));
|
|
}
|
|
default:
|
|
return ok(undefined);
|
|
}
|
|
};
|
|
|
|
/** A string field rebuilt around the new value of the generated field it embeds. */
|
|
const templated = (
|
|
observed: JsonObject,
|
|
written: JsonObject,
|
|
rule: Extract<FieldRule, { rule: "template" }>,
|
|
): JsonValue | undefined => {
|
|
const text = ownMember(observed, rule.field);
|
|
const token = scalarText(ownMember(observed, rule.from));
|
|
const replacement = scalarText(ownMember(written, rule.from));
|
|
return typeof text === "string" &&
|
|
token !== undefined &&
|
|
replacement !== undefined
|
|
? replaceDelimited(text, token, replacement)
|
|
: undefined;
|
|
};
|
|
|
|
/** Apply the rules that derive from fields already written or the call: slugs, templates, stamps and composed strings; why a composed string cannot be rebuilt, if one cannot. */
|
|
const derive = (
|
|
rules: readonly FieldRule[],
|
|
observed: JsonObject,
|
|
written: JsonObject,
|
|
sent: JsonObject,
|
|
): string | undefined => {
|
|
for (const rule of rules) {
|
|
if (rule.rule === "slug") {
|
|
const from = ownMember(sent, rule.from);
|
|
if (typeof from === "string")
|
|
setOwnMember(written, rule.field, slugOf(from, rule.ampersand));
|
|
} else if (rule.rule === "template") {
|
|
const value = templated(observed, written, rule);
|
|
if (value !== undefined) setOwnMember(written, rule.field, value);
|
|
} else if (rule.rule === "stamp")
|
|
setOwnMember(written, rule.field, rule.value);
|
|
else if (rule.rule === "derived" && Object.hasOwn(sent, rule.member))
|
|
setOwnMember(written, rule.field, rule.value);
|
|
}
|
|
// Composed strings read the fields the rules above wrote.
|
|
for (const rule of rules)
|
|
if (rule.rule === "composed") {
|
|
const value = composedValue(rule.parts, written);
|
|
if (value === undefined) {
|
|
const sources = new Set(
|
|
rule.parts.flatMap((part) => ("from" in part ? [part.from] : [])),
|
|
);
|
|
return `The write composes ${JSON.stringify(rule.field)} from ${listed([...sources])}, and the entity holds no value one of them can spell.`;
|
|
}
|
|
setOwnMember(written, rule.field, value);
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
/** Members stored only under another name, with the value of the field each is copied whole into, for type checks. */
|
|
const copiedShapes = (
|
|
rules: readonly FieldRule[],
|
|
observed: JsonObject,
|
|
): JsonObject =>
|
|
Object.fromEntries(
|
|
rules.flatMap((rule): [string, JsonValue][] => {
|
|
if (rule.rule !== "copy" || !Object.hasOwn(observed, rule.field))
|
|
return [];
|
|
const tokens = pointerTokens(rule.from);
|
|
return tokens.length === 1 ? [[tokens[0]!, observed[rule.field]!]] : [];
|
|
}),
|
|
);
|
|
|
|
/** 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,
|
|
sentCall: WriteCall,
|
|
generate: GenerateValue,
|
|
referenced?: ReferencedEntity,
|
|
idField?: string,
|
|
): Applied<JsonObject> => {
|
|
if (plan.body === null) return err("The tool has no modelled body.");
|
|
// A member a `sequence` with `sent` numbers when a call leaves it out takes the next number, not an empty value one sent.
|
|
const numbered = new Set(
|
|
plan.rules.flatMap((rule) =>
|
|
rule.rule === "sequence" && rule.sent === true ? [rule.field] : [],
|
|
),
|
|
);
|
|
const call = withBlanks(
|
|
{
|
|
...plan.body,
|
|
blank: (plan.body.blank ?? []).filter(
|
|
({ member }) => !numbered.has(member),
|
|
),
|
|
},
|
|
sentCall,
|
|
);
|
|
const problem = bodyProblem(plan.body, call, {
|
|
entity: { ...copiedShapes(plan.rules, observed), ...observed },
|
|
});
|
|
if (problem !== undefined) return err(problem);
|
|
const written: JsonObject = { ...observed };
|
|
const defaults = new Map(
|
|
(plan.body.defaults ?? []).map(({ member, value }) => [member, value]),
|
|
);
|
|
for (const rule of plan.rules) {
|
|
const value = ruledValue(rule, observed, call, generate, referenced);
|
|
if (!value.ok) return value;
|
|
if (value.value !== undefined)
|
|
setOwnMember(written, rule.field, value.value);
|
|
else if (rule.rule === "echo" && defaults.has(rule.field))
|
|
setOwnMember(written, rule.field, defaults.get(rule.field)!);
|
|
else if (rule.rule === "echo" || rule.rule === "reference")
|
|
delete written[rule.field];
|
|
}
|
|
ownMadeMembers(plan.rules, written, idField);
|
|
const unspelled = derive(plan.rules, observed, written, call.body ?? {});
|
|
return unspelled === undefined ? ok(written) : err(unspelled);
|
|
};
|
|
|
|
/** Set each made object's members that hold the entity's identity to the identity it was written with, generated or sent. */
|
|
const ownMadeMembers = (
|
|
rules: readonly FieldRule[],
|
|
written: JsonObject,
|
|
idField: string | undefined,
|
|
): void => {
|
|
const identity =
|
|
idField ?? rules.find((rule) => rule.rule === "identity")?.field;
|
|
const id = identity === undefined ? undefined : ownMember(written, identity);
|
|
if (id === undefined) return;
|
|
for (const rule of rules) {
|
|
if (rule.rule !== "made-object" || rule.own.length === 0) continue;
|
|
const made = ownMember(written, rule.field);
|
|
if (made === undefined || !isObject(made)) continue;
|
|
const owned: JsonObject = { ...made };
|
|
for (const member of rule.own)
|
|
setOwnMember(owned, member, spelledIdentity(ownMember(made, member), id));
|
|
setOwnMember(written, rule.field, owned);
|
|
}
|
|
};
|
|
|
|
/** An identity spelled as a recorded member spells it: a string, or a number when the identity is one. */
|
|
const spelledIdentity = (
|
|
exemplar: JsonValue | undefined,
|
|
id: JsonValue,
|
|
): JsonValue => (typeof exemplar === "string" ? (scalarText(id) ?? id) : id);
|
|
|
|
/** Rules an update gives a new value on every call. */
|
|
const REGENERATED: ReadonlySet<FieldRule["rule"]> = new Set([
|
|
"clock",
|
|
"nested-clock",
|
|
"held-clock",
|
|
"unique",
|
|
]);
|
|
const regenerated = (
|
|
rule: FieldRule,
|
|
): rule is Extract<
|
|
GeneratedRule,
|
|
{ rule: "clock" | "nested-clock" | "held-clock" | "unique" }
|
|
> => REGENERATED.has(rule.rule);
|
|
|
|
/** The value an echo or constant rule writes for a sent member, or a set rule on every call; undefined when it writes none. */
|
|
const statedValue = (
|
|
rule: FieldRule,
|
|
sent: JsonObject,
|
|
): JsonValue | undefined => {
|
|
if (rule.rule === "set") return rule.value;
|
|
if (rule.rule !== "echo" && rule.rule !== "constant") return undefined;
|
|
if (!Object.hasOwn(sent, rule.field)) return undefined;
|
|
return rule.rule === "echo" ? echoed(rule, sent[rule.field]!) : rule.value;
|
|
};
|
|
|
|
/** The fields of an updated entity: sent echoed members, the constants the plan recorded, generated and derived changes. */
|
|
export const updatedFields = (
|
|
plan: Pick<StateWritePlan, "rules" | "body" | "address">,
|
|
entity: { readonly before: JsonObject; readonly observed: JsonObject },
|
|
call: WriteCall & { readonly id: string },
|
|
generate: GenerateValue,
|
|
referenced?: ReferencedEntity,
|
|
): Applied<JsonObject> => {
|
|
const { before, observed } = entity;
|
|
if (plan.body === null) return err("The tool has no modelled body.");
|
|
const sent = call.body ?? {};
|
|
const problem =
|
|
bodyProblem(plan.body, call, {
|
|
entity: { ...observed, ...before },
|
|
id: call.id,
|
|
}) ?? stampProblem(plan.rules, before, sent);
|
|
if (problem !== undefined) return err(problem);
|
|
const members = Object.keys(sent).filter(
|
|
(member) => member !== plan.body?.identity,
|
|
);
|
|
// An item action names its change in its path, so it may send nothing.
|
|
if (members.length === 0 && plan.address?.by !== "action")
|
|
return err(
|
|
`The call sends none of the members an update applies (${bodyContract(plan.body)}).`,
|
|
);
|
|
const written: JsonObject = { ...before };
|
|
for (const rule of plan.rules) {
|
|
const stated = statedValue(rule, sent);
|
|
if (stated !== undefined) setOwnMember(written, rule.field, stated);
|
|
else if (rule.rule === "reference" && Object.hasOwn(sent, rule.member)) {
|
|
const value = referencedValue(rule, sent, referenced);
|
|
if (!value.ok) return value;
|
|
if (value.value !== undefined)
|
|
setOwnMember(written, rule.field, value.value);
|
|
} else if (rule.rule === "increment") {
|
|
const was = integerOf(ownMember(before, rule.field));
|
|
if (was !== undefined)
|
|
setOwnMember(
|
|
written,
|
|
rule.field,
|
|
spelledLike(ownMember(before, rule.field)!, was + 1),
|
|
);
|
|
} else if (
|
|
regenerated(rule) ||
|
|
// Child rows are renewed only on an entity that holds them.
|
|
(rule.rule === "renewed" && Object.hasOwn(before, rule.field))
|
|
) {
|
|
const value = generate(rule);
|
|
if (!value.ok) return value;
|
|
if (value.value !== undefined)
|
|
setOwnMember(written, rule.field, value.value);
|
|
}
|
|
}
|
|
// A member the call leaves out takes the default a whole-object update showed; one a reference names by its member is not left out.
|
|
for (const { member, value } of plan.body.defaults ?? [])
|
|
if (
|
|
!Object.hasOwn(sent, member) &&
|
|
!plan.rules.some(
|
|
(rule) =>
|
|
rule.rule === "reference" &&
|
|
rule.field === member &&
|
|
Object.hasOwn(sent, rule.member),
|
|
)
|
|
)
|
|
setOwnMember(written, member, value);
|
|
const unspelled = derive(
|
|
plan.rules.filter(
|
|
(rule) => rule.rule !== "slug" || Object.hasOwn(sent, rule.from),
|
|
),
|
|
observed,
|
|
written,
|
|
sent,
|
|
);
|
|
return unspelled === undefined ? ok(written) : err(unspelled);
|
|
};
|
|
|
|
/** Why a document write's body does not fit what the capture showed: members its plan accepts, as the JSON types the document holds, a member a replacing write dropped keeping the type the initial document showed. */
|
|
export const documentBodyProblem = (
|
|
plan: Pick<StateWritePlan, "body" | "observation">,
|
|
call: WriteCall,
|
|
documents: {
|
|
readonly initial: JsonValue | undefined;
|
|
readonly current: JsonValue;
|
|
},
|
|
): string | undefined => {
|
|
if (plan.body === null) return "The tool has no modelled body.";
|
|
const pointer = plan.observation?.pointer ?? "";
|
|
const members = (value: JsonValue | undefined): JsonObject => {
|
|
const object = value === undefined ? undefined : at(value, pointer);
|
|
return isObject(object) ? object : {};
|
|
};
|
|
return bodyProblem(plan.body, call, {
|
|
entity: { ...members(documents.initial), ...members(documents.current) },
|
|
});
|
|
};
|
|
|
|
/** A document after a write: the body's members merged into, or replacing, the object at the pointer. */
|
|
export const writtenDocument = (
|
|
plan: Pick<StateWritePlan, "merge" | "observation">,
|
|
before: JsonValue,
|
|
call: WriteCall,
|
|
): Applied<JsonValue> => {
|
|
const pointer = plan.observation?.pointer ?? "";
|
|
const target = at(before, pointer);
|
|
if (!isObject(target) || call.body === undefined)
|
|
return err("The call's body does not fit the document.");
|
|
const value =
|
|
plan.merge === "replace" ? { ...call.body } : { ...target, ...call.body };
|
|
return ok(replaceAt(before, pointer, value));
|
|
};
|
|
|
|
/** The fields a create stores only when a call sends their member: references and echoes of members not every call sent, with no default. */
|
|
export const optionalFields = (
|
|
plan: Pick<StateWritePlan, "effect" | "rules" | "body">,
|
|
): string[] => {
|
|
if (plan.effect !== "create" || plan.body === null) return [];
|
|
const { required, defaults = [] } = plan.body;
|
|
return plan.rules.flatMap((rule) =>
|
|
(rule.rule === "reference" && !required.includes(rule.member)) ||
|
|
(rule.rule === "echo" &&
|
|
!required.includes(rule.field) &&
|
|
!defaults.some(({ member }) => member === rule.field))
|
|
? [rule.field]
|
|
: [],
|
|
);
|
|
};
|
|
|
|
/** 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,
|
|
recorded: JsonValue | undefined,
|
|
written: {
|
|
readonly fields: JsonObject | undefined;
|
|
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)
|
|
return ok(recorded);
|
|
const pointer = response.pointer ?? "";
|
|
const shown = at(recorded, pointer);
|
|
if (response.shape === "identity")
|
|
return ok(
|
|
replaceAt(
|
|
recorded,
|
|
pointer,
|
|
typeof shown === "number" && integerOf(written.id) !== undefined
|
|
? Number(written.id)
|
|
: written.id,
|
|
),
|
|
);
|
|
if (!isObject(shown) || written.fields === undefined)
|
|
return err("The recorded response does not carry the entity.");
|
|
const held = (field: string) => Object.hasOwn(written.fields!, field);
|
|
const missing = Object.keys(shown).filter(
|
|
(field) => !held(field) && !optional.includes(field) && !later.has(field),
|
|
);
|
|
if (missing.length > 0)
|
|
return err(
|
|
`The state holds no complete entity for this response: it lacks ${missing.join(", ")}.`,
|
|
);
|
|
const fields = [
|
|
...Object.keys(shown).filter(held),
|
|
...optional.filter((field) => !Object.hasOwn(shown, field) && held(field)),
|
|
];
|
|
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 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;
|
|
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);
|
|
const [template] = Array.isArray(recorded) ? recorded : [];
|
|
if (template === undefined)
|
|
return err("The recorded response shows no element to render.");
|
|
const elements: JsonValue[] = [];
|
|
for (const entity of written) {
|
|
const element = writtenResponse(
|
|
{ ...response, pointer: "" },
|
|
template,
|
|
entity,
|
|
optional,
|
|
later,
|
|
);
|
|
if (!element.ok) return element;
|
|
elements.push(element.value ?? null);
|
|
}
|
|
return ok(elements);
|
|
};
|
|
|
|
/** An entity with updated fields: view-specific values of the fields that changed give way to the new values, and it cites no records. */
|
|
export const updatedEntity = (
|
|
entity: StateEntity,
|
|
fields: JsonObject,
|
|
): StateEntity => {
|
|
const changed = new Set(
|
|
Object.keys(fields).filter(
|
|
(field) => !same(ownMember(entity.fields, field), fields[field]),
|
|
),
|
|
);
|
|
// Written values are no longer the ones the cited records show.
|
|
return {
|
|
...entity,
|
|
fields,
|
|
record_ordinals: [],
|
|
view_fields: entity.view_fields
|
|
.map((view) => ({
|
|
tool: view.tool,
|
|
fields: Object.fromEntries(
|
|
Object.entries(view.fields).filter(([field]) => !changed.has(field)),
|
|
),
|
|
}))
|
|
.filter((view) => Object.keys(view.fields).length > 0),
|
|
};
|
|
};
|