Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
35 changes: 33 additions & 2 deletions test/laws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<Shape>;
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) => {
Expand Down