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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
63 changes: 62 additions & 1 deletion src/jsonAtom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,8 @@

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,
Expand Down Expand Up @@ -245,6 +246,66 @@
}
}

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 &&

Check warning on line 266 in src/jsonAtom.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=ltwlf_json-diff-ts&issues=AZ20STxA61ufHamCRHLo&open=AZ20STxA61ufHamCRHLo&pullRequest=407
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,
Expand Down
106 changes: 106 additions & 0 deletions tests/jsonAtom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,112 @@
});
});

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);
});

Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test covers multiple removes (#404), but it doesn’t cover the new ordering behavior’s interaction with type-change pairs inside $index arrays (which diff() represents as REMOVE+ADD at the same index when treatTypeChangeAsReplace is enabled). Consider adding a regression test where an element’s type changes at a given index (e.g. [1]→['x']) to ensure diffAtom/applyAtom still produces the correct final array.

Suggested change
it('applies same-index type changes correctly in $index arrays', () => {
const oldObj = { items: [1] };
const newObj = { items: ['x'] };
const atom = diffAtom(oldObj, newObj);
expect(atom.operations.some((op) => op.path === '$.items[0]')).toBe(true);
const applied = applyAtom(structuredClone(oldObj), atom);
expect(applied).toEqual(newObj);
});

Copilot uses AI. Check for mistakes.
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]));

Check warning on line 249 in tests/jsonAtom.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the "RegExp.exec()" method instead.

See more on https://sonarcloud.io/project/issues?id=ltwlf_json-diff-ts&issues=AZ20PeBtIVzJHEOEx3fm&open=AZ20PeBtIVzJHEOEx3fm&pullRequest=407

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]));

Check warning on line 265 in tests/jsonAtom.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the "RegExp.exec()" method instead.

See more on https://sonarcloud.io/project/issues?id=ltwlf_json-diff-ts&issues=AZ20PeBtIVzJHEOEx3fn&open=AZ20PeBtIVzJHEOEx3fn&pullRequest=407

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]))

Check warning on line 286 in tests/jsonAtom.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the "RegExp.exec()" method instead.

See more on https://sonarcloud.io/project/issues?id=ltwlf_json-diff-ts&issues=AZ20STuj61ufHamCRHLm&open=AZ20STuj61ufHamCRHLm&pullRequest=407
);
const pureRemoveIndices = atom.operations
.filter((op) => op.op === 'remove')
.map((op) => Number(op.path.match(/\[(\d+)\]$/)?.[1]))

Check warning on line 290 in tests/jsonAtom.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the "RegExp.exec()" method instead.

See more on https://sonarcloud.io/project/issues?id=ltwlf_json-diff-ts&issues=AZ20STuj61ufHamCRHLn&open=AZ20STuj61ufHamCRHLn&pullRequest=407
.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' }] },
Expand Down
Loading