From 280083c4e923f50e4a5113fa6f036773b31e022f Mon Sep 17 00:00:00 2001 From: Bob Massarczyk Date: Thu, 13 Aug 2026 14:40:35 +0200 Subject: [PATCH 1/2] docs: scope the reference-idempotence promise to what holds The Semantics list contradicted itself in consecutive lines: one bullet said replaced values count as unchanged only when they are the very same reference, the next promised that re-applying any valid delta returns the previous result by reference, so a store could drop duplicate frames with one equality check. Only the first is unconditional. Websocket reducers are the headline use case and they decode every frame afresh, and a replacement is unchanged only under Object.is. Ordinary JSON parsing satisfies that for primitives but allocates a fresh object or array every time, so a frame carrying an unkeyed array, a non-plain object or an object-valued `replace` subtree yields an equal result with a new reference. A `replace` path holding a string or a number costs nothing, which is why the line falls at what decoding allocates rather than at replacement. The claim also lived as the justification inside the law that appears to prove it, and as a constraint in AGENTS.md, which is where a future writer would go to restate it. All three now say the same thing, and a law pins both sides of the boundary through an unkeyed array and a configured replace path, so neither route can start comparing by value unnoticed. No behavior change. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 5 ++++- README.md | 2 +- test/laws.test.ts | 35 +++++++++++++++++++++++++++++++++-- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4a569c1..5a2e60d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,10 @@ published package and types. - **Structural sharing is observable.** Semantic no-ops return `base` by reference, and untouched sibling branches retain their references. - **The fold stays deterministic and idempotent.** Reapplying a valid delta - returns the previous result by reference. + returns the previous result by reference. A separately decoded copy is + reference-idempotent only when every replacement value is unchanged under + `Object.is`; ordinary JSON parsing allocates fresh object and array + replacements. Do not promise more than that. - **Replacement is a boundary.** Unkeyed arrays, non-plain objects, and paths configured with `replace` swap wholesale. Do not traverse, clone, sanitize, or interpret operators inside them. diff --git a/README.md b/README.md index e03c7ff..0bc0806 100644 --- a/README.md +++ b/README.md @@ -193,7 +193,7 @@ All configuration is validated when the merger is created: bad grammar, reserved - `__proto__`, `constructor`, and `prototype` keys in deltas are ignored wherever a delta object is folded, and never read. Inside a replaced value they are data like anything else. - The base is never mutated. A throw cannot leave a partial write behind. - A merge that changes nothing returns the base reference. Merged values count as unchanged when they are equal by value; replaced values count as unchanged only when they are the very same reference. -- Every valid delta is deterministic and idempotent: re-applying the same delta returns the previous result by reference, so a store can drop duplicate frames with one equality check. +- Every valid delta is deterministic and idempotent: re-applying the same delta object returns the previous result by reference. A separately decoded frame keeps that reference only when every replacement value is unchanged under `Object.is`. Ordinary JSON parsing satisfies that for primitives but allocates fresh objects and arrays, so a store can drop duplicate frames with one equality check only while none of them replace with an object or an array. Replaced values, unkeyed arrays, and non-plain objects are taken as-is, never scanned or sanitized; that is what keeps replacement cheap. A `DELETE` or `$delete` inside such a value is not an operator, just data the caller put there. diff --git a/test/laws.test.ts b/test/laws.test.ts index 575c8c8..62fcbef 100644 --- a/test/laws.test.ts +++ b/test/laws.test.ts @@ -128,8 +128,8 @@ describe("algebraic laws", () => { test("re-applying the same delta returns the same reference", () => { // Stronger than value-idempotence: the second application writes only // values that are already there, so nothing is copied and the previous - // result comes back by reference. A store can drop duplicate frames with - // one equality check. + // result comes back by reference. This applies the same delta object + // twice; the law below marks where a separately parsed copy stops. fc.assert( fc.property(baseArbitrary, deltaArbitrary, (base, delta) => { const once = merge(base, delta); @@ -139,6 +139,37 @@ describe("algebraic laws", () => { ); }); + test("a re-decoded frame keeps the reference only for Object.is-equal replacements", () => { + // Pins the documented limit rather than a desirable behavior. Merged + // values are judged by value, so re-decoding them costs nothing. A + // replacement is unchanged only under Object.is, which ordinary JSON + // parsing satisfies for primitives and never for objects or arrays. + const base: Shape = { meta: {}, items: [] }; + const reapply = (frame: string) => { + const parse = () => JSON.parse(frame) as Delta; + const once = merge(base, parse()); + return { once, again: merge(once, parse()) }; + }; + + const merged = reapply('{"title":"t","items":[{"id":"a"}]}'); + expect(merged.again).toBe(merged.once); + + // An unkeyed array and a configured replace path, so neither route to a + // replacement can start comparing by value without failing here. + for (const frame of ['{"tags":["x"]}', '{"meta":{"version":1}}']) { + const { once, again } = reapply(frame); + expect(again).not.toBe(once); + expect(again).toEqual(once); + } + + // A primitive replacement survives decoding, so the line falls at what + // parsing allocates rather than at replacement itself. + const mergePrimitive = createMerger<{ meta: string }>({ replace: ["meta"] }); + const parsePrimitive = () => JSON.parse('{"meta":"new"}') as Delta<{ meta: string }>; + const primitive = mergePrimitive({ meta: "old" }, parsePrimitive()); + expect(mergePrimitive(primitive, parsePrimitive())).toBe(primitive); + }); + test("is deterministic", () => { fc.assert( fc.property(baseArbitrary, deltaArbitrary, (base, delta) => { From 1ff0ce552be8d15e70c59ea4ecb6e1f28b4e7701 Mon Sep 17 00:00:00 2001 From: Bob Massarczyk Date: Thu, 13 Aug 2026 15:27:03 +0200 Subject: [PATCH 2/2] docs: state the equality premise the next bullet relies on The bullet above the reference-idempotence one said replaced values count as unchanged only when they are the very same reference. That is the consequence, not the rule: equality is Object.is, so an equal primitive at a replace path is unchanged without any shared reference. Both bullets now rest on the same premise. Found while sweeping the rest of the file for claims stronger than the code delivers, which is #14. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0bc0806..e97bffd 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ All configuration is validated when the merger is created: bad grammar, reserved - New keyed items are folded onto nothing, so they follow those same rules. A `replace` boundary or unkeyed array inside the item is taken verbatim. - `__proto__`, `constructor`, and `prototype` keys in deltas are ignored wherever a delta object is folded, and never read. Inside a replaced value they are data like anything else. - The base is never mutated. A throw cannot leave a partial write behind. -- A merge that changes nothing returns the base reference. Merged values count as unchanged when they are equal by value; replaced values count as unchanged only when they are the very same reference. +- A merge that changes nothing returns the base reference. Merged values count as unchanged when the recursive fold produces no change; wholesale replacements count as unchanged when `Object.is(base, delta)`, which for objects and arrays means the very same reference. - Every valid delta is deterministic and idempotent: re-applying the same delta object returns the previous result by reference. A separately decoded frame keeps that reference only when every replacement value is unchanged under `Object.is`. Ordinary JSON parsing satisfies that for primitives but allocates fresh objects and arrays, so a store can drop duplicate frames with one equality check only while none of them replace with an object or an array. Replaced values, unkeyed arrays, and non-plain objects are taken as-is, never scanned or sanitized; that is what keeps replacement cheap. A `DELETE` or `$delete` inside such a value is not an operator, just data the caller put there.