diff --git a/docs/agent-environments.md b/docs/agent-environments.md index e8fe3daad7..9efcde5425 100644 --- a/docs/agent-environments.md +++ b/docs/agent-environments.md @@ -432,7 +432,7 @@ A list or document call that names no checked line, including a line recorded after a write or with other query values, answers from the recording. Every answer has the replay shape plus `state`: `{"source": "state", "view": …}` naming the entity, collection or document path, or `{"source": "recording", -"reason": …}` with the view's reason, `no-view` or `unrecorded-request`. A +"reason": …}` with the view's reason, `no-view`, `unrecorded-request` or `page-exceeded` (a call naming a page of a whole list that the collection has outgrown). A state answer carries the base record's status, content type and `replay` provenance with `truncated: false`; one whose JSON would exceed 256 KiB is refused as a `resource_constraint` tool error rather than cut. Query `POST`s diff --git a/docs/specs/agent-environments/state-writes/spec.md b/docs/specs/agent-environments/state-writes/spec.md index 48f3832a43..8f31e69d28 100644 --- a/docs/specs/agent-environments/state-writes/spec.md +++ b/docs/specs/agent-environments/state-writes/spec.md @@ -174,18 +174,26 @@ members they sent. **Reads.** A read is checked when the served environment would answer it from the state: -- an item read of a state-backed item view; +- an item read of a state-backed item view, of the entity serving would name: + the read's collection and id as written, else a held collection whose path + matches once percent-decoded; - a list or document read on one of its view's checked request lines, chosen as replay chooses the line for the read's call: each path parameter the read's value verbatim or once percent-decoded, preferring verbatim spellings, then recorded order, and the query values decoded, in any order. A read with no query on a whole list falls back, as serving does, on a - checked line that only pages it. A line whose query repeats a name is never - chosen, since no call's arguments can name it. A list is compared as served - from the collection of that line. + checked line that only pages it. A line whose query repeats a name with + 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) 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 running, starting before that write ended, checks nothing: it may show the state before or after the write. +- A read a write's effect was learned from checks nothing of that write, but + still contradicts the other writes it exercises. **Comparison.** The read's answer is the full response serving would give from the copy: an item read's entity rendered into its item view's base recording, @@ -205,9 +213,13 @@ refuses to answer with. An update's effect is the fields it sent or changed, derived ones included. A later update overwrites the fields it sets (an increment adds to its field instead), a recorded outcome every field, and a create or delete the whole - entity. A write with nothing left on an entity no longer counts there, nor in - a collection where no entity it changed keeps any of its effect. A pass confirms them and a mismatch - contradicts them. + entity. A document write's effect is the members it merged, or the whole + document when it replaces it. A write with nothing left on an entity no + longer counts there, nor in a collection where no entity it changed keeps any + of its effect. A mismatch contradicts them. A pass confirms those it shows + something of: a create's or delete's membership, or a changed field the + read's view shows and does not treat as volatile; a read that shows none of + an update's changes confirms nothing of it. - A mismatch after an unmodelled write that followed those writes is checked only on the members they sent: such a write may explain the rest. - A mismatch on an entity or collection that no write changed contradicts, diff --git a/docs/specs/agent-environments/state-writes/verification.md b/docs/specs/agent-environments/state-writes/verification.md index 93e9c6de1f..4a6b82dcbf 100644 --- a/docs/specs/agent-environments/state-writes/verification.md +++ b/docs/specs/agent-environments/state-writes/verification.md @@ -274,3 +274,15 @@ against this branch's code: 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. + +An eleventh round settled six more findings, five raised on this branch and one on the fidelity branch: + +- a passing read confirms only the writes it shows something of; +- an item read names its entity as serving does, percent-decoded spellings included; +- a read a write's effect was learned from still contradicts the other writes; +- a line repeating a query name with one value stays checkable; +- document writes join the effect tracking, so a replacement overwrites an earlier merge; +- a call naming a page that the collection has outgrown answers from its recording (`page-exceeded`), and the gate does not check such reads. + +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. diff --git a/src/application/environment/environmentStateAnswer.ts b/src/application/environment/environmentStateAnswer.ts index 14e95b90a1..cd41601dac 100644 --- a/src/application/environment/environmentStateAnswer.ts +++ b/src/application/environment/environmentStateAnswer.ts @@ -2,6 +2,7 @@ import { stateBodyJson } from "../../domain/analysisView/EnvironmentStateAccumul import { collectionPathOf, listItems, + pageSize, pagesOnly, missingRequiredFields, renderStateEntity, @@ -48,7 +49,11 @@ import { environmentToolArguments } from "./environmentTools.js"; export type StateProvenance = | { readonly source: "recording"; - readonly reason: RecordedViewReason | "no-view" | "unrecorded-request"; + readonly reason: + | RecordedViewReason + | "no-view" + | "unrecorded-request" + | "page-exceeded"; } | { readonly source: "state"; @@ -420,6 +425,14 @@ const answerList = ( return fromRecording(source, tool, args, "body-unusable"); const collection = collectionPathOf(base, false); const type = store.type(view.type); + // A requested page the collection has outgrown is answered as recorded, never with more items than it asked for. + const size = exact ? pageSize(base.query ?? undefined) : undefined; + if ( + view.membership === "all" && + size !== undefined && + store.inCollection(view.type, collection).length > size + ) + return fromRecording(source, tool, args, "page-exceeded"); const members: readonly StateEntity[] = view.membership === "all" ? store.inCollection(view.type, collection) diff --git a/src/application/environment/environmentStateWriteAnswer.test.ts b/src/application/environment/environmentStateWriteAnswer.test.ts index 0f7066d195..0436e2e6c0 100644 --- a/src/application/environment/environmentStateWriteAnswer.test.ts +++ b/src/application/environment/environmentStateWriteAnswer.test.ts @@ -648,3 +648,26 @@ describe("values the state started with", () => { expect(create("acme1", "Epsilon")).toBe("/api/orgs/%61cme1/books"); }); }); + +describe("paged lines", () => { + it("answers a requested page the collection has outgrown from its recording, and a call with no query from the state", () => { + const books = servedFrom([ + ["GET", "/api/books?limit=2", [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" }], + // The application answers the same page with two books; serving would not answer it from the state. + ["GET", "/api/books?limit=2", [FIRST_BOOK, SECOND_BOOK]], + ["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK, THIRD_BOOK]], + ]); + const created = books.answer("post_books", CREATE_NEW_BOOK).body as Book; + expect(books.answer("get_books", { limit: "2" })).toMatchObject({ + body: [FIRST_BOOK, SECOND_BOOK], + state: { source: "recording", reason: "page-exceeded" }, + }); + expect(books.answer("get_books")).toMatchObject({ + body: [FIRST_BOOK, SECOND_BOOK, created], + state: { source: "state", membership: "all", listed: 3 }, + }); + }); +}); diff --git a/src/domain/analysisView/EnvironmentStateWriteSimulation.test.ts b/src/domain/analysisView/EnvironmentStateWriteSimulation.test.ts index 34329b0a52..82974b0a02 100644 --- a/src/domain/analysisView/EnvironmentStateWriteSimulation.test.ts +++ b/src/domain/analysisView/EnvironmentStateWriteSimulation.test.ts @@ -613,3 +613,142 @@ describe("reads as serving answers them", () => { }); }); }); + +describe("what a read shows of a write", () => { + it("confirms no update by a read that shows none of the fields it changed", () => { + const rows: TimedCaptureRow[] = [ + [ + "GET", + "/api/books", + [ + { id: 1, name: "A" }, + { id: 2, name: "B" }, + ], + ], + ["GET", "/api/books/1", { id: 1, name: "A", description: "x" }], + ["GET", "/api/books/2", { id: 2, name: "B", description: "y" }], + [ + "PATCH", + "/api/books/1", + { id: 1, name: "A", description: "new" }, + 200, + { description: "new" }, + ], + // The list passes, but it shows no description. + [ + "GET", + "/api/books", + [ + { id: 1, name: "A" }, + { id: 2, name: "B" }, + ], + ], + ]; + expect(gated(rows, "patch_books_by_id")).toMatchObject({ + served: false, + reason: "unconfirmed", + checked_record_ordinals: [], + }); + }); + + it("lets a read a create was observed through still contradict another write", () => { + const rows: TimedCaptureRow[] = [ + ["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK]], + ["GET", "/api/books/1", FIRST_BOOK], + ["GET", "/api/books/2", SECOND_BOOK], + ["PATCH", "/api/books/1", RENAMED, 200, { name: "Renamed Book" }], + ["GET", "/api/books/1", RENAMED], + // The create answers with its id alone, so the next list shows its entity; that list shows book 1 unrenamed. + ["POST", "/api/books", { id: 3 }, 201, { name: "Third Book" }], + ["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK, THIRD_BOOK]], + ]; + expect(gated(rows, "patch_books_by_id")).toMatchObject({ + served: false, + contradicted_by: 6, + }); + }); + + it("confirms no document write a later replacement overwrote", () => { + const rows: TimedCaptureRow[] = [ + ["GET", "/api/settings", { theme: "light", lang: "en" }], + [ + "PATCH", + "/api/settings", + { theme: "dark", lang: "en" }, + 200, + { theme: "dark" }, + ], + [ + "PUT", + "/api/settings", + { theme: "blue", lang: "fr" }, + 200, + { theme: "blue", lang: "fr" }, + ], + ["GET", "/api/settings", { theme: "blue", lang: "fr" }], + ]; + expect(gated(rows, "patch_settings")).toMatchObject({ + served: false, + checked_record_ordinals: [], + }); + expect(gated(rows, "put_settings")).toMatchObject({ + served: true, + checked_record_ordinals: [3], + }); + }); +}); + +describe("reads named as serving names them", () => { + const orgBook = (id: number, org: string, name: string): Book => ({ + id, + org, + name, + }); + it("contradicts an update by a read spelled plainly of the encoded collection serving answers it from", () => { + const alpha = orgBook(1, "acme1", "Alpha"); + const renamed = orgBook(1, "acme1", "Renamed"); + const beta = orgBook(2, "beta2", "Beta"); + const renamedBeta = orgBook(2, "beta2", "Renamed Beta"); + const rows: TimedCaptureRow[] = [ + ["GET", "/api/orgs/%61cme1/books", [alpha]], + ["GET", "/api/orgs/beta2/books", [beta]], + ["GET", "/api/orgs/%61cme1/books/1", alpha], + ["GET", "/api/orgs/beta2/books/2", beta], + ["PATCH", "/api/orgs/%61cme1/books/1", renamed, 200, { name: "Renamed" }], + [ + "PATCH", + "/api/orgs/beta2/books/2", + renamedBeta, + 200, + { name: "Renamed Beta" }, + ], + // Serving answers this plain spelling with the renamed book, which the recording shows absent. + ["GET", "/api/orgs/acme1/books/1", null], + ["GET", "/api/orgs/%61cme1/books/1", renamed], + ["GET", "/api/orgs/beta2/books/2", renamedBeta], + ]; + const { writes } = environmentStateViewOf(timedCaptureEvidence(rows), [ + "sibling-variance", + ]); + expect(writes.find((plan) => plan.effect === "update")).toMatchObject({ + served: false, + contradicted_by: 6, + }); + }); + + it("checks a line whose query repeats a name with one value, which a call still names", () => { + const rows: TimedCaptureRow[] = [ + ["GET", "/api/books?tag=a&tag=a", [FIRST_BOOK]], + ["GET", "/api/books/1", FIRST_BOOK], + ["GET", "/api/books/2", SECOND_BOOK], + ["PATCH", "/api/books/1", RENAMED, 200, { name: "Renamed Book" }], + // The list still shows the old name, though the item read after it shows the rename. + ["GET", "/api/books?tag=a&tag=a", [FIRST_BOOK]], + ["GET", "/api/books/1", RENAMED], + ]; + expect(gated(rows, "patch_books_by_id")).toMatchObject({ + served: false, + contradicted_by: 4, + }); + }); +}); diff --git a/src/domain/analysisView/EnvironmentStateWriteSimulation.ts b/src/domain/analysisView/EnvironmentStateWriteSimulation.ts index 5a73cae63c..c1689d9604 100644 --- a/src/domain/analysisView/EnvironmentStateWriteSimulation.ts +++ b/src/domain/analysisView/EnvironmentStateWriteSimulation.ts @@ -1,10 +1,8 @@ import { at, changesState, - collectionPathOf, isObject, isStateWrite, - lastSegmentValue, normalizedPath, type StateDocument, type StateEntity, @@ -22,6 +20,7 @@ import { collectionTarget, entityTarget, isAbsence, + itemTarget, servedLine, succeeded, withBodies, @@ -32,6 +31,9 @@ import { type ServedLine, } from "./environmentStateWriteChecks.js"; import { + exercisedBy, + runningAt, + shownFields, touchTarget, WriteEffects, type PendingTargets, @@ -56,6 +58,7 @@ import { type StateWritePlan, } from "./environmentStateWriteRules.js"; import { WriteTaint } from "./environmentStateWriteTaint.js"; +import { writeVerdict } from "./environmentStateWriteVerdict.js"; /** What the write gate reads: the manifest tools, the timeline, the read views and the initial state. */ export interface WriteSimulationInput { @@ -90,7 +93,8 @@ export class EnvironmentStateWriteSimulation { readonly #types = new Map(); /** Each list or document view's checked request lines, with the recording a call of the line is answered on. */ readonly #checkedLines: ReadonlyMap; - readonly #observations = new Set(); + /** Reads a write's effect was learned from, with the tools that borrowed each. */ + readonly #observations = new Map>(); readonly #taint: WriteTaint; readonly #touched: TouchedTargets = new Map(); readonly #effects = new WriteEffects(); @@ -154,7 +158,9 @@ export class EnvironmentStateWriteSimulation { }); } this.#input.records.forEach((record, index) => this.#step(record, index)); - return [...this.#drafts.values()].map((draft) => this.#verdict(draft)); + return [...this.#drafts.values()].map((draft) => + writeVerdict(draft, this.#input), + ); } #step(record: StateRecordFacts, index: number): void { @@ -163,11 +169,10 @@ export class EnvironmentStateWriteSimulation { if (isStateWrite(record) && !succeeded(record)) this.#unmodelled(record.path, index); else if (isStateWrite(record)) this.#write(record, index, tool); - else if (tool?.method === "GET" && !this.#observations.has(record.ordinal)) - this.#read(record, index, tool); + else if (tool?.method === "GET") this.#read(record, index, tool); } - #context(): EffectContext { + #context(writer: string): EffectContext { return { state: this.#state, io: this.#input.io, @@ -180,7 +185,11 @@ export class EnvironmentStateWriteSimulation { : this.#input.views.get(tool.name); }, idField: (type) => this.#types.get(type)!.id_field, - observed: (ordinal) => this.#observations.add(ordinal), + observed: (ordinal) => + this.#observations.set( + ordinal, + (this.#observations.get(ordinal) ?? new Set()).add(writer), + ), unknown: (type, collection, id) => this.#taint.unknown(type, collection, id), initialDocument: (tool) => @@ -207,7 +216,7 @@ export class EnvironmentStateWriteSimulation { const observeOnly = draft.plan.reason !== null; if (!observeOnly) draft.calls += 1; const step: WriteStep = { record, index, tool, observeOnly }; - this.#settle(APPLY[effect](this.#context(), draft, step), { + this.#settle(APPLY[effect](this.#context(tool.name), draft, step), { index, tool: tool.name, path: record.path, @@ -235,7 +244,7 @@ export class EnvironmentStateWriteSimulation { result.entity.collection, result.entity.id, ); - if (result.kind === "applied" && result.entity !== undefined) + if (result.kind === "applied") this.#effects.supersede(this.#touched, write.tool, result); if (result.kind === "observed") this.#effects.observed(this.#touched, result.targets); @@ -292,8 +301,7 @@ export class EnvironmentStateWriteSimulation { /** Whether an item read is of an entity left unknown that it shows again rather than checks. */ #resynced(view: StateView, record: StateRecordFacts): boolean { if (view.shape !== "item") return false; - const collection = collectionPathOf(record, true); - const id = lastSegmentValue(record.path); + const { collection, id } = itemTarget(this.#state, view.type, record); if (!this.#taint.unknown(view.type, collection, id)) return false; const entity = this.#state.entity(view.type, collection, id); // A recorded absence of an entity the state no longer holds is still a check of its delete. @@ -315,7 +323,9 @@ export class EnvironmentStateWriteSimulation { checkDocumentRead(this.#state, view.tool, record, body), ); const item = view.shape === "item"; - const collection = item ? collectionPathOf(record, true) : line!.collection; + const collection = item + ? itemTarget(this.#state, view.type, record).collection + : line!.collection; if (this.#taint.under(collection)) return undefined; if (!item) { const ids = this.#state @@ -352,26 +362,29 @@ export class EnvironmentStateWriteSimulation { #read(record: StateRecordFacts, index: number, tool: StateToolFacts): void { const view = this.#input.views.get(tool.name); - if (view === undefined || this.#resynced(view, record)) return; + const borrowers = this.#observations.get(record.ordinal); + if (view === undefined || (!borrowers && this.#resynced(view, record))) + return; const outcome = this.#check(view, record); if (outcome === undefined) return; const keys = [...outcome.targets, ...outcome.unheld]; - // A read made while a write to its targets was still running may show either state, so it checks nothing. - const running = (key: string) => - (this.#touchedSince.get(key)?.until ?? -Infinity) > record.start; - if (keys.some((key) => this.#taint.tainted(key) || running(key))) return; - const exercised = new Set(); - for (const key of keys) - for (const name of this.#touched.get(key)?.keys() ?? []) - exercised.add(name); + if ( + keys.some( + (key) => + this.#taint.tainted(key) || + runningAt(this.#touchedSince, key, record.start), + ) + ) + return; + const exercised = exercisedBy(this.#touched, keys, borrowers); + if (borrowers !== undefined) { + this.#contradictOthers(outcome.pass, keys, exercised, record); + return; + } const pass = outcome.pass || (view.shape === "list" && this.#retryInsert(view, record, exercised)); - if (pass) - for (const name of exercised) { - this.#drafts.get(name)!.confirmed.push(record.ordinal); - if (view.shape === "list") this.#placed.add(name); - } + if (pass) this.#confirm(view, record, keys, exercised); else if (this.#interfered(keys)) { // An unmodelled write since these writes may explain the difference: check their own members only. if (view.shape === "item") this.#checkMembers(view, record); @@ -386,6 +399,36 @@ export class EnvironmentStateWriteSimulation { } } + /** A read a write's effect was learned from checks nothing of that write, yet still contradicts the others. */ + #contradictOthers( + pass: boolean, + keys: readonly string[], + exercised: ReadonlySet, + record: StateRecordFacts, + ): void { + if (pass || this.#interfered(keys)) return; + for (const name of exercised) + this.#drafts.get(name)!.contradictedBy ??= record.ordinal; + } + + /** A passing read confirms the writes it exercised that it shows something of. */ + #confirm( + view: StateView, + record: StateRecordFacts, + keys: readonly string[], + exercised: ReadonlySet, + ): void { + const shown = shownFields(view, this.#input.volatileOf); + for (const name of exercised) { + const shows = (key: string) => + this.#touched.get(key)?.has(name) === true && + this.#effects.shows(name, key, shown); + if (!keys.some(shows)) continue; + this.#drafts.get(name)!.confirmed.push(record.ordinal); + if (view.shape === "list") this.#placed.add(name); + } + } + #unmodelled(path: string, index: number): void { this.#taint.write(path); this.#unmodelledAt.push(index); @@ -404,11 +447,8 @@ export class EnvironmentStateWriteSimulation { view: Extract, record: StateRecordFacts, ): void { - const entity = this.#state.entity( - view.type, - collectionPathOf(record, true), - lastSegmentValue(record.path), - ); + const { collection, id } = itemTarget(this.#state, view.type, record); + const entity = this.#state.entity(view.type, collection, id); if (entity === undefined) return; const writes = this.#touched.get( @@ -433,8 +473,7 @@ export class EnvironmentStateWriteSimulation { view: Extract, record: StateRecordFacts, ): void { - const collection = collectionPathOf(record, true); - const id = lastSegmentValue(record.path); + const { collection, id } = itemTarget(this.#state, view.type, record); const entity = this.#state.entity(view.type, collection, id); if (entity === undefined) { this.#taint.fail(entityTarget(view.type, collection, id)); @@ -513,43 +552,4 @@ export class EnvironmentStateWriteSimulation { } return false; } - - /** Whether an update or delete that answers with the entity can answer for an initial entity: one holds every field its recorded response shows. */ - #renders(plan: StateWritePlan): boolean { - const { response } = plan; - if ( - (plan.effect !== "update" && plan.effect !== "delete") || - response?.shape !== "entity" - ) - return true; - const shown = this.#input.io.withBody(response.ordinal, (body) => { - const object = - body === undefined ? undefined : at(body, response.pointer ?? ""); - return isObject(object) ? Object.keys(object) : undefined; - }); - return ( - shown === undefined || - this.#input.entities.some( - (entity) => - entity.type === plan.target && - shown.every((field) => Object.hasOwn(entity.fields, field)), - ) - ); - } - - #verdict(draft: WriteDraft): StateWritePlan { - const plan = draft.plan; - if (plan.reason === null) { - if (draft.calls === 0) plan.reason = "no-call"; - else if (draft.contradictedBy !== undefined) { - plan.reason = "contradicted"; - plan.contradicted_by = draft.contradictedBy; - } else if (draft.sideEffect) plan.reason = "side-effect"; - else if (draft.confirmed.length === 0) plan.reason = "unconfirmed"; - else if (!this.#renders(plan)) plan.reason = "incomplete-entities"; - } - plan.served = plan.reason === null; - plan.checked_record_ordinals = [...new Set(draft.confirmed)]; - return { ...plan }; - } } diff --git a/src/domain/analysisView/environmentStateRules.ts b/src/domain/analysisView/environmentStateRules.ts index cb0a05bc5d..1d7b01287b 100644 --- a/src/domain/analysisView/environmentStateRules.ts +++ b/src/domain/analysisView/environmentStateRules.ts @@ -1,5 +1,8 @@ import { canonicalJson } from "../comparisonSemantics.js"; -import { parseUrlQueryParameterNames } from "../recordedTransportUrl.js"; +import { + iterateUrlQueryParameters, + parseUrlQueryParameterNames, +} from "../recordedTransportUrl.js"; import type { JsonValue } from "../jsonValue.js"; import type { StateWritePlan } from "./environmentStateWriteRules.js"; import { ENTRY_BYTES, textBytes } from "./webNetworkRouteTemplates.js"; @@ -166,6 +169,25 @@ export const pagesOnly = (query: string | undefined): boolean => PAGING_PARAMETERS.has(name.toLowerCase()), ); +/** Paging parameters that bound how many items a page holds. */ +const PAGE_SIZE_PARAMETERS = new Set([ + "limit", + "per_page", + "perpage", + "page_size", + "pagesize", + "size", +]); + +/** The most items a query's page holds: its smallest whole-number page-size value, or undefined without one. */ +export const pageSize = (query: string | undefined): number | undefined => { + let size: number | undefined; + for (const [name, value] of iterateUrlQueryParameters(query ?? "")) + if (PAGE_SIZE_PARAMETERS.has(name.toLowerCase()) && /^\d+$/u.test(value)) + size = Math.min(size ?? Infinity, Number(value)); + return size; +}; + /** Identity field names tried, in order, when no path parameter proves one. */ export const IDENTITY_NAMES = [ "id", diff --git a/src/domain/analysisView/environmentStateWriteAttribution.ts b/src/domain/analysisView/environmentStateWriteAttribution.ts index 02c0f140b0..5774a2d372 100644 --- a/src/domain/analysisView/environmentStateWriteAttribution.ts +++ b/src/domain/analysisView/environmentStateWriteAttribution.ts @@ -1,9 +1,41 @@ +import type { StateView } from "./environmentStateRules.js"; + +/** The fields a read of a view shows of its entities, less volatile ones it does not compare; all of a document's. */ +export const shownFields = ( + view: StateView, + volatileOf: (type: string) => ReadonlySet, +): ReadonlySet | "all" => { + if (view.shape !== "item" && view.shape !== "list") return "all"; + const volatile = volatileOf(view.type); + return new Set(view.fields.filter((field) => !volatile.has(field))); +}; + /** Modelled writes per target since its last check, with the fields each wrote (null: all). */ export type TouchedTargets = Map>; /** Per target, the earliest modelled write since its last check and when the latest of them ended. */ export type PendingTargets = Map; +/** Whether a modelled write to the target was still running when a read began at `start`, so the read may show either state. */ +export const runningAt = ( + pending: PendingTargets, + key: string, + start: number, +): boolean => (pending.get(key)?.until ?? -Infinity) > start; + +/** The tools whose writes a read exercises: those that changed its targets since they were last checked, less those that borrowed the read to learn their effect. */ +export const exercisedBy = ( + touched: TouchedTargets, + keys: readonly string[], + borrowers: ReadonlySet | undefined, +): Set => { + const exercised = new Set(); + for (const key of keys) + for (const name of touched.get(key)?.keys() ?? []) + if (!borrowers?.has(name)) exercised.add(name); + return exercised; +}; + /** Record a modelled write on a target: the fields it wrote (undefined: all), when it began and when it ended. */ export const touchTarget = ( touched: TouchedTargets, @@ -90,6 +122,23 @@ export class WriteEffects { this.#members.delete(key); } + /** Whether a read showing `fields` of a target shows anything of a tool's effect there: a create's or delete's always, an update's through a field it changed; unknown claims count as shown. */ + shows( + tool: string, + key: string, + fields: ReadonlySet | "all", + ): boolean { + const visible = (claim: ChangedFields | undefined): boolean => + fields === "all" || + claim === undefined || + claim === null || + claim.some((field) => fields.has(field)); + const own = this.#changed.get(key); + if (own !== undefined) return visible(own.get(tool)); + const members = this.#members.get(key)?.get(tool); + return members === undefined || [...members.values()].some(visible); + } + /** A write's effect replaces what earlier writes left of theirs, then joins them. */ supersede( touched: TouchedTargets, diff --git a/src/domain/analysisView/environmentStateWriteChecks.ts b/src/domain/analysisView/environmentStateWriteChecks.ts index 6679a163f5..f0b6cf3bad 100644 --- a/src/domain/analysisView/environmentStateWriteChecks.ts +++ b/src/domain/analysisView/environmentStateWriteChecks.ts @@ -14,6 +14,7 @@ import { listItems, missingRequiredFields, pagesOnly, + pageSize, renderStateEntity, renderStateList, replaceAt, @@ -30,12 +31,16 @@ import { type StateView, } from "./environmentStateRules.js"; -/** A query as replay compares a call's: decoded values by name, in any order; undefined when it repeats a name, which no call's arguments can carry. */ +/** A query as replay compares a call's: decoded values by name, in any order; undefined when it repeats a name with another value, which no call's arguments can carry. */ const queryKey = (query: string | undefined): string | undefined => { - const pairs = [...iterateUrlQueryParameters(query ?? "")]; - return new Set(pairs.map(([name]) => name)).size === pairs.length - ? compositeKey(pairs.sort(([left], [right]) => compare(left, right))) - : undefined; + const values = new Map(); + for (const [name, value] of iterateUrlQueryParameters(query ?? "")) { + if (values.has(name) && values.get(name) !== value) return undefined; + values.set(name, value); + } + return compositeKey( + [...values].sort(([left], [right]) => compare(left, right)), + ); }; interface CheckedLine { @@ -249,14 +254,44 @@ interface ReadBodies { } /** An item read: the answer the held entity renders into the served envelope must equal the recording; a recorded 404 or 410 agrees with none held. */ +/** + * The entity an item read names, resolved as serving resolves an item call: its collection and id as written, else a + * held collection whose path matches once percent-decoded and an id tried decoded, then as written. + */ +export const itemTarget = ( + state: SimulatedState, + type: string, + record: Pick, +): { readonly collection: string; readonly id: string } => { + const collection = collectionPathOf(record, true); + const id = lastSegmentValue(record.path); + if (state.entity(type, collection, id) !== undefined) + return { collection, id }; + const read = segmentsOf(collection); + const ids = [decodedSegment(id), id]; + for (const candidate of ids) + for (const entity of state.ofType(type)) { + const held = segmentsOf(entity.collection); + if ( + entity.id === candidate && + held.length === read.length && + held.every( + (segment, index) => + segment === read[index] || decodedSegment(segment) === read[index], + ) + ) + return { collection: entity.collection, id: entity.id }; + } + return { collection, id }; +}; + export const checkItemRead = ( state: SimulatedState, view: StateItemView, record: StateRecordFacts, check: ReadBodies, ): CheckOutcome | undefined => { - const collection = collectionPathOf(record, true); - const id = lastSegmentValue(record.path); + const { collection, id } = itemTarget(state, view.type, record); const entity = state.entity(view.type, collection, id); const targets = [entityTarget(view.type, collection, id)]; if (isAbsence(record.status) || (succeeded(record) && check.body === null)) @@ -369,6 +404,14 @@ export const checkListRead = ( check: ReadBodies & { readonly idField: string; readonly collection: string }, ): CheckOutcome | undefined => { if (!succeeded(record)) return undefined; + // Serving answers a page too small for the collection from its recording, so such a read checks nothing. + const size = pageSize(record.query); + if ( + view.membership === "all" && + size !== undefined && + state.members(view.type, check.collection).length > size + ) + return undefined; const { body, base } = check; const baseItems = base === undefined ? undefined : listItems(base, view.pointer); diff --git a/src/domain/analysisView/environmentStateWriteEffects.ts b/src/domain/analysisView/environmentStateWriteEffects.ts index d670d45061..c9b846f7c7 100644 --- a/src/domain/analysisView/environmentStateWriteEffects.ts +++ b/src/domain/analysisView/environmentStateWriteEffects.ts @@ -28,6 +28,7 @@ import { type SimulatedState, } from "./environmentStateWriteChecks.js"; import { + conflictsWithPlan, documentPointerOf, explainUpdate, responseShapeOf, @@ -251,26 +252,6 @@ const refineUpdate = ( ]; }; -/** Whether a later call treats a member or field otherwise than the plan: echoed in one and stored as something else in the other, or ruled differently. */ -const conflictsWithPlan = ( - plan: WriteDraft["plan"], - explained: ReturnType, -): boolean => { - const body = plan.body!; - const fixed = new Set(body.fixed.map((entry) => entry.member)); - const rules = new Map(plan.rules.map((rule) => [rule.field, rule])); - return ( - explained.body.accepted.some((member) => fixed.has(member)) || - explained.body.fixed.some((entry) => - body.accepted.includes(entry.member), - ) || - explained.rules.some((rule) => { - const held = rules.get(rule.field); - return held !== undefined && !same(held, rule); - }) - ); -}; - /** Whether a rule changes its field on calls that do not send it: serving applies it to every call that could. */ const unsentChange = (rule: FieldRule): boolean => rule.rule === "increment" || rule.rule === "slug"; @@ -561,5 +542,10 @@ export const applyDocument = ( })); context.io.retain(64 + JSON.stringify(written.value).length * 2); context.state.setDocument(tool, written.value); - return { kind: "applied", targets: [documentTarget(tool)] }; + // A merge writes the members it sent; a replacement writes the whole document. + return { + kind: "applied", + targets: [documentTarget(tool)], + ...(draft.plan.merge === "merge" ? { fields: Object.keys(body) } : {}), + }; }; diff --git a/src/domain/analysisView/environmentStateWriteExplain.ts b/src/domain/analysisView/environmentStateWriteExplain.ts index 8132b69ca7..ff426dd63a 100644 --- a/src/domain/analysisView/environmentStateWriteExplain.ts +++ b/src/domain/analysisView/environmentStateWriteExplain.ts @@ -11,6 +11,7 @@ import { import type { FieldRule, InsertRule, + StateWritePlan, WriteBody, WriteCall, WriteResponse, @@ -435,3 +436,23 @@ export const documentPointerOf = ( } return best?.pointer; }; + +/** Whether a later call explains a member or field otherwise than the plan: echoed by one and stored as something else by the other, or ruled differently. */ +export const conflictsWithPlan = ( + plan: Pick, + explained: ReturnType, +): boolean => { + const body = plan.body!; + const fixed = new Set(body.fixed.map((entry) => entry.member)); + const rules = new Map(plan.rules.map((rule) => [rule.field, rule])); + return ( + explained.body.accepted.some((member) => fixed.has(member)) || + explained.body.fixed.some((entry) => + body.accepted.includes(entry.member), + ) || + explained.rules.some((rule) => { + const held = rules.get(rule.field); + return held !== undefined && !same(held, rule); + }) + ); +}; diff --git a/src/domain/analysisView/environmentStateWriteVerdict.ts b/src/domain/analysisView/environmentStateWriteVerdict.ts new file mode 100644 index 0000000000..ef0bc86c22 --- /dev/null +++ b/src/domain/analysisView/environmentStateWriteVerdict.ts @@ -0,0 +1,49 @@ +import { at, isObject, type StateEntity } from "./environmentStateRules.js"; +import type { WriteDraft, WriteIo } from "./environmentStateWriteEffects.js"; +import type { StateWritePlan } from "./environmentStateWriteRules.js"; + +/** Whether an update or delete that answers with the entity can answer for an initial entity: one holds every field its recorded response shows. */ +const renders = ( + plan: StateWritePlan, + input: { readonly io: WriteIo; readonly entities: readonly StateEntity[] }, +): boolean => { + const { response } = plan; + if ( + (plan.effect !== "update" && plan.effect !== "delete") || + response?.shape !== "entity" + ) + return true; + const shown = input.io.withBody(response.ordinal, (body) => { + const object = + body === undefined ? undefined : at(body, response.pointer ?? ""); + return isObject(object) ? Object.keys(object) : undefined; + }); + return ( + shown === undefined || + input.entities.some( + (entity) => + entity.type === plan.target && + shown.every((field) => Object.hasOwn(entity.fields, field)), + ) + ); +}; + +/** A replayed write's plan, served only when it was called, nothing contradicted it, a read confirmed it and its answers can render. */ +export const writeVerdict = ( + draft: WriteDraft, + input: { readonly io: WriteIo; readonly entities: readonly StateEntity[] }, +): StateWritePlan => { + const plan = draft.plan; + if (plan.reason === null) { + if (draft.calls === 0) plan.reason = "no-call"; + else if (draft.contradictedBy !== undefined) { + plan.reason = "contradicted"; + plan.contradicted_by = draft.contradictedBy; + } else if (draft.sideEffect) plan.reason = "side-effect"; + else if (draft.confirmed.length === 0) plan.reason = "unconfirmed"; + else if (!renders(plan, input)) plan.reason = "incomplete-entities"; + } + plan.served = plan.reason === null; + plan.checked_record_ordinals = [...new Set(draft.confirmed)]; + return { ...plan }; +};