From d5de1f452524743d02d756d15d48dbc75b3cddfd Mon Sep 17 00:00:00 2001 From: Christian Glessner Date: Wed, 22 Apr 2026 08:57:47 +0200 Subject: [PATCH 1/7] chore: switch v5 prerelease back to alpha.9 --- package-lock.json | 4 ++-- package.json | 2 +- src/jsonAtom.ts | 13 ++++++++++++- tests/jsonAtom.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index d62b89a..976717a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "json-diff-ts", - "version": "5.0.0-alpha.8", + "version": "5.0.0-alpha.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "json-diff-ts", - "version": "5.0.0-alpha.8", + "version": "5.0.0-alpha.9", "license": "MIT", "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/package.json b/package.json index 75f856a..8e63118 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "json-diff-ts", - "version": "5.0.0-alpha.8", + "version": "5.0.0-alpha.9", "description": "Modern TypeScript JSON diff library - Zero dependencies, high performance, ESM + CommonJS support. Calculate and apply differences between JSON objects with advanced features like key-based array diffing, JSONPath support, and atomic changesets.", "main": "./dist/index.cjs", "module": "./dist/index.js", diff --git a/src/jsonAtom.ts b/src/jsonAtom.ts index f33a8b2..bb33a8c 100644 --- a/src/jsonAtom.ts +++ b/src/jsonAtom.ts @@ -213,7 +213,8 @@ function walkChanges( if (change.embeddedKey) { // Array level — process each child with filter expression - for (const childChange of change.changes) { + const orderedChildChanges = orderArrayChildChanges(change.changes, change.embeddedKey); + for (const childChange of orderedChildChanges) { const filterPath = buildCanonicalFilterPath( childPath, change.embeddedKey, @@ -245,6 +246,16 @@ function walkChanges( } } +function orderArrayChildChanges(changes: IChange[], embeddedKey: string | FunctionKey): IChange[] { + if (embeddedKey !== '$index') { + return changes; + } + + const removes = changes.filter((c) => c.type === Operation.REMOVE).sort((a, b) => Number(b.key) - Number(a.key)); + const rest = changes.filter((c) => c.type !== Operation.REMOVE); + return [...rest, ...removes]; +} + function emitLeafOp( change: IChange, path: string, diff --git a/tests/jsonAtom.test.ts b/tests/jsonAtom.test.ts index 5c8d59f..fd2f3fc 100644 --- a/tests/jsonAtom.test.ts +++ b/tests/jsonAtom.test.ts @@ -203,6 +203,42 @@ describe('diffAtom', () => { }); }); + it('applies multiple index-based removes correctly without identity keys (#404)', () => { + const oldObj = { + bankAccounts: [ + { iban: 'DE12345678901234567890', bic: 'BIC123456' }, + { iban: 'DE23456789012345678901', bic: 'BIC234567' }, + { iban: 'DE23456789012345678902', bic: 'BIC234567' }, + ], + }; + const newObj = { + bankAccounts: [{ iban: 'DE11456789012345678999', bic: 'BIC123456' }], + }; + + const atom = diffAtom(oldObj, newObj); + expect(atom.operations).toEqual([ + { + op: 'replace', + path: '$.bankAccounts[0].iban', + oldValue: 'DE12345678901234567890', + value: 'DE11456789012345678999', + }, + { + op: 'remove', + path: '$.bankAccounts[2]', + oldValue: { iban: 'DE23456789012345678902', bic: 'BIC234567' }, + }, + { + op: 'remove', + path: '$.bankAccounts[1]', + oldValue: { iban: 'DE23456789012345678901', bic: 'BIC234567' }, + }, + ]); + + const applied = applyAtom(structuredClone(oldObj), atom); + expect(applied).toEqual(newObj); + }); + it('handles arrays with named key (string IDs)', () => { const atom = diffAtom( { items: [{ id: '1', name: 'Widget' }] }, From 6af987acf4f08b4a9d24763bddd51cb46865dd05 Mon Sep 17 00:00:00 2001 From: Christian Glessner Date: Wed, 22 Apr 2026 10:10:08 +0200 Subject: [PATCH 2/7] fix: only reorder remove slots for index-based array diffs --- src/jsonAtom.ts | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/jsonAtom.ts b/src/jsonAtom.ts index bb33a8c..d3f7ddc 100644 --- a/src/jsonAtom.ts +++ b/src/jsonAtom.ts @@ -251,9 +251,27 @@ function orderArrayChildChanges(changes: IChange[], embeddedKey: string | Functi return changes; } - const removes = changes.filter((c) => c.type === Operation.REMOVE).sort((a, b) => Number(b.key) - Number(a.key)); - const rest = changes.filter((c) => c.type !== Operation.REMOVE); - return [...rest, ...removes]; + const removeSlots: number[] = []; + const removes: IChange[] = []; + + for (let i = 0; i < changes.length; i++) { + if (changes[i].type === Operation.REMOVE) { + removeSlots.push(i); + removes.push(changes[i]); + } + } + + if (removes.length < 2) { + return changes; + } + + removes.sort((a, b) => Number(b.key) - Number(a.key)); + + const ordered = [...changes]; + for (let i = 0; i < removeSlots.length; i++) { + ordered[removeSlots[i]] = removes[i]; + } + return ordered; } function emitLeafOp( From ebd2f8a28270f8597d5e396a66f7218bef747a12 Mon Sep 17 00:00:00 2001 From: Christian Glessner Date: Wed, 22 Apr 2026 10:10:12 +0200 Subject: [PATCH 3/7] test: expand coverage for index-based remove ordering --- tests/jsonAtom.test.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/jsonAtom.test.ts b/tests/jsonAtom.test.ts index fd2f3fc..3087629 100644 --- a/tests/jsonAtom.test.ts +++ b/tests/jsonAtom.test.ts @@ -239,6 +239,39 @@ describe('diffAtom', () => { expect(applied).toEqual(newObj); }); + it('emits index-based remove operations in descending order for nested arrays', () => { + const oldObj = { items: [1, 2, 3, 4] }; + const newObj = { items: [1] }; + + const atom = diffAtom(oldObj, newObj); + const removeIndices = atom.operations + .filter((op) => op.op === 'remove') + .map((op) => Number(op.path.match(/\[(\d+)\]$/)?.[1])); + + expect(removeIndices.length).toBeGreaterThanOrEqual(2); + expect(removeIndices).toEqual([...removeIndices].sort((a, b) => b - a)); + + const applied = applyAtom(structuredClone(oldObj), atom); + expect(applied).toEqual(newObj); + }); + + it('keeps non-remove operations while sorting multiple index removes descending', () => { + const oldObj = { items: ['a', 'b', 'c', 'd'] }; + const newObj = { items: ['z', 'b'] }; + + const atom = diffAtom(oldObj, newObj); + const removeIndices = atom.operations + .filter((op) => op.op === 'remove') + .map((op) => Number(op.path.match(/\[(\d+)\]$/)?.[1])); + + expect(atom.operations.some((op) => op.op === 'replace')).toBe(true); + expect(removeIndices.length).toBeGreaterThanOrEqual(2); + expect(removeIndices).toEqual([...removeIndices].sort((a, b) => b - a)); + + const applied = applyAtom(structuredClone(oldObj), atom); + expect(applied).toEqual(newObj); + }); + it('handles arrays with named key (string IDs)', () => { const atom = diffAtom( { items: [{ id: '1', name: 'Widget' }] }, From 31178d2dabd82af3fcd4f2f45720cca7cdfe73e9 Mon Sep 17 00:00:00 2001 From: Christian Glessner Date: Wed, 22 Apr 2026 10:10:17 +0200 Subject: [PATCH 4/7] fix: guard index-remove reordering with numeric-key check --- src/jsonAtom.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/jsonAtom.ts b/src/jsonAtom.ts index d3f7ddc..abbb99f 100644 --- a/src/jsonAtom.ts +++ b/src/jsonAtom.ts @@ -265,6 +265,12 @@ function orderArrayChildChanges(changes: IChange[], embeddedKey: string | Functi return changes; } + const removeIndices = removes.map((change) => Number(change.key)); + if (removeIndices.some((idx) => !Number.isInteger(idx))) { + // Defensive fallback: if keys are not numeric, keep original order. + return changes; + } + removes.sort((a, b) => Number(b.key) - Number(a.key)); const ordered = [...changes]; From 0077a39176cd5191739545c1bed0c3c43e9086ed Mon Sep 17 00:00:00 2001 From: Christian Glessner Date: Wed, 22 Apr 2026 10:22:46 +0200 Subject: [PATCH 5/7] fix: preserve remove+add pairs when reordering index removals --- src/jsonAtom.ts | 47 ++++++++++++++++++++++++++++++++---------- tests/jsonAtom.test.ts | 21 +++++++++++++++++++ 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/src/jsonAtom.ts b/src/jsonAtom.ts index abbb99f..3344d37 100644 --- a/src/jsonAtom.ts +++ b/src/jsonAtom.ts @@ -251,32 +251,57 @@ function orderArrayChildChanges(changes: IChange[], embeddedKey: string | Functi return changes; } - const removeSlots: number[] = []; - const removes: IChange[] = []; + type OrderedGroup = { kind: 'pure-remove' } | { kind: 'preserved'; changes: IChange[] }; + const groups: OrderedGroup[] = []; + const pureRemoves: IChange[] = []; for (let i = 0; i < changes.length; i++) { - if (changes[i].type === Operation.REMOVE) { - removeSlots.push(i); - removes.push(changes[i]); + const current = changes[i]; + const next = changes[i + 1]; + + // Keep REMOVE+ADD type-change pairs together and in original order. + if ( + current.type === Operation.REMOVE && + next && + next.type === Operation.ADD && + String(current.key) === String(next.key) + ) { + groups.push({ kind: 'preserved', changes: [current, next] }); + i++; + continue; + } + + if (current.type === Operation.REMOVE) { + pureRemoves.push(current); + groups.push({ kind: 'pure-remove' }); + continue; } + + groups.push({ kind: 'preserved', changes: [current] }); } - if (removes.length < 2) { + if (pureRemoves.length < 2) { return changes; } - const removeIndices = removes.map((change) => Number(change.key)); + const removeIndices = pureRemoves.map((change) => Number(change.key)); if (removeIndices.some((idx) => !Number.isInteger(idx))) { // Defensive fallback: if keys are not numeric, keep original order. return changes; } - removes.sort((a, b) => Number(b.key) - Number(a.key)); + pureRemoves.sort((a, b) => Number(b.key) - Number(a.key)); - const ordered = [...changes]; - for (let i = 0; i < removeSlots.length; i++) { - ordered[removeSlots[i]] = removes[i]; + const ordered: IChange[] = []; + let removeIndex = 0; + for (const group of groups) { + if (group.kind === 'pure-remove') { + ordered.push(pureRemoves[removeIndex++]); + } else { + ordered.push(...group.changes); + } } + return ordered; } diff --git a/tests/jsonAtom.test.ts b/tests/jsonAtom.test.ts index 3087629..8fd5148 100644 --- a/tests/jsonAtom.test.ts +++ b/tests/jsonAtom.test.ts @@ -272,6 +272,27 @@ describe('diffAtom', () => { expect(applied).toEqual(newObj); }); + it('keeps index type-change REMOVE+ADD pairs in order while still applying correctly', () => { + const oldObj = { items: [1, 2, 3, 4] }; + const newObj = { items: ['x', 2] }; + + const atom = diffAtom(oldObj, newObj); + expect(applyAtom(structuredClone(oldObj), atom)).toEqual(newObj); + + // Ensure pure removes (excluding paired type-change REMOVE+ADD at same index) stay descending. + const addIndices = new Set( + atom.operations + .filter((op) => op.op === 'add') + .map((op) => Number(op.path.match(/\[(\d+)\]$/)?.[1])) + ); + const pureRemoveIndices = atom.operations + .filter((op) => op.op === 'remove') + .map((op) => Number(op.path.match(/\[(\d+)\]$/)?.[1])) + .filter((idx) => !addIndices.has(idx)); + + expect(pureRemoveIndices).toEqual([...pureRemoveIndices].sort((a, b) => b - a)); + }); + it('handles arrays with named key (string IDs)', () => { const atom = diffAtom( { items: [{ id: '1', name: 'Widget' }] }, From 386f7e27b89af60909e62523d7a80f29dec04bb0 Mon Sep 17 00:00:00 2001 From: Christian Glessner Date: Wed, 22 Apr 2026 10:26:41 +0200 Subject: [PATCH 6/7] test: ignore defensive non-numeric index fallback for coverage --- src/jsonAtom.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/jsonAtom.ts b/src/jsonAtom.ts index 3344d37..92fd948 100644 --- a/src/jsonAtom.ts +++ b/src/jsonAtom.ts @@ -285,6 +285,7 @@ function orderArrayChildChanges(changes: IChange[], embeddedKey: string | Functi } const removeIndices = pureRemoves.map((change) => Number(change.key)); + /* istanbul ignore next -- $index keys are always integer-like from diff(); fallback is defensive */ if (removeIndices.some((idx) => !Number.isInteger(idx))) { // Defensive fallback: if keys are not numeric, keep original order. return changes; From 03b09cd6dfb4741bd19675765fb9e29c82e2ac28 Mon Sep 17 00:00:00 2001 From: Christian Glessner Date: Wed, 22 Apr 2026 10:40:03 +0200 Subject: [PATCH 7/7] test: cover P1 index replace-pair ordering regression --- tests/jsonAtom.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/jsonAtom.test.ts b/tests/jsonAtom.test.ts index 8fd5148..cd52cfb 100644 --- a/tests/jsonAtom.test.ts +++ b/tests/jsonAtom.test.ts @@ -293,6 +293,22 @@ describe('diffAtom', () => { expect(pureRemoveIndices).toEqual([...pureRemoveIndices].sort((a, b) => b - a)); }); + it('preserves same-index REMOVE+ADD pairs for pure index type changes (P1 badge case)', () => { + const oldObj = { a: [1, 2] }; + const newObj = { a: [[1], [2]] }; + + const atom = diffAtom(oldObj, newObj); + const applied = applyAtom(structuredClone(oldObj), atom); + + expect(applied).toEqual(newObj); + expect(atom.operations).toEqual([ + { op: 'remove', path: '$.a[0]', oldValue: 1 }, + { op: 'add', path: '$.a[0]', value: [1] }, + { op: 'remove', path: '$.a[1]', oldValue: 2 }, + { op: 'add', path: '$.a[1]', value: [2] }, + ]); + }); + it('handles arrays with named key (string IDs)', () => { const atom = diffAtom( { items: [{ id: '1', name: 'Widget' }] },