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>
This commit is contained in:
Edgar ArakelyanandClaude Opus 5.5 committed 2026-10-26 14:38:55 -07:00
1 parent 0c0c6ef7c5
commit b490c8fd51
9 files changed
+178 -44

No files matched your search

@@ -32,11 +32,12 @@ natural-language tasks ask for, or withheld them:
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.
that excludes it or a whole-collection read that placed a create did not follow it; on a match, the type's creates
and updates take that order.
- **Unloaded answers.** A create's later item read is taken as its observation when it differs from the answer only in
values the answer had not loaded: empty, zero, `false` or Go's zero time, or objects whose members are each the same
or loaded so. An unsent update field that changes only between `null`, `[]` and `{}` is unchanged. Write answers
spell empty lists and maps as their recorded answers did.
spell empty lists and maps the call did not send as their recorded answers did, and a sent one as sent.
- **Unruled fields.** A later create that stored a field no rule writes otherwise than the plan keeps it is
`unexplained-change` at the create, so its recorded entity is held and later reads check other writes as before.
- **Skill.** The two scratch creates differ in what they send, the second only what a person must give, however they
+8 -5
View File
@@ -757,10 +757,12 @@ 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, 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
in its answer's shape and clock precision, with empty lists and maps the call
did not send spelled (`null`, `[]` or `{}`) as its answer spelled them, and
stores the fields of both with the item read-back's clock precision. Updates
answer empty lists and maps they did not send as their answers spelled them
too; a member a call sent is answered as sent. 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
@@ -853,7 +855,8 @@ 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
a create was placed by a sort that excludes it or a read listing the whole
collection that placed a create did not follow it; on a match, the type's creates
and updates take that order, as Grafana keeps folders by title though no earlier
read could tell that from creation order, or from id order where both ascend.
@@ -172,7 +172,15 @@ export const answerBulk = (
const answer = writeAnswer(
serving,
request,
(recorded) => writtenBulkResponse(plan.response!, recorded, written.value),
(recorded) =>
writtenBulkResponse(
plan.response!,
recorded,
written.value.map((entity, index) => ({
...entity,
sent: Object.keys(targets.value[index]!.body ?? {}),
})),
),
{
source: "state",
view: update ? "update" : "delete",
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
BULK_RENAMED_LABEL_ROWS,
ECHOED_EMPTY_ROWS,
formLabelRows,
LISTED_ID_ROWS,
NOTE_DEFAULT_ROWS,
@@ -43,6 +44,20 @@ describe("served creates and updates modelled on form recordings", () => {
).toMatchObject({ message: expect.stringContaining('"name"') });
});
it("answers an empty member a call sent as it was sent, whichever spelling the recorded answer used", () => {
const labels = servedFrom(ECHOED_EMPTY_ROWS);
const tags = (sent: JsonValue) => {
const body = labels.answer("post_labels", {
body: { name: `Spare ${JSON.stringify(sent)}`, tags: sent },
}).body;
if (typeof body !== "object" || body === null || Array.isArray(body))
throw new Error("Expected an object body");
return body["tags"];
};
expect(tags([])).toEqual([]);
expect(tags(null)).toBeNull();
});
it("moves a renamed entity to where the collection's order puts its new name", () => {
const labels = servedFrom(RENAMED_LABEL_ROWS);
labels.answer("patch_labels_by_id", { id: "1", body: { name: "Zinc" } });
@@ -334,7 +334,11 @@ export const writeAnswer = (
serving: StateServing,
request: WriteRequest,
rendered:
| { readonly fields: JsonObject | undefined; readonly id: string }
| {
readonly fields: JsonObject | undefined;
readonly id: string;
readonly sent?: readonly string[];
}
| ((
recorded: JsonValue | undefined,
) => Result<JsonValue | undefined, string>),
@@ -520,7 +524,7 @@ const answerCreate = (
const answer = writeAnswer(
serving,
request,
{ fields: fields.value, id },
{ fields: fields.value, id, sent: Object.keys(call.body ?? {}) },
state,
);
if (!answer.ok) return answer;
@@ -592,7 +596,11 @@ const answerUpdate = (
const answer = writeAnswer(
serving,
request,
{ fields: written.value, id: entity.id },
{
fields: written.value,
id: entity.id,
sent: Object.keys(call.body ?? {}),
},
{
source: "state",
view: "update",
@@ -711,8 +711,10 @@ export class EnvironmentStateWriteSimulation {
});
for (const name of shown.confirmed) {
this.#drafts.get(name)!.confirmed.push(record.ordinal);
if (showsPlacement(view, outcome, this.#inserted, name))
if (showsPlacement(view, outcome, this.#inserted, name)) {
this.#placed.add(name);
if (view.shape === "list") this.#narrowInserts(view, record, name);
}
}
return shown.unseen;
}
@@ -821,17 +823,12 @@ export class EnvironmentStateWriteSimulation {
for (const key of failed.keys) this.#taint.fail(key);
}
/**
* The successful check with the collection sorted by a field an update the read exercised writes, when the read
* lists the whole collection in that order and no create of the type was placed by a sort that excludes it; the
* type's creates then insert by it, and its updates move their entity by it. A sort the creates chose by a field the
* update did not write does not stop this, since both may have fit every earlier read.
*/
#retrySort(
/** What `use` makes of a read's items when the read lists the whole collection the state holds, the only read that shows the order it is kept in. */
#wholeListing<T>(
view: Extract<StateView, { shape: "list" }>,
record: StateRecordFacts,
exercised: ReadonlySet<string>,
): CheckOutcome | undefined {
use: (items: readonly JsonObject[]) => T,
): T | undefined {
const line = this.#line(view, record);
const idField = this.#types.get(view.type)?.id_field;
if (line === undefined || idField === undefined) return undefined;
@@ -840,6 +837,53 @@ export class EnvironmentStateWriteSimulation {
.members(view.type, line.collection)
.map((entity) => entity.id),
);
return this.#input.io.withBody(record.ordinal, (body) => {
const items =
body === undefined ? undefined : listItems(body, view.pointer);
if (
items === undefined ||
items.length < 2 ||
items.length !== held.size ||
!items.every((item) =>
held.has(scalarText(ownMember(item, idField)) ?? ""),
)
)
return undefined;
return use(items);
});
}
/** A whole-collection read that placed a create leaves it only the sorted inserts whose order the read follows. */
#narrowInserts(
view: Extract<StateView, { shape: "list" }>,
record: StateRecordFacts,
name: string,
): void {
const draft = this.#drafts.get(name)!;
const kept = this.#wholeListing(view, record, (items) =>
draft.candidates.filter(
(candidate) =>
candidate.rule !== "sorted" ||
same(candidate, draft.plan.insert) ||
orderedBy(items, candidate.field, candidate.direction),
),
);
if (kept !== undefined) draft.candidates = kept;
}
/**
* The successful check with the collection sorted by a field an update the read exercised writes, when the read
* lists the whole collection in that order and no create of the type was placed by a sort that excludes it; the
* type's creates then insert by it, and its updates move their entity by it. A sort the creates chose by a field the
* update did not write does not stop this when every whole-collection read that placed them followed this order too.
*/
#retrySort(
view: Extract<StateView, { shape: "list" }>,
record: StateRecordFacts,
exercised: ReadonlySet<string>,
): CheckOutcome | undefined {
const line = this.#line(view, record);
if (line === undefined) return undefined;
const written = new Set(
[...exercised].flatMap((name) => {
const { plan } = this.#drafts.get(name)!;
@@ -858,27 +902,16 @@ export class EnvironmentStateWriteSimulation {
draft.plan.target === view.type &&
draft.observed !== undefined,
);
const rules = this.#input.io.withBody(record.ordinal, (body) => {
const items =
body === undefined ? undefined : listItems(body, view.pointer);
// Only a read of the whole collection shows the order it is kept in.
if (
items === undefined ||
items.length < 2 ||
items.length !== held.size ||
!items.every((item) =>
held.has(scalarText(ownMember(item, idField)) ?? ""),
)
)
return [];
return [...written].flatMap((field) =>
(["ascending", "descending"] as const).flatMap((direction) =>
orderedBy(items, field, direction)
? [{ rule: "sorted", field, direction } as const]
: [],
const rules =
this.#wholeListing(view, record, (items) =>
[...written].flatMap((field) =>
(["ascending", "descending"] as const).flatMap((direction) =>
orderedBy(items, field, direction)
? [{ rule: "sorted", field, direction } as const]
: [],
),
),
);
});
) ?? [];
for (const rule of rules.filter(
(candidate) => current === undefined || !same(candidate, current),
)) {
@@ -5,6 +5,7 @@ import {
type TimedCaptureRow,
} from "../../../tests/fixtures/analysisView.js";
import {
CREATION_ORDER_ROWS,
DERIVED_TEXT_ROWS,
formLabelRows,
ID_OR_TITLE_ROWS,
@@ -141,6 +142,16 @@ describe("collections kept sorted", () => {
});
});
it("learns no sort from a rename that an earlier read of the creates' order rules out", () => {
const byTitle = { rule: "sorted", field: "title", direction: "ascending" };
expect(planOf(CREATION_ORDER_ROWS, "post_folders").insert).not.toEqual(
byTitle,
);
expect(planOf(CREATION_ORDER_ROWS, "put_folders_by_id")).toMatchObject({
served: false,
});
});
it("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) =>
@@ -529,6 +529,8 @@ export const writtenResponse = (
readonly id: string;
/** Generated clocks keep the response's spelling independently of the stored entity's. */
readonly clocks?: readonly string[];
/** Members the call sent, which the answer shows as stored. */
readonly sent?: readonly string[];
},
optional: readonly string[] = [],
): Applied<JsonValue | undefined> => {
@@ -569,8 +571,12 @@ export const writtenResponse = (
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))
// 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
@@ -590,7 +596,11 @@ export const writtenResponse = (
export const writtenBulkResponse = (
response: WriteResponse,
recorded: JsonValue | undefined,
written: readonly { readonly fields: JsonObject; readonly id: string }[],
written: readonly {
readonly fields: JsonObject;
readonly id: string;
readonly sent?: readonly string[];
}[],
): Applied<JsonValue | undefined> => {
if (response.shape === "empty") return ok(undefined);
if (response.shape === "recorded") return ok(recorded);
+45
View File
@@ -321,3 +321,48 @@ export const ID_OR_TITLE_ROWS: readonly TimedCaptureRow[] = (() => {
["GET", "/api/folders", [renamed, alpha, bravo, charlie]],
];
})();
/**
* Two seeded folders and two creates kept in creation order, which a list read after both shows (`Charlie` after
* `Zulu`), then a rename whose read happens to follow title order.
*/
export const CREATION_ORDER_ROWS: readonly TimedCaptureRow[] = (() => {
const [alpha, bravo] = [folder(1, "Alpha"), folder(2, "Bravo")];
const [zulu, charlie] = [folder(3, "Zulu"), folder(4, "Charlie")];
const renamed = folder(3, "Aardvark", 2);
return [
["GET", "/api/folders", [alpha, bravo]],
["GET", "/api/folders/1", alpha],
["POST", "/api/folders", zulu, 200, { title: "Zulu" }],
["GET", "/api/folders/3", zulu],
["GET", "/api/folders", [alpha, bravo, zulu]],
["POST", "/api/folders", charlie, 200, { title: "Charlie" }],
["GET", "/api/folders/4", charlie],
["GET", "/api/folders", [alpha, bravo, zulu, charlie]],
["PUT", "/api/folders/3", renamed, 200, { title: "Aardvark", version: 1 }],
["GET", "/api/folders/3", renamed],
["GET", "/api/folders", [renamed, alpha, bravo, charlie]],
];
})();
/** Two label creates that send and echo empty tags, one `null` and one `[]`, as an application storing what it is sent does. */
export const ECHOED_EMPTY_ROWS: readonly TimedCaptureRow[] = (() => {
const tagged = (entity: Entity, tags: JsonValue): Entity => ({
...entity,
tags,
});
const [kitchen, tools] = [tagged(KITCHEN, null), tagged(TOOLS, null)];
const garden = tagged(label(3, "Garden"), null);
const attic = tagged(label(4, "Attic"), []);
return [
["GET", "/api/labels", [kitchen, tools]],
["GET", "/api/labels/1", kitchen],
["GET", "/api/labels/2", tools],
["POST", "/api/labels", garden, 201, formBody(garden)],
["GET", "/api/labels/3", garden],
["GET", "/api/labels", [garden, kitchen, tools]],
["POST", "/api/labels", attic, 201, formBody(attic)],
["GET", "/api/labels/4", attic],
["GET", "/api/labels", [attic, garden, kitchen, tools]],
];
})();