diff --git a/src/domain/analysisView/EnvironmentStateWriteSimulation.test.ts b/src/domain/analysisView/EnvironmentStateWriteSimulation.test.ts index 1f7bbe2375..d388b52a35 100644 --- a/src/domain/analysisView/EnvironmentStateWriteSimulation.test.ts +++ b/src/domain/analysisView/EnvironmentStateWriteSimulation.test.ts @@ -277,6 +277,97 @@ describe("slug rules learned from a later call", () => { }); }); +describe("clock rules learned across calls", () => { + it("serves a clock only when every recorded call changed that field", () => { + const oldFirst = "2026-10-01T09:00:00Z"; + const oldSecond = "2026-10-01T10:00:00Z"; + const described = ( + entry: Book, + description: string, + publishedAt: string, + ): Book => ({ ...entry, description, published_at: publishedAt }); + const first = described(FIRST_BOOK, "original", oldFirst); + const second = described(SECOND_BOOK, "other", oldSecond); + const published = described( + renamedBook(FIRST_BOOK, "Renamed Book", 3), + "original", + bookClock(3), + ); + /** The first call stamps `published_at`; `later` is the second call's answer. */ + const rows = (later: Book, sent: Book): TimedCaptureRow[] => [ + ["GET", "/api/books", [first, second]], + ["GET", "/api/books/1", first], + ["GET", "/api/books/2", second], + ["PATCH", "/api/books/1", published, 200, { name: "Renamed Book" }], + ["GET", "/api/books/1", published], + ["PATCH", "/api/books/2", later, 200, sent], + ["GET", "/api/books/2", later], + ]; + // The description edit stamps `updated_at` and leaves `published_at` as it was. + const edited = described( + { ...SECOND_BOOK, updated_at: bookClock(5) }, + "revised", + oldSecond, + ); + expect( + gated(rows(edited, { description: "revised" }), "patch_books_by_id"), + ).toMatchObject({ served: false, reason: "unexplained-change" }); + const stamped = described( + renamedBook(SECOND_BOOK, "Second Edition", 5), + "other", + bookClock(5), + ); + const plan = gated( + rows(stamped, { name: "Second Edition" }), + "patch_books_by_id", + ); + expect(plan).toMatchObject({ + served: true, + checked_record_ordinals: [4, 6], + }); + expect(plan.rules).toContainEqual({ + field: "published_at", + rule: "clock", + format: { kind: "iso", separator: "T", fraction: 0, zone: "Z" }, + }); + }); + + it("leaves a clock unserved when an earlier call kept the field a later call stamped", () => { + const oldFirst = "2026-10-01T09:00:00Z"; + const oldSecond = "2026-10-01T10:00:00Z"; + const described = ( + entry: Book, + description: string, + publishedAt: string, + ): Book => ({ ...entry, description, published_at: publishedAt }); + const first = described(FIRST_BOOK, "original", oldFirst); + const second = described(SECOND_BOOK, "other", oldSecond); + const edited = described( + { ...SECOND_BOOK, updated_at: bookClock(3) }, + "revised", + oldSecond, + ); + const published = described( + renamedBook(FIRST_BOOK, "Renamed Book", 5), + "original", + bookClock(5), + ); + const rows: TimedCaptureRow[] = [ + ["GET", "/api/books", [first, second]], + ["GET", "/api/books/1", first], + ["GET", "/api/books/2", second], + ["PATCH", "/api/books/2", edited, 200, { description: "revised" }], + ["GET", "/api/books/2", edited], + ["PATCH", "/api/books/1", published, 200, { name: "Renamed Book" }], + ["GET", "/api/books/1", published], + ]; + expect(gated(rows, "patch_books_by_id")).toMatchObject({ + served: false, + reason: "unexplained-change", + }); + }); +}); + describe("checked lines", () => { /** A list page that echoes its own request path, as some APIs do. */ const page = (path: string, items: JsonValue[]): JsonValue => ({ @@ -1057,6 +1148,30 @@ describe("what a check leaves pending", () => { }); }); + it("learns an update from a read begun after the write, not from one that overlapped it", () => { + const named = (name: string) => ({ id: 1, name }); + const other = { id: 2, name: "B" }; + const rows: TimedCaptureRow[] = [ + ["GET", "/api/books", [named("A"), other]], + ["GET", "/api/books/1", named("A")], + ["GET", "/api/books/2", other], + // The patch runs from second 3 to 5.5 and returns no entity. A read begun while it still runs shows the old name. + ["PATCH", "/api/books/1", "", 204, { name: "Z" }, 2500], + ["GET", "/api/books/1", named("A")], + ["GET", "/api/books/1", named("A")], + ["GET", "/api/books/1", named("Z")], + ["GET", "/api/books", [named("Z"), other]], + ]; + expect(gated(rows, "patch_books_by_id")).toMatchObject({ + served: true, + observation: { ordinal: 6, pointer: "" }, + body: { accepted: ["name"], fixed: [] }, + rules: [{ field: "name", rule: "echo" }], + checked_record_ordinals: [7], + contradicted_by: null, + }); + }); + it("checks nothing by a range read the collection has outgrown", () => { const rows: TimedCaptureRow[] = [ ["GET", "/api/books?_start=0&_end=2", [FIRST_BOOK, SECOND_BOOK]], diff --git a/src/domain/analysisView/environmentStateWriteAttribution.ts b/src/domain/analysisView/environmentStateWriteAttribution.ts index 71c3970a1d..d46a2462d0 100644 --- a/src/domain/analysisView/environmentStateWriteAttribution.ts +++ b/src/domain/analysisView/environmentStateWriteAttribution.ts @@ -19,12 +19,16 @@ export type PendingTargets = Map< { until: number; readonly at: Map } >; +/** Whether a write that ended at `until` was still running when a read began at `start`, so the read may show either state. */ +export const overlapsWrite = (until: number, start: number): boolean => + until > start; + /** 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; +): boolean => overlapsWrite(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 = ( diff --git a/src/domain/analysisView/environmentStateWriteEffects.ts b/src/domain/analysisView/environmentStateWriteEffects.ts index ca3696f492..1c424d75f4 100644 --- a/src/domain/analysisView/environmentStateWriteEffects.ts +++ b/src/domain/analysisView/environmentStateWriteEffects.ts @@ -313,13 +313,15 @@ const contradictsEarlierCalls = ( const ruled = new Set(draft.plan.rules.map((rule) => rule.field)); return rules.some( (rule) => - unsentChange(rule) && + (unsentChange(rule) || rule.rule === "clock") && !ruled.has(rule.field) && draft.updates.some(({ shown, sent }) => { // An unruled field an earlier call showed is one it left as it was. const value = shown.get(rule.field); if (value === undefined) return false; if (rule.rule === "increment") return integerOf(value) !== undefined; + // Serving stamps a clock on every call, so a field an earlier call kept contradicts it. + if (rule.rule === "clock") return true; const from = rule.rule === "slug" ? sent.get(rule.from) : undefined; return typeof from === "string" && slugOf(from) !== value; }), diff --git a/src/domain/analysisView/environmentStateWriteObserve.ts b/src/domain/analysisView/environmentStateWriteObserve.ts index 9994d2192d..f9c4c4febe 100644 --- a/src/domain/analysisView/environmentStateWriteObserve.ts +++ b/src/domain/analysisView/environmentStateWriteObserve.ts @@ -1,4 +1,5 @@ import type { JsonValue } from "../jsonValue.js"; +import { overlapsWrite } from "./environmentStateWriteAttribution.js"; import { collectionPathOf, isObject, @@ -62,7 +63,7 @@ export const newEntityIn = ( export interface ObservationSearch { readonly records: readonly StateRecordFacts[]; readonly from: number; - /** A timed write's completion; reads begun before it cannot observe its effect. */ + /** A timed write's completion. A read begun while it still ran may show the prior state. */ readonly after?: number; readonly viewOf: (ordinal: number) => StateView | undefined; readonly withBody: ( @@ -77,6 +78,7 @@ const succeeded = (record: StateRecordFacts): boolean => /** * The first later read, before the next write, that shows a written entity: an item read of * `id` when given, else an item read of an unheld id or a collection read listing exactly one. + * A read begun while the write still ran may show the earlier state, so it is not chosen. */ export const laterObservation = ( search: ObservationSearch, @@ -93,7 +95,9 @@ export const laterObservation = ( ): Observed | undefined => { for (const record of search.records.slice(search.from + 1)) { if (target.isWrite(record)) return undefined; - if (search.after !== undefined && record.start < search.after) continue; + // The same overlap the check path ignores: the write was still running when this read began. + if (search.after !== undefined && overlapsWrite(search.after, record.start)) + continue; const view = search.viewOf(record.ordinal); if ( !succeeded(record) ||