mirror of
https://github.com/morluto/rea.git
synced 2026-11-06 06:46:10 +00:00
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>
This commit is contained in:
1 parent
0ceff6fa69
commit
053264bd77
13 files changed
+317
-145
No files matched your search
@@ -23,18 +23,20 @@ natural-language tasks ask for, or withheld them:
|
||||
- **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, and one leaving it out is applied as
|
||||
the earliest did. Updates are unchanged.
|
||||
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, and its plan carries that `insert`. When a read listing the whole
|
||||
collection fails after an update and no create of the type was placed by a sort, replay tries the collection sorted
|
||||
by a field the update writes; on a match, the type's creates and updates take that order.
|
||||
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 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, unless a create was placed by a sort
|
||||
that excludes 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. An unsent update field that changes only between `null`, `[]` and `{}` is unchanged.
|
||||
or loaded so. An unsent update field that changes only between `null`, `[]` and `{}` is unchanged. Write answers
|
||||
spell empty lists and maps as their recorded answers did.
|
||||
- **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
|
||||
|
||||
@@ -24,8 +24,13 @@ the agent recordings.
|
||||
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; a created tag's
|
||||
id appears in the list envelope's `all` in list order.
|
||||
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
|
||||
|
||||
|
||||
@@ -757,8 +757,10 @@ 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 spelled
|
||||
(`null`, `[]` or `{}`) as its answer spelled them, and stores the fields of both with
|
||||
the item read-back's clock precision. Updates answer empty lists and maps as
|
||||
their answers spelled them too. 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
|
||||
@@ -774,9 +776,10 @@ 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
|
||||
`{}`), 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. A call may send it as any recorded call sent
|
||||
it empty, 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
|
||||
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. A later create that disagrees
|
||||
leaves the plan as it was. A create whose observed reference differs from the
|
||||
entity the plan rebuilds, whether or not it sent the reference member, is
|
||||
@@ -846,11 +849,13 @@ contradicted it; otherwise its `reason` is the first of its build reason
|
||||
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. 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`). When a read listing the whole
|
||||
collection fails after an update and no create of the type was placed by a
|
||||
sort, the view tries the collection sorted by a field that update writes; 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.
|
||||
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 fails after an update and the type's creates are not sorted by a field
|
||||
that update writes, the view tries the collection sorted by such a field, unless
|
||||
a create was placed by a sort that excludes 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.
|
||||
|
||||
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
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
generator,
|
||||
observedEntity,
|
||||
pathValues,
|
||||
resortedIndex,
|
||||
sortedOrder,
|
||||
unloadable,
|
||||
heldEntity,
|
||||
updateTarget,
|
||||
@@ -184,19 +184,18 @@ 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;
|
||||
// In a sorted collection each entity whose sorted field changed moves, one after another.
|
||||
for (const [index, entity] of written.value.entries()) {
|
||||
const at = resortedIndex(serving, plan, {
|
||||
entity: targets.value[index]!.entity,
|
||||
fields: entity.fields,
|
||||
});
|
||||
if (at !== undefined) {
|
||||
const moved = serving.store.update(entity, at);
|
||||
if (!moved.ok) return moved;
|
||||
}
|
||||
}
|
||||
} else serving.store.removeAll(written.value);
|
||||
serving.store.commitWrite(values.taken);
|
||||
return answer;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
BULK_RENAMED_LABEL_ROWS,
|
||||
formLabelRows,
|
||||
LISTED_ID_ROWS,
|
||||
NOTE_DEFAULT_ROWS,
|
||||
RENAMED_LABEL_ROWS,
|
||||
TITLE_ORDER_ROWS,
|
||||
UNLOADED_ANSWER_ROWS,
|
||||
} from "../../../tests/fixtures/environmentStateFormWrites.js";
|
||||
import { servedFrom } from "../../../tests/fixtures/environmentStateServing.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
@@ -66,6 +69,60 @@ describe("served creates and updates modelled on form recordings", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
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({ title: "Coil the ropes", assignees: [] });
|
||||
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("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" } });
|
||||
|
||||
@@ -306,48 +306,18 @@ export class EnvironmentStateStore {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
/** Replace a held entity's values, in place or at `index` of its collection's other entities, unless the state would outgrow what `data/add` loads. */
|
||||
update(
|
||||
entity: StateEntity,
|
||||
index?: number,
|
||||
): Result<void, AnalysisResourceConstraintError> {
|
||||
const held = this.#entities.get(
|
||||
compositeKey([entity.type, entity.collection, entity.id]),
|
||||
);
|
||||
if (index === undefined || held === undefined)
|
||||
return this.updateAll([entity]);
|
||||
const entities = this.#content.entities.filter(
|
||||
(candidate) => candidate !== held,
|
||||
);
|
||||
const members = entities.filter(
|
||||
(candidate) =>
|
||||
candidate.type === entity.type &&
|
||||
candidate.collection === entity.collection,
|
||||
);
|
||||
if (members.length === 0) return this.updateAll([entity]);
|
||||
const bytes = this.#grown(jsonByteLength(entity) - jsonByteLength(held));
|
||||
if (!bytes.ok) return bytes;
|
||||
const anchor =
|
||||
index < members.length
|
||||
? entities.indexOf(members[index]!)
|
||||
: entities.indexOf(members.at(-1)!) + 1;
|
||||
this.#install(
|
||||
{
|
||||
...this.#content,
|
||||
entities: [
|
||||
...entities.slice(0, anchor),
|
||||
entity,
|
||||
...entities.slice(anchor),
|
||||
],
|
||||
},
|
||||
bytes.value,
|
||||
);
|
||||
return ok(undefined);
|
||||
/** Replace a held entity's values in place, unless the state would outgrow what `data/add` loads. */
|
||||
update(entity: StateEntity): Result<void, AnalysisResourceConstraintError> {
|
||||
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) {
|
||||
@@ -363,11 +333,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,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "../../domain/analysisView/environmentStateWriteApply.js";
|
||||
import {
|
||||
insertIndex,
|
||||
resortedMembers,
|
||||
nextInteger,
|
||||
} from "../../domain/analysisView/environmentStateWriteExplain.js";
|
||||
import { callRenewal } from "../../domain/analysisView/environmentStateWriteGenerated.js";
|
||||
@@ -42,7 +43,6 @@ import {
|
||||
collectionMatch,
|
||||
decodedSegment,
|
||||
isObject,
|
||||
same,
|
||||
scalarText,
|
||||
type JsonObject,
|
||||
type StateEntity,
|
||||
@@ -602,38 +602,31 @@ const answerUpdate = (
|
||||
},
|
||||
);
|
||||
if (!answer.ok) return answer;
|
||||
const updated = serving.store.update(
|
||||
updatedEntity(entity, written.value),
|
||||
resortedIndex(serving, plan, { entity, fields: 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;
|
||||
};
|
||||
|
||||
/** Where an update moves its entity among the others of a collection sorted by a field it changed; undefined to keep it in place. */
|
||||
export const resortedIndex = (
|
||||
/** 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,
|
||||
written: { readonly entity: StateEntity; readonly fields: JsonObject },
|
||||
): number | undefined => {
|
||||
const { insert } = plan;
|
||||
const { entity, fields } = written;
|
||||
if (
|
||||
insert?.rule !== "sorted" ||
|
||||
same(
|
||||
ownMember(entity.fields, insert.field),
|
||||
ownMember(fields, insert.field),
|
||||
)
|
||||
)
|
||||
return undefined;
|
||||
return insertIndex(
|
||||
insert,
|
||||
serving.store
|
||||
.inCollection(entity.type, entity.collection)
|
||||
.filter((member) => member.id !== entity.id)
|
||||
.map((member) => member.fields),
|
||||
fields,
|
||||
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,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -789,15 +789,15 @@ export class EnvironmentStateWriteSimulation {
|
||||
|
||||
/**
|
||||
* 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 otherwise; the type's creates then
|
||||
* insert by it, and its updates move their entity by it.
|
||||
* 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, since both may have fit every earlier read.
|
||||
*/
|
||||
#retrySort(
|
||||
view: Extract<StateView, { shape: "list" }>,
|
||||
record: StateRecordFacts,
|
||||
exercised: ReadonlySet<string>,
|
||||
): CheckOutcome | undefined {
|
||||
if (this.#sortOf(view.type) !== undefined) return undefined;
|
||||
const line = this.#line(view, record);
|
||||
const idField = this.#types.get(view.type)?.id_field;
|
||||
if (line === undefined || idField === undefined) return undefined;
|
||||
@@ -814,6 +814,10 @@ export class EnvironmentStateWriteSimulation {
|
||||
: [];
|
||||
}),
|
||||
);
|
||||
// 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" &&
|
||||
@@ -841,7 +845,9 @@ export class EnvironmentStateWriteSimulation {
|
||||
),
|
||||
);
|
||||
});
|
||||
for (const rule of rules) {
|
||||
for (const rule of rules.filter(
|
||||
(candidate) => current === undefined || !same(candidate, current),
|
||||
)) {
|
||||
// A create placed by another sort shows the collection is not kept in this order.
|
||||
if (
|
||||
creates.some(
|
||||
|
||||
@@ -7,14 +7,17 @@ import {
|
||||
import {
|
||||
DERIVED_TEXT_ROWS,
|
||||
formLabelRows,
|
||||
ID_OR_TITLE_ROWS,
|
||||
LISTED_ID_ROWS,
|
||||
MINIMAL_CONTACT_ROWS,
|
||||
NOTE_DEFAULT_ROWS,
|
||||
RENAMED_LABEL_ROWS,
|
||||
TITLE_ORDER_ROWS,
|
||||
UNLOADED_ANSWER_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) => {
|
||||
@@ -77,6 +80,19 @@ describe("create members a form sent empty", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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({
|
||||
@@ -114,6 +130,38 @@ describe("collections kept sorted", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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("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),
|
||||
|
||||
@@ -13,14 +13,15 @@ import {
|
||||
type JsonObject,
|
||||
type StateEntity,
|
||||
} 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,
|
||||
@@ -327,12 +328,13 @@ const copiedShapes = (
|
||||
}),
|
||||
);
|
||||
|
||||
/** A create's call with the members it left out that recorded calls sent empty, as the earliest sent them. */
|
||||
/** 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
|
||||
@@ -566,9 +568,13 @@ export const writtenResponse = (
|
||||
Object.fromEntries(
|
||||
fields.map((field) => {
|
||||
const value = ownMember(written.fields!, field)!;
|
||||
const answered = ownMember(shown, field);
|
||||
// An answer spells an empty list or map as its recording did, whichever spelling the state holds.
|
||||
if (answered !== undefined && emptySpellings(answered, value))
|
||||
return [field, answered];
|
||||
const clock =
|
||||
written.clocks?.includes(field) === true
|
||||
? readClock(ownMember(shown, field))
|
||||
? readClock(answered)
|
||||
: undefined;
|
||||
return [
|
||||
field,
|
||||
|
||||
@@ -192,11 +192,6 @@ export const mergedCreatePlan = (
|
||||
? undefined
|
||||
: joinedDefaults(plan.body.defaults ?? [], defaults);
|
||||
if (body === undefined || joined === undefined) return undefined;
|
||||
// A member a call left out stores its default, which a value sent empty elsewhere does not override.
|
||||
const { blank: sentBlank = [], ...rest } = body;
|
||||
const blank = sentBlank.filter(
|
||||
({ member }) => !joined.some((entry) => entry.member === member),
|
||||
);
|
||||
// The plan's rules keep their order; rules the later call adds follow.
|
||||
return {
|
||||
rules: [
|
||||
@@ -206,38 +201,22 @@ export const mergedCreatePlan = (
|
||||
}),
|
||||
...[...merged.values()].filter((rule) => !held.has(rule.field)),
|
||||
],
|
||||
body: {
|
||||
...rest,
|
||||
...(joined.length === 0 ? {} : { defaults: joined }),
|
||||
...(blank.length === 0 ? {} : { blank }),
|
||||
},
|
||||
body: joined.length === 0 ? body : { ...body, defaults: joined },
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Members both calls sent and either sent empty, with each empty value sent, the earliest first; a member only one call
|
||||
* sent takes its default from the call that left it out instead.
|
||||
*/
|
||||
/** Every empty value recorded calls sent a member, the earliest first. */
|
||||
const joinedBlanks = (
|
||||
held: readonly Blank[],
|
||||
added: readonly Blank[],
|
||||
calls: {
|
||||
readonly sentBefore: ReadonlySet<string>;
|
||||
readonly later: JsonObject;
|
||||
},
|
||||
): Blank[] => {
|
||||
const blank: Blank[] = [];
|
||||
const members = new Set(held.map(({ member }) => member));
|
||||
for (const member of members) {
|
||||
if (!Object.hasOwn(calls.later, member)) continue;
|
||||
const entries = held.filter((entry) => entry.member === member);
|
||||
const sent = calls.later[member]!;
|
||||
if (isBlankValue(sent) && !entries.some((entry) => same(entry.sent, sent)))
|
||||
entries.push({ member, sent });
|
||||
blank.push(...entries);
|
||||
}
|
||||
const blank = [...held];
|
||||
for (const entry of added)
|
||||
if (calls.sentBefore.has(entry.member) && !members.has(entry.member))
|
||||
if (
|
||||
!blank.some(
|
||||
({ member, sent }) => member === entry.member && same(sent, entry.sent),
|
||||
)
|
||||
)
|
||||
blank.push(entry);
|
||||
return blank;
|
||||
};
|
||||
@@ -267,13 +246,9 @@ const mergedBody = (
|
||||
const added = contract.accepted.filter(
|
||||
(member) => !body.accepted.includes(member),
|
||||
);
|
||||
const blank = joinedBlanks(body.blank ?? [], contract.blank ?? [], {
|
||||
sentBefore: new Set([...body.accepted, ...fixed.keys()]),
|
||||
later: later.body,
|
||||
});
|
||||
const { blank: _blank, ...rest } = body;
|
||||
const blank = joinedBlanks(body.blank ?? [], contract.blank ?? []);
|
||||
return {
|
||||
...rest,
|
||||
...body,
|
||||
accepted: [...body.accepted, ...added],
|
||||
fixed: [
|
||||
...body.fixed,
|
||||
|
||||
@@ -680,6 +680,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,
|
||||
|
||||
+65
@@ -256,3 +256,68 @@ export const MINIMAL_CONTACT_ROWS: readonly TimedCaptureRow[] = (() => {
|
||||
["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]],
|
||||
];
|
||||
})();
|
||||
Reference in new issue
Block a user