mirror of
https://github.com/morluto/rea.git
synced 2026-11-03 21:36:13 +00:00
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:
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
|
||||
|
||||
@@ -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
@@ -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)]],
|
||||
];
|
||||
})();
|
||||
Reference in new issue
Block a user