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..e97bffd 100644 --- a/README.md +++ b/README.md @@ -192,8 +192,8 @@ 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. -- 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. +- 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. 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) => {