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
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
## Milestone 0.3.0 – Web Performance & Data Pipelines
- [x] Introduce request deduplication helper
- [x] Ship virtual scrolling utilities
- [ ] Add diff/patch helpers for nested JSON structures
- [x] Add diff/patch helpers for nested JSON structures
- [ ] Create benchmarking scripts to compare algorithm variants
- [ ] Expand CI to include coverage gating and bundle size checks

Expand Down
16 changes: 16 additions & 0 deletions docs/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,22 @@ export function diff<T>(
keyFn?: (item: T) => unknown
): DiffOperation<T>[];

/**
* Nested JSON diff/patch helpers.
* Use for: syncing application state, sending incremental updates, audit logging.
* Performance: O(n) relative to traversed keys.
* Import: data/jsonDiff.ts
*/
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
export type JsonPathSegment = string | number;
export type JsonDiffOperation =
| { op: 'add'; path: JsonPathSegment[]; value: JsonValue }
| { op: 'remove'; path: JsonPathSegment[] }
| { op: 'replace'; path: JsonPathSegment[]; value: JsonValue };
export function diffJson(previous: JsonValue, next: JsonValue): JsonDiffOperation[];
export function applyJsonDiff<T extends JsonValue>(value: T, diff: JsonDiffOperation[]): JsonValue;

/**
* Deep clone structured data.
* Use for: immutability, snapshots, undo buffers.
Expand Down
10 changes: 10 additions & 0 deletions examples/jsonDiff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { applyJsonDiff, diffJson } from '../src/index.js';

const previous = { status: 'idle', jobs: ['ingest', 'transform'] };
const next = { status: 'running', jobs: ['ingest', 'transform', 'export'] };

const patch = diffJson(previous, next);
const updated = applyJsonDiff(previous, patch);

console.log(patch);
console.log(updated);
244 changes: 244 additions & 0 deletions src/data/jsonDiff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import { deepClone } from './deepClone.js';

export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonValue[] | JsonObject;
export type JsonObject = { [key: string]: JsonValue };
export type JsonPathSegment = string | number;

export type JsonDiffOperation =
| { op: 'add'; path: JsonPathSegment[]; value: JsonValue }
| { op: 'remove'; path: JsonPathSegment[] }
| { op: 'replace'; path: JsonPathSegment[]; value: JsonValue };

/**
* Computes a structural diff between two JSON-compatible values.
* Useful for: syncing cached state, generating patches, change feeds.
*/
export function diffJson(previous: JsonValue, next: JsonValue): JsonDiffOperation[] {
const operations: JsonDiffOperation[] = [];
walkDiff(previous, next, [], operations);
return operations;
}

/**
* Applies a JSON diff to a value and returns a new structure.
* Useful for: reconstructing snapshots, applying remote patches, optimistic updates.
*/
export function applyJsonDiff<T extends JsonValue>(value: T, diff: JsonDiffOperation[]): JsonValue {
let result: JsonValue = deepClone(value);
for (const operation of diff) {
result = applyOperation(result, operation);
}
return result;
}

function walkDiff(
previous: JsonValue,
next: JsonValue,
path: JsonPathSegment[],
operations: JsonDiffOperation[]
): void {
if (Object.is(previous, next)) {
return;
}

if (Array.isArray(previous) && Array.isArray(next)) {
diffArray(previous, next, path, operations);
return;
}

if (isPlainObject(previous) && isPlainObject(next)) {
diffObject(previous, next, path, operations);
return;
}

operations.push({ op: 'replace', path, value: deepClone(next) });
}

function diffArray(
previous: JsonValue[],
next: JsonValue[],
path: JsonPathSegment[],
operations: JsonDiffOperation[]
): void {
const minLength = Math.min(previous.length, next.length);

for (let index = 0; index < minLength; index += 1) {
const prevValue = previous[index];
const nextValue = next[index];
walkDiff(prevValue, nextValue, [...path, index], operations);
}

for (let index = previous.length - 1; index >= next.length; index -= 1) {
operations.push({ op: 'remove', path: [...path, index] });
}

for (let index = minLength; index < next.length; index += 1) {
operations.push({ op: 'add', path: [...path, index], value: deepClone(next[index]) });
}
}

function diffObject(
previous: JsonObject,
next: JsonObject,
path: JsonPathSegment[],
operations: JsonDiffOperation[]
): void {
const previousKeys = new Set(Object.keys(previous));
const nextKeys = new Set(Object.keys(next));

for (const key of nextKeys) {
if (previousKeys.has(key)) {
walkDiff(previous[key], next[key], [...path, key], operations);
previousKeys.delete(key);
} else {
operations.push({ op: 'add', path: [...path, key], value: deepClone(next[key]) });
}
}

for (const key of previousKeys) {
operations.push({ op: 'remove', path: [...path, key] });
}
}

function applyOperation(root: JsonValue, operation: JsonDiffOperation): JsonValue {
if (operation.path.length === 0) {
if (operation.op === 'remove') {
return null;
}
return deepClone(operation.value);
}

const parentPath = operation.path.slice(0, -1);
const key = operation.path[operation.path.length - 1];
if (key === undefined) {
throw new Error('Operation path is invalid.');
}
const parent = resolveParent(root, parentPath, operation.op === 'add');

if (parent === null || typeof parent !== 'object') {
throw new Error(`Cannot apply patch at path ${JSON.stringify(operation.path)}`);
}

if (Array.isArray(parent)) {
return applyArrayOperation(root, parent, key, operation);
}

if (!isJsonObject(parent)) {
throw new Error(`Cannot apply patch at path ${JSON.stringify(operation.path)}`);
}

return applyObjectOperation(root, parent, key, operation);
}

