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>
This commit is contained in:
Edgar ArakelyanandClaude Opus 5.5 committed 2026-10-26 16:31:53 -07:00
1 parent 4231166b84
commit 4b233f7670
8 files changed
+102 -20

No files matched your search

@@ -30,7 +30,8 @@ natural-language tasks ask for, or withheld them:
matches, which keeps the recorded value as a partial page's total does.
- **Sorted collections.** An update that writes the field its type's creates are sorted by moves its entity to where
the new value sorts, in replay and serving (a bulk update moving each entity in turn), and its plan carries that
`insert`. When a read listing the whole collection fails after an update and the type's creates are not sorted by a
`insert`. When a read listing the whole collection in collection order (not sorted by its query) fails after an
update and the type's creates are not sorted by a
field the update writes, replay tries the collection sorted by such a field when the entities no exercised write
changed already follow it, as serving moves only written entities, and unless a create was placed by a sort
that excludes it or a whole-collection read in collection order (not sorted by its query) that placed a create did
@@ -38,7 +39,7 @@ natural-language tasks ask for, or withheld them:
- **Unloaded answers.** A create's later item read is taken as its observation when it differs from the answer only in
values the answer had not loaded: empty, zero, `false` or Go's zero time, or objects whose members are each the same
or loaded so; the plan points to those values (`observation.read.unloaded`), which a served create's answer keeps as
the recorded answer showed them while the state holds the read's. An unsent update field that changes only between
the recorded answer showed them, unless the call sent one the plan stores as sent, while the state holds the read's. An unsent update field that changes only between
`null`, `[]` and `{}` is unchanged. Write answers, bulk answers and replay's checks of them included, spell empty
lists and maps the call did not send as their recorded answers did, and a sent one as sent.
- **Unruled fields.** A later create that stored a field no rule writes otherwise than the plan keeps it is
+3 -2
View File
@@ -760,7 +760,8 @@ 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 the call
did not send spelled (`null`, `[]` or `{}`) as its answer spelled them and the
values it had not loaded as it answered them, and stores the fields of both,
values it had not loaded as it answered them (unless the call sent one the plan
stores as sent), and stores the fields of both,
as the item read-back shows them, with its clock precision. Updates
answer empty lists and maps they did not send as their answers spelled them
too; a member a call sent is answered as sent. A `reference` is an object
@@ -856,7 +857,7 @@ all request completions are known; otherwise it is null. An
update that writes the field its type's creates are sorted by moves its entity
to where the new value sorts (its own `insert`); a bulk update moves each entity
it writes in turn, as replay applies them. When a read listing the whole
collection fails after an update and the type's creates are not sorted by a field
collection in its own order, not one its query sorts, fails after an update and the type's creates are not sorted by a field
that update writes, the view tries the collection sorted by such a field, when
the entities no write it exercised changed already follow it, as serving moves
only the entities a write changes, and unless
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
ACTIVE_ON_READ_ROWS,
BULK_RENAMED_LABEL_ROWS,
ECHOED_EMPTY_ROWS,
formLabelRows,
@@ -146,6 +147,18 @@ describe("served creates and updates modelled on form recordings", () => {
});
});
it("answers a sent value its create does not store as the recorded answer did, and stores the read's", () => {
const users = servedFrom(ACTIVE_ON_READ_ROWS);
expect(
users.answer("post_users", { body: { name: "Cy", active: false } }).body,
).toEqual({ id: 2, name: "Cy", active: false });
expect(users.answer("get_users_by_id", { id: "2" }).body).toEqual({
id: 2,
name: "Cy",
active: true,
});
});
it("lists a created tag's id in the list envelope's ids, in list order", () => {
const tags = servedFrom(LISTED_ID_ROWS);
tags.answer("post_tags", { body: { name: "Lamp" } });
@@ -43,6 +43,7 @@ import {
collectionMatch,
decodedSegment,
isObject,
pointerTokens,
scalarText,
type JsonObject,
type StateEntity,
@@ -375,7 +376,18 @@ export const writeAnswer = (
rule.rule === "clock" ? [rule.field] : [],
)
: [],
unloaded: plan.observation?.read?.unloaded ?? [],
// A member the call sent and the plan stores as sent is answered as sent; any other keeps the recorded answer.
unloaded: (plan.observation?.read?.unloaded ?? []).filter(
(path) => {
const [field] = pointerTokens(path);
return !(
rendered.sent?.includes(field!) === true &&
plan.rules.some(
(rule) => rule.field === field && rule.rule === "echo",
)
);
},
),
},
optionalFields(plan),
);
@@ -104,6 +104,23 @@ const keptInOrder = (
);
});
/** Whether a list read's query sets a parameter the view's query model reads as a sort or its direction. */
const sortedByQuery = (
view: Extract<StateView, { shape: "list" }>,
record: StateRecordFacts,
): boolean => {
const sent = new Set(
[...new URLSearchParams(record.query ?? "").keys()].map((key) =>
key.toLowerCase(),
),
);
return Object.entries(view.query?.parameters ?? {}).some(
([parameter, { role }]) =>
(role === "sort" || role === "direction") &&
sent.has(parameter.toLowerCase()),
);
};
const APPLY = {
create: applyCreate,
update: applyUpdate,
@@ -882,19 +899,7 @@ export class EnvironmentStateWriteSimulation {
record: StateRecordFacts,
name: string,
): void {
const sent = new Set(
[...new URLSearchParams(record.query ?? "").keys()].map((key) =>
key.toLowerCase(),
),
);
if (
Object.entries(view.query?.parameters ?? {}).some(
([parameter, { role }]) =>
(role === "sort" || role === "direction") &&
sent.has(parameter.toLowerCase()),
)
)
return;
if (sortedByQuery(view, record)) return;
const draft = this.#drafts.get(name)!;
const kept = this.#wholeListing(view, record, (items) =>
draft.candidates.filter(
@@ -919,7 +924,8 @@ export class EnvironmentStateWriteSimulation {
exercised: ReadonlySet<string>,
): CheckOutcome | undefined {
const line = this.#line(view, record);
if (line === undefined) return undefined;
// A read its query sorts shows the query's order, which may break ties otherwise than the collection.
if (line === undefined || sortedByQuery(view, record)) return undefined;
const written = new Set(
[...exercised].flatMap((name) => {
const { plan } = this.#drafts.get(name)!;
@@ -15,6 +15,7 @@ import {
NOTE_DEFAULT_ROWS,
RENAMED_LABEL_ROWS,
SHELF_SORTED_ROWS,
SHELF_TIE_ROWS,
TITLE_ORDER_ROWS,
UNLOADED_ANSWER_ROWS,
UNSORTED_SEED_ROWS,
@@ -169,6 +170,10 @@ describe("collections kept sorted", () => {
expect(plan.insert).toBeNull();
});
it("learns no sort from a read its query sorts, whose ties need not follow the collection's order", () => {
expect(planOf(SHELF_TIE_ROWS, "put_books_by_id").insert).toBeNull();
});
it("serves a bulk update whose recordings echo the empty values each call sent", () => {
expect(planOf(BULK_EMPTY_TAG_ROWS, "patch_labels")).toMatchObject({
served: true,
@@ -590,7 +590,7 @@ export const writtenResponse = (
(rendered, path) => {
const tokens = pointerTokens(path);
const kept = memberAt(shown, tokens);
return kept === undefined || written.sent?.includes(tokens[0]!) === true
return kept === undefined
? rendered
: withMemberAt(rendered, tokens, kept);
},
+44
View File
@@ -439,3 +439,47 @@ export const UNSORTED_SEED_ROWS: readonly TimedCaptureRow[] = (() => {
["GET", "/api/folders", [alpha, bravo, renamed]],
];
})();
/** Books kept in id order, which a `?sort=shelf` read breaks ties of by name, then a rename that read shows reordered. */
export const SHELF_TIE_ROWS: readonly TimedCaptureRow[] = (() => {
const shelved = (id: number, name: string): Entity => ({
id,
name,
shelf: "a",
});
const [alpha, bravo] = [shelved(1, "Alpha"), shelved(2, "Bravo")];
const zulu = shelved(1, "Zulu");
return [
["GET", "/api/books", [alpha, bravo]],
["GET", "/api/books?sort=-id", [bravo, alpha]],
["GET", "/api/books/1", alpha],
["GET", "/api/books/2", bravo],
["PUT", "/api/books/1", zulu, 200, { name: "Zulu", shelf: "a" }],
["GET", "/api/books/1", zulu],
["GET", "/api/books?sort=shelf", [bravo, zulu]],
["GET", "/api/books", [zulu, bravo]],
];
})();
/** A user create that sends `active: false` and is answered so, whose read shows the account active. */
export const ACTIVE_ON_READ_ROWS: readonly TimedCaptureRow[] = (() => {
const user = (id: number, name: string, active: boolean): Entity => ({
id,
name,
active,
});
const root = user(1, "Root", true);
return [
["GET", "/api/users", [root]],
["GET", "/api/users/1", root],
[
"POST",
"/api/users",
user(2, "Ada", false),
201,
{ name: "Ada", active: false },
],
["GET", "/api/users/2", user(2, "Ada", true)],
["GET", "/api/users", [root, user(2, "Ada", true)]],
];
})();