mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(environment): never rule an entity's identity, and read a zero page size as no bound
Greptile raised these on the list-queries branch against this branch's code. - An update's identity member never takes a rule. Sent as the path's id it is the identity member; sent as another value the application ignored it is accepted with that value and stores nothing, so the exemplar's id is no longer written into the entity another call names; a stored id that changed is unexplained. - A page size of zero bounds nothing, so a whole line recorded with `limit=0` answers from the state instead of replaying stale members. The verdicts over the 50 captures are unchanged plan by plan, and the smoke applies all 31 served write tools. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
264ce41062
commit
0ae64400ee
5 files changed
+62
-7
No files matched your search
@@ -102,7 +102,10 @@ naming the exemplar's collection.
|
||||
**Updates.** Body members follow the same accepted and not-accepted rules,
|
||||
except that a member the application stored differently is accepted only with
|
||||
the value a recorded call sent it with, and then stores the value that call
|
||||
recorded (its `constant` rule carries it).
|
||||
recorded (its `constant` rule carries it). The type's identity field never
|
||||
takes a rule: sent as the path's id it is the identity member, sent as another
|
||||
value the application ignored it is accepted with that value and stores
|
||||
nothing, and a stored id that changed is `unexplained-change`.
|
||||
`PUT` requires every member the exemplar sent; `PATCH` and `POST` require at
|
||||
least one. A field the exemplar's update changed without sending it is
|
||||
explained as one of:
|
||||
@@ -191,7 +194,7 @@ from the state:
|
||||
different values is never chosen, since no call's arguments can name it; one
|
||||
repeating a name with one value is, as a call naming it matches both. A list
|
||||
is compared as served from the collection of that line. A read on a page
|
||||
(`limit`, `size`, `per_page` and the like, or a `_start`–`_end` range) of a
|
||||
(`limit`, `size`, `per_page` and the like, a size of zero bounding nothing, or a `_start`–`_end` range) of a
|
||||
whole list checks nothing once the collection holds more entities than the
|
||||
page, which serving then answers from its recording.
|
||||
- A read made while a modelled write to its entity or collection was still
|
||||
|
||||
@@ -310,3 +310,12 @@ A thirteenth round settled seven findings Greptile raised on this branch, the fi
|
||||
|
||||
Each new test fails on the previous head. The verdicts over the 50 captures are unchanged plan by plan, the smoke
|
||||
applies all 31 served write tools, and the gates passed again.
|
||||
|
||||
A fourteenth round settled two findings Greptile raised on the list-queries branch against this branch's code:
|
||||
|
||||
- the type's identity field never becomes a constant: an update whose id member the application ignored no longer
|
||||
writes the exemplar's id into the entity another call names;
|
||||
- a page size of zero bounds nothing, so a whole line recorded with `limit=0` answers from the state.
|
||||
|
||||
Both tests fail on the previous head. The verdicts over the 50 captures are unchanged plan by plan, the smoke applies
|
||||
all 31 served write tools, and the gates passed again.
|
||||
@@ -83,6 +83,29 @@ describe("served creates", () => {
|
||||
});
|
||||
|
||||
describe("served updates and deletes", () => {
|
||||
it("keeps an entity's identity when the application ignored the id an update sent", () => {
|
||||
const renamed = renamedBook(FIRST_BOOK, "Renamed Book", 3);
|
||||
const books = servedFrom([
|
||||
...BOOK_INIT_ROWS,
|
||||
[
|
||||
"PATCH",
|
||||
"/api/books/1",
|
||||
renamed,
|
||||
200,
|
||||
{ id: 999, name: "Renamed Book" },
|
||||
],
|
||||
["GET", "/api/books/1", renamed],
|
||||
]);
|
||||
const answer = books.answer("patch_books_by_id", {
|
||||
id: "2",
|
||||
body: { id: 999, name: "Second Edition" },
|
||||
});
|
||||
expect(answer.body).toMatchObject({ id: 2, name: "Second Edition" });
|
||||
expect(books.answer("get_books_by_id", { id: "2" }).body).toMatchObject({
|
||||
id: 2,
|
||||
});
|
||||
});
|
||||
|
||||
const SECOND_EDITION = renamedBook(SECOND_BOOK, "Second Edition", 13);
|
||||
const RENAME_SECOND = { id: "2", body: { name: "Second Edition" } };
|
||||
|
||||
@@ -671,6 +694,21 @@ describe("paged lines", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("answers a whole list a zero limit names from the state, as zero lists every match", () => {
|
||||
const books = servedFrom([
|
||||
["GET", "/api/books?limit=0", [FIRST_BOOK, SECOND_BOOK]],
|
||||
["GET", "/api/books/1", FIRST_BOOK],
|
||||
["GET", "/api/books/2", SECOND_BOOK],
|
||||
["POST", "/api/books", THIRD_BOOK, 201, { name: "Third Book" }],
|
||||
["GET", "/api/books/3", THIRD_BOOK],
|
||||
]);
|
||||
const created = books.answer("post_books", CREATE_NEW_BOOK).body as Book;
|
||||
expect(books.answer("get_books", { limit: "0" })).toMatchObject({
|
||||
body: [FIRST_BOOK, SECOND_BOOK, created],
|
||||
state: { source: "state", membership: "all", listed: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it("answers a range the collection has outgrown from its recording", () => {
|
||||
const books = servedFrom([
|
||||
["GET", "/api/books?_start=0&_end=2", [FIRST_BOOK, SECOND_BOOK]],
|
||||
|
||||
@@ -186,8 +186,10 @@ export const pageSize = (query: string | undefined): number | undefined => {
|
||||
for (const [name, value] of iterateUrlQueryParameters(query ?? "")) {
|
||||
const key = name.toLowerCase();
|
||||
if (!/^\d+$/u.test(value)) continue;
|
||||
if (PAGE_SIZE_PARAMETERS.has(key)) size = Math.min(size, Number(value));
|
||||
else if (key === "_start") range.start = Number(value);
|
||||
// A size of zero bounds nothing: applications read it as no limit, as a whole line naming it shows.
|
||||
if (PAGE_SIZE_PARAMETERS.has(key)) {
|
||||
if (Number(value) > 0) size = Math.min(size, Number(value));
|
||||
} else if (key === "_start") range.start = Number(value);
|
||||
else if (key === "_end") range.end = Number(value);
|
||||
}
|
||||
const width = Math.min(size, Math.max(range.end - range.start, 0));
|
||||
|
||||
@@ -280,10 +280,14 @@ export const explainUpdate = (
|
||||
const { body } = evidence.call;
|
||||
const rules: FieldRule[] = [];
|
||||
const echoed = new Set<string>();
|
||||
const unexplained: string[] = [];
|
||||
let identity: string | null = null;
|
||||
for (const [member, sent] of Object.entries(body)) {
|
||||
if (member === evidence.idField && scalarText(sent) === evidence.id) {
|
||||
identity = member;
|
||||
// The identity is the entity's key: the path's id repeated is the identity member; another value is one the application ignored, or used to re-key the entity, which no rule models.
|
||||
if (member === evidence.idField) {
|
||||
if (scalarText(sent) === evidence.id) identity = member;
|
||||
else if (!same(evidence.after[member], evidence.before[member]))
|
||||
unexplained.push(member);
|
||||
continue;
|
||||
}
|
||||
if (!Object.hasOwn(evidence.after, member)) continue;
|
||||
@@ -297,7 +301,6 @@ export const explainUpdate = (
|
||||
value: evidence.after[member]!,
|
||||
});
|
||||
}
|
||||
const unexplained: string[] = [];
|
||||
for (const field of Object.keys(evidence.after)) {
|
||||
if (
|
||||
Object.hasOwn(body, field) ||
|
||||
|
||||
Reference in new issue
Block a user