diff --git a/ROADMAP.md b/ROADMAP.md index 88b86f3..1c9a827 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/docs/index.d.ts b/docs/index.d.ts index 3dafb73..156f849 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -314,6 +314,22 @@ export function diff( keyFn?: (item: T) => unknown ): DiffOperation[]; +/** + * 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(value: T, diff: JsonDiffOperation[]): JsonValue; + /** * Deep clone structured data. * Use for: immutability, snapshots, undo buffers. diff --git a/examples/jsonDiff.ts b/examples/jsonDiff.ts new file mode 100644 index 0000000..4aef8a6 --- /dev/null +++ b/examples/jsonDiff.ts @@ -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); diff --git a/src/data/jsonDiff.ts b/src/data/jsonDiff.ts new file mode 100644 index 0000000..e065357 --- /dev/null +++ b/src/data/jsonDiff.ts @@ -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(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); +} diff --git a/src/index.ts b/src/index.ts index 99dd041..6c94570 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/tests/jsonDiff.test.ts b/tests/jsonDiff.test.ts new file mode 100644 index 0000000..709e988 --- /dev/null +++ b/tests/jsonDiff.test.ts @@ -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); + }); +});