fix(environment): keep the largest sequence value in replay, and store update clocks by the kept read

Replay now keeps the largest value a sequence took, as serving's commitWrite
does, so a lower sent value no longer lowers the next number.

Serving stores every update's clocks in the spelling of the read the plan
keeps as observation.read; replay now does the same through
readSpelledClocks, instead of adopting each update's own read, so a later
update whose read spells its clock otherwise contradicts the plan. The plain
clock branch of restampedFields, which no caller used any more, is gone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Edgar ArakelyanandClaude Opus 5.5 committed 2026-10-27 17:36:57 -07:00
1 parent 5691f97f25
commit 3b800a1076
7 files changed
+128 -44

No files matched your search

@@ -28,15 +28,17 @@ the application breaks ties in. The skill now spaces creates.
- **Update members.** A later update narrows the plan's required members to those every call sent.
- **Numbered unless sent.** A member one create sent and stored as sent, and another left out and the application
numbered one past the others, is a `sequence` with `sent`, in either order: serving stores the value a call sends,
else the next number, and a later call that sends it must have stored what it sent.
else the next number, and a later call that sends it must have stored what it sent. The next number follows the
largest value any call took, a deleted entity's included, in replay as in serving.
- **Cleared by a PUT.** A field a `PUT` left out that the application cleared to an empty value (`""`, `null`, `false`,
`[]` or `{}`) is that member's default (`body.defaults`) when every call that left it out stored that value, as its
answer shows it or, where the answer leaves the member out, the read after it: serving stores it for an update that
leaves the member out. Calls that left it out and stored it otherwise withhold the plan.
- **Update clocks.** The state holds an update's clock as the first read of the entity after it spells the same instant
at a coarser precision in the same zone and separator, as it already does for nested clocks: replay and serving store
that spelling by one rule, serving from the read the plan keeps as `observation.read`, while answers keep the update's
own. A create's clocks stay as answered, as serving makes them.
every update's clocks in the spelling of the read the plan keeps as `observation.read`, so a later update whose read
spells them otherwise contradicts the plan, while answers keep the update's own. A create's clocks stay as answered,
as serving makes them.
- **Skill.** Recordings leave more than a second between two creates, as between a create and its first edit.
Plans and views that need none of this are unchanged.
+7 -5
View File
@@ -800,7 +800,8 @@ required, and an echoed member a call left out takes the value that call stored
(`body.defaults`). A member one call sent and stored as sent, and another left
out and the application numbered one past the others, takes the value a call
sends, else that number (a `sequence` with `sent`), whichever call came first;
a later call that sends it must have stored what it sent. A member a create sent empty (`""`, `null`, `false`, `[]` or
a later call that sends it must have stored what it sent, and the next number
follows the largest value any call took, a deleted entity's included. A member a create sent empty (`""`, `null`, `false`, `[]` or
`{}`), as a form sends a field left as it was, is not required: a call that
leaves it out is applied as sending that value (`body.blank`), unless a call
that left it out showed a default, which it then keeps. A call may send it as any
@@ -874,10 +875,11 @@ with the query its first call sent, which serving applies to a call that
leaves a parameter out and the catalog advertises as required. Every member every call sent is required, except by `PATCH` and
`POST` updates and members a create sent empty. The state holds an update's
clock as the first read of the entity after it spells the same instant at a
coarser precision in the same zone and separator: replay and serving store that
spelling by one rule, serving from the read the plan keeps as
`observation.read`, while answers keep the update's own; a create's clocks stay
as answered. A `PUT` default must agree with what every call that left the
coarser precision in the same zone and separator: replay and serving store every
update's clocks in the spelling of the read the plan keeps as
`observation.read`, so a later update whose read spells them otherwise
contradicts the plan, while answers keep the update's own; a create's clocks
stay as answered. A `PUT` default must agree with what every call that left the
member out stored, as its answer shows it or, where the answer leaves it out,
the read after the call.
@@ -7,11 +7,13 @@ import {
import {
CLEARED_THEN_OMITTED_KEPT_ROWS,
DONE_CLOCK_ROWS,
DONE_MIXED_ROWS,
DONE_ZONE_ROWS,
OMITTED_KEPT_THEN_CLEARED_ROWS,
FEWER_MEMBERS_ROWS,
NUMBERED_UNLESS_SENT_ROWS,
NUMBERED_THEN_SENT_ROWS,
SENT_THEN_DELETED_ROWS,
KEPT_THEN_CLEARED_ROWS,
CLEARED_THEN_KEPT_ROWS,
PUT_CLEARS_ROWS,
@@ -163,3 +165,19 @@ describe("writes whose calls send different members", () => {
});
});
});
describe("writes replay holds as serving does", () => {
it("serves no create numbered below a sent value it deleted, as serving counts past it", () => {
// Serving keeps the largest order sent, 9, and would number the last create 10.
expect(planOf(SENT_THEN_DELETED_ROWS, "post_bundles")).toMatchObject({
served: false,
});
});
it("serves no update whose read spells its clock otherwise than the read the plan keeps", () => {
// Serving stores every update's clock as the first read spells it, to the millisecond.
expect(planOf(DONE_MIXED_ROWS, "patch_tasks_by_id")).toMatchObject({
served: false,
});
});
});
@@ -716,27 +716,35 @@ const fitClocks = (draft: WriteDraft, fields: JsonObject): void => {
noteFixedWidths(draft.fixedWidth, fields);
};
/** Record the values a create's sequences took, which the next call follows. */
/** Record the values a create's sequences took, which the next call follows; as serving does, the largest stays, so a lower sent value does not lower it. */
const takeSequences = (
draft: WriteDraft,
created: Pick<StateEntity, "collection" | "fields">,
): void => {
for (const { rule, key } of sequencesOf(draft, created.collection)) {
const value = sequenceNumber(
ownMember(created.fields, rule.field),
ownMember(draft.observed, rule.field),
const take = (key: string, value: number | undefined) => {
if (value === undefined) return;
const taken = draft.sequences.get(key);
draft.sequences.set(
key,
taken === undefined ? value : Math.max(taken, value),
);
};
for (const { rule, key } of sequencesOf(draft, created.collection))
take(
key,
sequenceNumber(
ownMember(created.fields, rule.field),
ownMember(draft.observed, rule.field),
),
);
if (value !== undefined) draft.sequences.set(key, value);
}
for (const rule of draft.plan.rules) {
if (rule.rule !== "made-object") continue;
const made = ownMember(created.fields, rule.field);
for (const { member } of rule.counts) {
const value = isObject(made)
? sequenceNumber(ownMember(made, member))
: undefined;
if (value !== undefined)
draft.sequences.set(madeKey(rule.field, member), value);
take(
madeKey(rule.field, member),
isObject(made) ? sequenceNumber(ownMember(made, member)) : undefined,
);
}
}
};
@@ -977,7 +985,6 @@ export const applyCreate = (
itemReads(context, { type, idField, collection, id }),
fields.value,
draft.plan.rules,
false,
);
const inserted = insertCreated(context, draft, entity(stored, id));
context.io.retain(collection.length * 2 + 16);
@@ -51,11 +51,13 @@ import {
shownBefore,
viewFields,
type Observed,
type ObservationSearch,
type ObservedRead,
} from "./environmentStateWriteObserve.js";
import {
plannedBody,
readSpellsCoarser,
readSpelledClocks,
restampedFields,
} from "./environmentStateWriteStamps.js";
import { readClock } from "./environmentStateWriteValues.js";
@@ -526,6 +528,18 @@ const spelledReadBack = (
: undefined;
};
/** The object the plan's kept read-back shows, which serving spells every update's clocks by. */
const keptRead = (
search: ObservationSearch,
read: ObservedRead | undefined,
): JsonObject | undefined =>
read === undefined
? undefined
: search.withBody(read.ordinal, (body) => {
const object = body === undefined ? undefined : at(body, read.pointer);
return isObject(object) ? object : undefined;
});
/** Build an update plan from its exemplar, or add what a later call showed. */
const buildUpdate = (
context: EffectContext,
@@ -753,13 +767,17 @@ export const updateEntity = (
{ before: entity.fields, after: after.object, sent: body, kept, read },
explained.unstamped,
);
// Response reproducibility is established before the read owner adopts a stored clock spelling.
const stored = restampedFields(
searchFrom(context, step),
itemReads(context, { type, idField, collection, id }),
written.value,
// Response reproducibility is established before the read owner adopts a stored clock spelling; serving spells every update's clocks as the plan's kept read-back does, so replay does too.
const search = searchFrom(context, step);
const stored = readSpelledClocks(
draft.plan.rules,
true,
restampedFields(
search,
itemReads(context, { type, idField, collection, id }),
written.value,
draft.plan.rules,
),
keptRead(search, draft.plan.observation?.read),
);
context.io.retain(
64 +
@@ -11,7 +11,6 @@ import {
} from "./environmentStateRules.js";
import {
laterObservation,
respelledInstant,
type ObservationSearch,
} from "./environmentStateWriteObserve.js";
import { singleQueryValues } from "../recordedTransportUrl.js";
@@ -705,8 +704,7 @@ export const pinnedEchoes = (
};
/**
* A write's fields as stored: each nested clock as the first later read of the entity shows it restamped, and, for an
* update, each clock as that read spells the same instant at a coarser precision (as serving stores it), else as the
* A write's fields as stored: each nested clock as the first later read of the entity shows it restamped, else as the
* call showed it.
*/
export const restampedFields = (
@@ -714,26 +712,13 @@ export const restampedFields = (
target: Parameters<typeof laterObservation>[1],
shown: JsonObject,
rules: readonly FieldRule[],
update: boolean,
): JsonObject => {
const nested = rules.flatMap((rule) =>
rule.rule === "nested-clock" ? [rule] : [],
);
// Serving respells only an update's clocks, so a create keeps them as answered.
const clocks = update
? rules.flatMap((rule) => (rule.rule === "clock" ? [rule.field] : []))
: [];
if (nested.length === 0 && clocks.length === 0) return shown;
if (nested.length === 0) return shown;
const later = laterObservation(search, target);
const fields = { ...shown };
for (const field of clocks) {
const read = ownMember(later?.object, field);
if (
respelledInstant(ownMember(shown, field), read) &&
readSpellsCoarser(ownMember(shown, field), read)
)
setOwnMember(fields, field, read!);
}
for (const rule of nested) {
const read = storedRestamp(
ownMember(shown, rule.field),
+52
View File
@@ -98,6 +98,58 @@ export const DONE_CLOCK_ROWS: readonly TimedCaptureRow[] = (() => {
];
})();
/** Tasks marked done, each answer to the nanosecond: the first read spells `done_at` to the millisecond, the second to the second. */
export const DONE_MIXED_ROWS: readonly TimedCaptureRow[] = DONE_CLOCK_ROWS.map(
(row, index) =>
index === 4 && row[0] === "GET"
? [
row[0],
row[1],
{
...(row[2] as Entity),
done_at: String((row[2] as Entity)["done_at"]).replace(
"Z",
".001Z",
),
},
]
: row,
);
/** Bundles numbered unless an order is sent: 2 left out, 9 and 4 sent, the 9 deleted, then one left out the application numbers 5. */
export const SENT_THEN_DELETED_ROWS: readonly TimedCaptureRow[] = (() => {
const bundle = (id: number, name: string, order: number): Entity => ({
id,
name,
order,
});
const seeds = [bundle(1, "Alpha", 0), bundle(2, "Bravo", 1)];
const [charlie, delta, echo, foxtrot] = [
bundle(3, "Charlie", 2),
bundle(4, "Delta", 9),
bundle(5, "Echo", 4),
bundle(6, "Foxtrot", 5),
];
return [
["GET", "/api/bundles", seeds],
["GET", "/api/bundles/1", seeds[0]!],
["POST", "/api/bundles", charlie, 201, { name: "Charlie" }],
["GET", "/api/bundles/3", charlie],
["GET", "/api/bundles", [...seeds, charlie]],
["POST", "/api/bundles", delta, 201, { name: "Delta", order: 9 }],
["GET", "/api/bundles/4", delta],
["GET", "/api/bundles", [...seeds, charlie, delta]],
["POST", "/api/bundles", echo, 201, { name: "Echo", order: 4 }],
["GET", "/api/bundles/5", echo],
["GET", "/api/bundles", [...seeds, charlie, delta, echo]],
["DELETE", "/api/bundles/4", null, 204],
["GET", "/api/bundles", [...seeds, charlie, echo]],
["POST", "/api/bundles", foxtrot, 201, { name: "Foxtrot" }],
["GET", "/api/bundles/6", foxtrot],
["GET", "/api/bundles", [...seeds, charlie, echo, foxtrot]],
];
})();
/** Tasks marked done: each answer stamps `done_at` to the millisecond in `Z`, the read after it to the second in `+00:00`. */
export const DONE_ZONE_ROWS: readonly TimedCaptureRow[] = (() => {
const task = (id: number, title: string): Entity => ({