function applyArrayOperation(
root: JsonValue,
parent: JsonValue[],
key: JsonPathSegment,
operation: JsonDiffOperation
): JsonValue {
if (typeof key !== 'number') {
throw new Error(`Array path segment must be a number. Received ${String(key)}`);
}

switch (operation.op) {
case 'add':
parent.splice(Math.min(key, parent.length), 0, deepClone(operation.value));
break;
case 'replace':
if (key < 0 || key >= parent.length) {
throw new Error(`Cannot replace index ${key} on array of length ${parent.length}`);
}
parent[key] = deepClone(operation.value);
break;
case 'remove':
if (key < 0 || key >= parent.length) {
throw new Error(`Cannot remove index ${key} on array of length ${parent.length}`);
}
parent.splice(key, 1);
break;
default:
throw new Error(`Unsupported operation ${(operation as JsonDiffOperation).op}`);
}

return root;
}

function applyObjectOperation(
root: JsonValue,
parent: JsonObject,
key: JsonPathSegment,
operation: JsonDiffOperation
): JsonValue {
if (typeof key !== 'string') {
throw new Error(`Object path segment must be a string. Received ${String(key)}`);
}

switch (operation.op) {
case 'add':
case 'replace':
parent[key] = deepClone(operation.value);
break;
case 'remove':
delete parent[key];
break;
default:
throw new Error(`Unsupported operation ${(operation as JsonDiffOperation).op}`);
}

return root;
}

function resolveParent(value: JsonValue, path: JsonPathSegment[], allowCreate: boolean): JsonValue | null {
if (path.length === 0) {
return value;
}

let cursor: JsonValue = value;
for (let i = 0; i < path.length; i += 1) {
const segment = path[i];
const isLast = i === path.length - 1;
if (Array.isArray(cursor)) {
if (typeof segment !== 'number' || segment < 0 || segment >= cursor.length) {
throw new Error(`Invalid array path segment ${String(segment)}`);
}
cursor = cursor[segment];
continue;
}

if (cursor === null || typeof cursor !== 'object') {
throw new Error(`Cannot traverse into non-object segment ${JSON.stringify(segment)}`);
}

if (typeof segment !== 'string') {
throw new Error(`Object path segment must be a string. Received ${String(segment)}`);
}

if (!isJsonObject(cursor)) {
throw new Error(`Cannot traverse into non-object segment ${JSON.stringify(segment)}`);
}
const record = cursor;
if (!(segment in record)) {
if (!allowCreate) {
throw new Error(`Missing path segment ${String(segment)}`);
}
const nextSegment = isLast ? undefined : path[i + 1];
const newValue: JsonValue = typeof nextSegment === 'number' ? [] : {};
record[segment] = newValue;
}
cursor = record[segment];
}
return cursor;
}

function isPlainObject(value: unknown): value is { [key: string]: JsonValue } {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return false;
}
const proto = Reflect.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}

function isJsonObject(value: JsonValue): value is JsonObject {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ export { levenshteinDistance } from './search/levenshtein.js';
export { diff } from './data/diff.js';
export { deepClone } from './data/deepClone.js';
export { groupBy } from './data/groupBy.js';
export { diffJson, applyJsonDiff } from './data/jsonDiff.js';
export type {
JsonDiffOperation,
JsonPathSegment,
JsonPrimitive,
JsonValue,
} from './data/jsonDiff.js';

export { graphBFS, graphDFS, topologicalSort } from './graph/traversal.js';

Expand Down
60 changes: 60 additions & 0 deletions tests/jsonDiff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import { applyJsonDiff, diffJson } from '../src/data/jsonDiff.js';

describe('diffJson', () => {
it('produces replace operations for primitive changes at root', () => {
const diff = diffJson(1, 2);
expect(diff).toEqual([{ op: 'replace', path: [], value: 2 }]);
const patched = applyJsonDiff(1, diff);
expect(patched).toBe(2);
});

it('diffs nested objects and arrays', () => {
const previous = {
user: { name: 'Ada', tags: ['ml', 'ai'] },
count: 1,
};
const next = {
user: { name: 'Grace', tags: ['ml', 'ai', 'cs'] },
count: 2,
active: true,
};

const diff = diffJson(previous, next);
expect(diff).toContainEqual({ op: 'replace', path: ['user', 'name'], value: 'Grace' });
expect(diff).toContainEqual({ op: 'add', path: ['user', 'tags', 2], value: 'cs' });
expect(diff).toContainEqual({ op: 'replace', path: ['count'], value: 2 });
expect(diff).toContainEqual({ op: 'add', path: ['active'], value: true });

const patched = applyJsonDiff(previous, diff);
expect(patched).toEqual(next);
expect(patched).not.toBe(previous);
});

it('handles removals and replacements inside arrays and objects', () => {
const previous = {
items: [1, 2, 3],
settings: { theme: 'light', extras: true },
};
const next = {
items: [1, 3],
settings: { theme: 'dark' },
};

const diff = diffJson(previous, next);
expect(diff).toContainEqual({ op: 'replace', path: ['items', 1], value: 3 });
expect(diff).toContainEqual({ op: 'remove', path: ['items', 2] });
expect(diff).toContainEqual({ op: 'replace', path: ['settings', 'theme'], value: 'dark' });
expect(diff).toContainEqual({ op: 'remove', path: ['settings', 'extras'] });

const patched = applyJsonDiff(previous, diff);
expect(patched).toEqual(next);
});

it('returns empty diff for equal structures', () => {
const value = { nested: ['a', { n: 1 }] };
const diff = diffJson(value, { nested: ['a', { n: 1 }] });
expect(diff).toEqual([]);
expect(applyJsonDiff(value, diff)).toEqual(value);
});
});