fix(environment): share write overlap and check clocks on earlier calls
Observation search skips a read begun before the write ended. That comparison now uses the same overlap as the check path. A clock learned on a later call was not checked against earlier calls that kept the field. Serving would stamp those calls too, so the plan stays unserved. A clock learned earlier is still refused when a later call does not advance it. Regression tests cover an overlapping empty update and a published_at clock that one recorded call leaves unchanged. Co-authored-by: morluto <morluto@users.noreply.github.com>
This commit is contained in:
4 files changed
+129
-4
No files matched your search
@@ -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]],
|
||||
|
||||
@@ -19,12 +19,16 @@ export type PendingTargets = Map<
|
||||
{ until: number; readonly at: Map<string, number> }
|
||||
>;
|
||||
|
||||
/** 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 = (
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
|
||||
@@ -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: <T>(
|
||||
@@ -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) ||
|
||||
|
||||
Reference in new issue
Block a user