Skip to content

Commit abf5dac

Browse files
authored
Order index-based array removes to preserve correct application; add test and bump version (#407)
* chore: switch v5 prerelease back to alpha.9 * fix: only reorder remove slots for index-based array diffs * test: expand coverage for index-based remove ordering * fix: guard index-remove reordering with numeric-key check * fix: preserve remove+add pairs when reordering index removals * test: ignore defensive non-numeric index fallback for coverage * test: cover P1 index replace-pair ordering regression
1 parent 693af56 commit abf5dac

4 files changed

Lines changed: 171 additions & 4 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "json-diff-ts",
3-
"version": "5.0.0-alpha.8",
3+
"version": "5.0.0-alpha.9",
44
"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.",
55
"main": "./dist/index.cjs",
66
"module": "./dist/index.js",

src/jsonAtom.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,8 @@ function walkChanges(
213213

214214
if (change.embeddedKey) {
215215
// Array level — process each child with filter expression
216-
for (const childChange of change.changes) {
216+
const orderedChildChanges = orderArrayChildChanges(change.changes, change.embeddedKey);
217+
for (const childChange of orderedChildChanges) {
217218
const filterPath = buildCanonicalFilterPath(
218219
childPath,
219220
change.embeddedKey,
@@ -245,6 +246,66 @@ function walkChanges(
245246
}
246247
}
247248

249+
function orderArrayChildChanges(changes: IChange[], embeddedKey: string | FunctionKey): IChange[] {
250+
if (embeddedKey !== '$index') {
251+
return changes;
252+
}
253+
254+
type OrderedGroup = { kind: 'pure-remove' } | { kind: 'preserved'; changes: IChange[] };
255+
const groups: OrderedGroup[] = [];
256+
const pureRemoves: IChange[] = [];
257+
258+
for (let i = 0; i < changes.length; i++) {
259+
const current = changes[i];
260+
const next = changes[i + 1];
261+
262+
// Keep REMOVE+ADD type-change pairs together and in original order.
263+
if (
264+
current.type === Operation.REMOVE &&
265+
next &&
266+
next.type === Operation.ADD &&
267+
String(current.key) === String(next.key)
268+
) {
269+
groups.push({ kind: 'preserved', changes: [current, next] });
270+
i++;
271+
continue;
272+
}
273+
274+
if (current.type === Operation.REMOVE) {
275+
pureRemoves.push(current);
276+
groups.push({ kind: 'pure-remove' });
277+
continue;
278+
}
279+
280+
groups.push({ kind: 'preserved', changes: [current] });
281+
}
282+
283+
if (pureRemoves.length < 2) {
284+
return changes;
285+
}
286+
287+
const removeIndices = pureRemoves.map((change) => Number(change.key));
288+
/* istanbul ignore next -- $index keys are always integer-like from diff(); fallback is defensive */
289+
if (removeIndices.some((idx) => !Number.isInteger(idx))) {
290+
// Defensive fallback: if keys are not numeric, keep original order.
291+
return changes;
292+
}
293+
294+
pureRemoves.sort((a, b) => Number(b.key) - Number(a.key));
295+
296+
const ordered: IChange[] = [];
297+
let removeIndex = 0;
298+
for (const group of groups) {
299+
if (group.kind === 'pure-remove') {
300+
ordered.push(pureRemoves[removeIndex++]);
301+
} else {
302+
ordered.push(...group.changes);
303+
}
304+
}
305+
306+
return ordered;
307+
}
308+
248309
function emitLeafOp(
249310
change: IChange,
250311
path: string,

tests/jsonAtom.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,112 @@ describe('diffAtom', () => {
203203
});
204204
});
205205

206+
it('applies multiple index-based removes correctly without identity keys (#404)', () => {
207+
const oldObj = {
208+
bankAccounts: [
209+
{ iban: 'DE12345678901234567890', bic: 'BIC123456' },
210+
{ iban: 'DE23456789012345678901', bic: 'BIC234567' },
211+
{ iban: 'DE23456789012345678902', bic: 'BIC234567' },
212+
],
213+
};
214+
const newObj = {
215+
bankAccounts: [{ iban: 'DE11456789012345678999', bic: 'BIC123456' }],
216+
};
217+
218+
const atom = diffAtom(oldObj, newObj);
219+
expect(atom.operations).toEqual([
220+
{
221+
op: 'replace',
222+
path: '$.bankAccounts[0].iban',
223+
oldValue: 'DE12345678901234567890',
224+
value: 'DE11456789012345678999',
225+
},
226+
{
227+
op: 'remove',
228+
path: '$.bankAccounts[2]',
229+
oldValue: { iban: 'DE23456789012345678902', bic: 'BIC234567' },
230+
},
231+
{
232+
op: 'remove',
233+
path: '$.bankAccounts[1]',
234+
oldValue: { iban: 'DE23456789012345678901', bic: 'BIC234567' },
235+
},
236+
]);
237+
238+
const applied = applyAtom(structuredClone(oldObj), atom);
239+
expect(applied).toEqual(newObj);
240+
});
241+
242+
it('emits index-based remove operations in descending order for nested arrays', () => {
243+
const oldObj = { items: [1, 2, 3, 4] };
244+
const newObj = { items: [1] };
245+
246+
const atom = diffAtom(oldObj, newObj);
247+
const removeIndices = atom.operations
248+
.filter((op) => op.op === 'remove')
249+
.map((op) => Number(op.path.match(/\[(\d+)\]$/)?.[1]));
250+
251+
expect(removeIndices.length).toBeGreaterThanOrEqual(2);
252+
expect(removeIndices).toEqual([...removeIndices].sort((a, b) => b - a));
253+
254+
const applied = applyAtom(structuredClone(oldObj), atom);
255+
expect(applied).toEqual(newObj);
256+
});
257+
258+
it('keeps non-remove operations while sorting multiple index removes descending', () => {
259+
const oldObj = { items: ['a', 'b', 'c', 'd'] };
260+
const newObj = { items: ['z', 'b'] };
261+
262+
const atom = diffAtom(oldObj, newObj);
263+
const removeIndices = atom.operations
264+
.filter((op) => op.op === 'remove')
265+
.map((op) => Number(op.path.match(/\[(\d+)\]$/)?.[1]));
266+
267+
expect(atom.operations.some((op) => op.op === 'replace')).toBe(true);
268+
expect(removeIndices.length).toBeGreaterThanOrEqual(2);
269+
expect(removeIndices).toEqual([...removeIndices].sort((a, b) => b - a));
270+
271+
const applied = applyAtom(structuredClone(oldObj), atom);
272+
expect(applied).toEqual(newObj);
273+
});
274+
275+
it('keeps index type-change REMOVE+ADD pairs in order while still applying correctly', () => {
276+
const oldObj = { items: [1, 2, 3, 4] };
277+
const newObj = { items: ['x', 2] };
278+
279+
const atom = diffAtom(oldObj, newObj);
280+
expect(applyAtom(structuredClone(oldObj), atom)).toEqual(newObj);
281+
282+
// Ensure pure removes (excluding paired type-change REMOVE+ADD at same index) stay descending.
283+
const addIndices = new Set(
284+
atom.operations
285+
.filter((op) => op.op === 'add')
286+
.map((op) => Number(op.path.match(/\[(\d+)\]$/)?.[1]))
287+
);
288+
const pureRemoveIndices = atom.operations
289+
.filter((op) => op.op === 'remove')
290+
.map((op) => Number(op.path.match(/\[(\d+)\]$/)?.[1]))
291+
.filter((idx) => !addIndices.has(idx));
292+
293+
expect(pureRemoveIndices).toEqual([...pureRemoveIndices].sort((a, b) => b - a));
294+
});
295+
296+
it('preserves same-index REMOVE+ADD pairs for pure index type changes (P1 badge case)', () => {
297+
const oldObj = { a: [1, 2] };
298+
const newObj = { a: [[1], [2]] };
299+
300+
const atom = diffAtom(oldObj, newObj);
301+
const applied = applyAtom(structuredClone(oldObj), atom);
302+
303+
expect(applied).toEqual(newObj);
304+
expect(atom.operations).toEqual([
305+
{ op: 'remove', path: '$.a[0]', oldValue: 1 },
306+
{ op: 'add', path: '$.a[0]', value: [1] },
307+
{ op: 'remove', path: '$.a[1]', oldValue: 2 },
308+
{ op: 'add', path: '$.a[1]', value: [2] },
309+
]);
310+
});
311+
206312
it('handles arrays with named key (string IDs)', () => {
207313
const atom = diffAtom(
208314
{ items: [{ id: '1', name: 'Widget' }] },

0 commit comments

Comments
 (0)