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>
This commit is contained in:
Edgar ArakelyanandClaude Opus 5.5 committed 2026-10-26 16:06:20 -07:00
1 parent 3de7d9a7ae
commit 28cda1f3f6
5 files changed
+67 -5

No files matched your search

@@ -31,7 +31,8 @@ natural-language tasks ask for, or withheld them:
- **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
field the update writes, replay tries the collection sorted by such a field, unless a create was placed by a sort
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
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
+3 -1
View File
@@ -857,7 +857,9 @@ 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
that update writes, the view tries the collection sorted by such a field, unless
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
a create was placed by a sort that excludes it or a read listing the whole
collection in its own order, not one its query sorts, that placed a create did
not follow it; on a match, the type's creates
@@ -85,7 +85,24 @@ import {
} from "./environmentStateWriteRules.js";
import { WriteTaint } from "./environmentStateWriteTaint.js";
import { writeVerdict } from "./environmentStateWriteVerdict.js";
import { orderedBy } from "./environmentStateWriteExplain.js";
import { compareValues, orderedBy } from "./environmentStateWriteExplain.js";
/** Whether members are in a sorted rule's order, ties kept in place as an insert keeps them. */
const keptInOrder = (
members: readonly JsonObject[],
rule: Extract<InsertRule, { rule: "sorted" }>,
): boolean =>
members.every((member, index) => {
if (index === 0) return true;
const order = compareValues(
ownMember(members[index - 1]!, rule.field),
ownMember(member, rule.field),
);
return (
order !== undefined &&
(rule.direction === "ascending" ? order <= 0 : order >= 0)
);
});
const APPLY = {
create: applyCreate,
@@ -135,6 +152,8 @@ export class EnvironmentStateWriteSimulation {
}[] = [];
/** Entities a create inserted, by target, with the create's tool. */
readonly #inserted = new Map<string, string>();
/** The targets each write tool changed, which serving moves when their collection is kept sorted. */
readonly #wrote = new Map<string, Set<string>>();
/** Creates whose insert rule a passing list check relied on, so it is no longer changed. */
readonly #placed = new Set<string>();
/** The order a read showed a type's collections sorted in after an update, when no create of it showed one. */
@@ -442,11 +461,15 @@ export class EnvironmentStateWriteSimulation {
return;
}
this.#effects.supersede(this.#touched, write.tool, result);
for (const target of result.targets)
const wrote = this.#wrote.get(write.tool) ?? new Set<string>();
this.#wrote.set(write.tool, wrote);
for (const target of result.targets) {
wrote.add(target);
touchTarget(this.#touched, this.#touchedSince, target, {
...write,
fields: result.fields,
});
}
this.#log.push(write);
if (result.inserted === undefined) return;
this.#inserted.set(result.inserted, write.tool);
@@ -925,8 +948,20 @@ export class EnvironmentStateWriteSimulation {
),
),
) ?? [];
// Serving moves only the entities an update writes, so the others must already be in the order tried.
const kept = this.#state
.members(view.type, line.collection)
.filter((entity) => {
const target = entityTarget(entity.type, entity.collection, entity.id);
return ![...exercised].some((name) =>
this.#wrote.get(name)?.has(target),
);
})
.map((entity) => entity.fields);
for (const rule of rules.filter(
(candidate) => current === undefined || !same(candidate, current),
(candidate) =>
(current === undefined || !same(candidate, current)) &&
keptInOrder(kept, candidate),
)) {
// A create placed by another sort shows the collection is not kept in this order.
if (
@@ -17,6 +17,7 @@ import {
SHELF_SORTED_ROWS,
TITLE_ORDER_ROWS,
UNLOADED_ANSWER_ROWS,
UNSORTED_SEED_ROWS,
} from "../../../tests/fixtures/environmentStateFormWrites.js";
import { environmentStateViewOf } from "../../../tests/fixtures/environmentStateWrites.js";
import { mergedCreatePlan } from "./environmentStateWriteCreateCalls.js";
@@ -162,6 +163,12 @@ describe("collections kept sorted", () => {
});
});
it("learns no sort from a rename that moving the renamed entity alone cannot reach", () => {
const plan = planOf(UNSORTED_SEED_ROWS, "put_folders_by_id");
expect(plan.served).toBe(false);
expect(plan.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,
+17
View File
@@ -422,3 +422,20 @@ export const SHELF_SORTED_ROWS: readonly TimedCaptureRow[] = (() => {
["GET", "/api/books", [renamed, alpha, bravo]],
];
})();
/** Seeded folders listed out of title order, then a rename after which a list read happens to show title order. */
export const UNSORTED_SEED_ROWS: readonly TimedCaptureRow[] = (() => {
const [charlie, bravo, alpha] = [
folder(1, "Charlie"),
folder(2, "Bravo"),
folder(3, "Alpha"),
];
const renamed = folder(1, "Zulu", 2);
return [
["GET", "/api/folders", [charlie, bravo, alpha]],
["GET", "/api/folders/1", charlie],
["PUT", "/api/folders/1", renamed, 200, { title: "Zulu", version: 1 }],
["GET", "/api/folders/1", renamed],
["GET", "/api/folders", [alpha, bravo, renamed]],
];
})();