fix(environment): make no count of a made-object member earlier creates held alike

A count only a later create shows starts at the exemplar's value, which is
right only while the exemplar is the one create accepted: creates accepted
after it showed the member unchanged, which a count would have moved. The
merge now refuses such a count, as the top-level sequence check does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Edgar ArakelyanandClaude Opus 5.5 committed 2026-10-27 15:11:40 -07:00
1 parent 2362564cc4
commit 308aebf0eb
2 files changed
+60 -2

No files matched your search

@@ -215,3 +215,52 @@ describe("merging a later create into a plan", () => {
).toBeUndefined();
});
});
describe("merging a later create's made object", () => {
const made = (counts: { member: string; start: number }[]): FieldRule => ({
field: "subscription",
rule: "made-object",
clocks: ["created"],
format: { kind: "iso", separator: "T", fraction: 0, zone: "Z" },
own: ["entity_id"],
counts,
});
const body = {
accepted: ["title"],
fixed: [],
required: ["title"],
identity: null,
kinds: [],
};
const title: FieldRule = { field: "title", rule: "echo" };
const subscription = (id: number, task: number) => ({
id,
entity: "task",
entity_id: task,
created: "2026-10-26T10:00:00Z",
});
const plan = { rules: [title, made([])], body };
const exemplar = { title: "First", subscription: subscription(31, 20) };
const later = {
entity: { title: "Third", subscription: subscription(32, 22) },
body: { title: "Third" },
rules: [title, made([{ member: "id", start: 32 }])],
contract: body,
};
const earlier = (accepted: number) => ({
kept: new Set<string>(),
clockOf: () => undefined,
createCollections: Array.from({ length: accepted }, () => "/api/tasks"),
});
it("counts a member only the later create shows while the exemplar is the one create accepted", () => {
expect(
mergedCreatePlan(plan, exemplar, later, earlier(1))?.rules,
).toContainEqual(made([{ member: "id", start: 31 }]));
});
it("makes no count of a member creates past the exemplar held alike", () => {
// Creates of 31 and 31 then 32 are no count: serving would make 31, 32, 33.
expect(mergedCreatePlan(plan, exemplar, later, earlier(2))).toBeUndefined();
});
});
@@ -55,7 +55,12 @@ const agreeing = (
const alike = alikeRules(held, later, values.earlier.kept);
if (alike !== undefined) return alike;
if (held.rule === "made-object" && later.rule === "made-object")
return madeAlike(held, later, ownMember(values.exemplar, values.field));
return madeAlike(
held,
later,
ownMember(values.exemplar, values.field),
values.earlier,
);
const spelled = ampersandSpelled(
held,
{ rule: later, entity: values.laterEntity },
@@ -73,11 +78,12 @@ const agreeing = (
type MadeObject = Extract<FieldRule, { rule: "made-object" }>;
/** Two made-object rules as one: the same clocks, format and own members, and the earlier counts among the later's; a count only the later call shows starts at the exemplar's value, which no earlier object could show past. */
/** Two made-object rules as one: the same clocks, format and own members, and the earlier counts among the later's; a count only the later call shows starts at the exemplar's value, which no earlier object could show past, and only while the exemplar is the one create accepted, as earlier creates held it still. */
const madeAlike = (
held: MadeObject,
later: MadeObject,
exemplar: JsonValue | undefined,
earlier: EarlierEvidence,
): MadeObject | undefined => {
if (
!same(held.clocks, later.clocks) ||
@@ -91,6 +97,9 @@ const madeAlike = (
const counts: MadeObject["counts"] = [];
for (const { member } of later.counts) {
const kept = held.counts.find((count) => count.member === member);
// Accepted creates past the exemplar showed this member unchanged, which a count would have moved.
if (kept === undefined && (earlier.createCollections ?? []).length > 1)
return undefined;
const start =
kept?.start ??
sequenceNumber(