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..92fd948 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,66 @@ function walkChanges( } } +function orderArrayChildChanges(changes: IChange[], embeddedKey: string | FunctionKey): IChange[] { + if (embeddedKey !== '$index') { + return changes; + } + + type OrderedGroup = { kind: 'pure-remove' } | { kind: 'preserved'; changes: IChange[] }; + const groups: OrderedGroup[] = []; + const pureRemoves: IChange[] = []; + + for (let i = 0; i < changes.length; 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 (pureRemoves.length < 2) { + return changes; + } + + 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; + } + + pureRemoves.sort((a, b) => Number(b.key) - Number(a.key)); + + 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; +} + function emitLeafOp( change: IChange, path: string, diff --git a/tests/jsonAtom.test.ts b/tests/jsonAtom.test.ts index 5c8d59f..cd52cfb 100644 --- a/tests/jsonAtom.test.ts +++ b/tests/jsonAtom.test.ts @@ -203,6 +203,112 @@ 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('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('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('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' }] },