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:
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.
|
||||
|
||||
@@ -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
@@ -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 => ({
|
||||
|
||||
Reference in new issue
Block a user