diff --git a/docs/specs/agent-environments/state-writes/spec.md b/docs/specs/agent-environments/state-writes/spec.md index 76dae8c4f6..b9ddfbeace 100644 --- a/docs/specs/agent-environments/state-writes/spec.md +++ b/docs/specs/agent-environments/state-writes/spec.md @@ -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 diff --git a/docs/specs/agent-environments/state-writes/verification.md b/docs/specs/agent-environments/state-writes/verification.md index b41444f405..2345a73be8 100644 --- a/docs/specs/agent-environments/state-writes/verification.md +++ b/docs/specs/agent-environments/state-writes/verification.md @@ -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. diff --git a/src/application/environment/environmentStateWriteAnswer.test.ts b/src/application/environment/environmentStateWriteAnswer.test.ts index a6d98148f6..94851dcc33 100644 --- a/src/application/environment/environmentStateWriteAnswer.test.ts +++ b/src/application/environment/environmentStateWriteAnswer.test.ts @@ -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]], diff --git a/src/domain/analysisView/environmentStateRules.ts b/src/domain/analysisView/environmentStateRules.ts index fe9b15e1b9..99a43ec22d 100644 --- a/src/domain/analysisView/environmentStateRules.ts +++ b/src/domain/analysisView/environmentStateRules.ts @@ -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)); diff --git a/src/domain/analysisView/environmentStateWriteExplain.ts b/src/domain/analysisView/environmentStateWriteExplain.ts index 4034185f47..40f4af59db 100644 --- a/src/domain/analysisView/environmentStateWriteExplain.ts +++ b/src/domain/analysisView/environmentStateWriteExplain.ts @@ -280,10 +280,14 @@ export const explainUpdate = ( const { body } = evidence.call; const rules: FieldRule[] = []; const echoed = new Set(); + 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) ||