diff --git a/README.md b/README.md index 9c841ac..640d9e5 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,15 @@ const { valid, errors } = validateDelta(maybeDelta); | `validateDelta` | `(delta) => { valid, errors }` | Structural validation | | `toDelta` | `(changeset, options?) => IJsonDelta` | Bridge: v4 changeset to JSON Delta | | `fromDelta` | `(delta) => IAtomicChange[]` | Bridge: JSON Delta to v4 atomic changes | +| `squashDeltas` | `(source, deltas, options?) => IJsonDelta` | Compact multiple deltas into one net-effect delta | +| `deltaMap` | `(delta, fn) => IJsonDelta` | Transform each operation in a delta | +| `deltaStamp` | `(delta, extensions) => IJsonDelta` | Set extension properties on all operations | +| `deltaGroupBy` | `(delta, keyFn) => Record` | Group operations into sub-deltas | +| `operationSpecDict` | `(op) => IDeltaOperation` | Strip extension properties from operation | +| `operationExtensions` | `(op) => Record` | Get extension properties from operation | +| `deltaSpecDict` | `(delta) => IJsonDelta` | Strip all extensions from delta | +| `deltaExtensions` | `(delta) => Record` | Get envelope extensions from delta | +| `leafProperty` | `(op) => string \| null` | Terminal property name from operation path | ### DeltaOptions @@ -251,6 +260,125 @@ interface DeltaOptions extends Options { } ``` +### Delta Workflow Helpers + +Transform, inspect, and compact deltas for workflow automation. + +#### `squashDeltas` -- Compact Multiple Deltas + +Combine a sequence of deltas into a single net-effect delta. Useful for compacting audit logs or collapsing undo history: + +```typescript +import { diffDelta, applyDelta, squashDeltas } from 'json-diff-ts'; + +const source = { name: 'Alice', role: 'viewer' }; +const d1 = diffDelta(source, { name: 'Bob', role: 'viewer' }); +const d2 = diffDelta({ name: 'Bob', role: 'viewer' }, { name: 'Bob', role: 'admin' }); + +const squashed = squashDeltas(source, [d1, d2]); +// squashed.operations => [ +// { op: 'replace', path: '$.name', value: 'Bob', oldValue: 'Alice' }, +// { op: 'replace', path: '$.role', value: 'admin', oldValue: 'viewer' } +// ] + +// Verify: applying the squashed delta equals applying both sequentially +const result = applyDelta(structuredClone(source), squashed); +// result => { name: 'Bob', role: 'admin' } +``` + +Options: `reversible`, `arrayIdentityKeys`, `target` (pre-computed final state), `verifyTarget` (default: true). + +#### `deltaMap` / `deltaStamp` / `deltaGroupBy` -- Delta Transformations + +All transforms are immutable — they return new deltas without modifying the original: + +```typescript +import { diffDelta, deltaMap, deltaStamp, deltaGroupBy } from 'json-diff-ts'; + +const delta = diffDelta( + { name: 'Alice', age: 30, role: 'viewer' }, + { name: 'Bob', age: 31, status: 'active' } +); + +// Stamp metadata onto every operation +const stamped = deltaStamp(delta, { x_author: 'system', x_ts: Date.now() }); + +// Transform operations +const prefixed = deltaMap(delta, (op) => ({ + ...op, + path: op.path.replace('$', '$.data'), +})); + +// Group by operation type +const groups = deltaGroupBy(delta, (op) => op.op); +// groups => { replace: IJsonDelta, add: IJsonDelta, remove: IJsonDelta } +``` + +#### `operationSpecDict` / `deltaSpecDict` -- Spec Introspection + +Separate spec-defined fields from extension properties: + +```typescript +import { operationSpecDict, operationExtensions, deltaSpecDict } from 'json-diff-ts'; + +const op = { op: 'replace', path: '$.name', value: 'Bob', x_author: 'system' }; +operationSpecDict(op); // { op: 'replace', path: '$.name', value: 'Bob' } +operationExtensions(op); // { x_author: 'system' } + +// Strip all extensions from a delta +const clean = deltaSpecDict(delta); +``` + +#### `leafProperty` -- Path Introspection + +Extract the terminal property name from an operation's path: + +```typescript +import { leafProperty } from 'json-diff-ts'; + +leafProperty({ op: 'replace', path: '$.user.name' }); // 'name' +leafProperty({ op: 'add', path: '$.items[?(@.id==1)]' }); // null (filter) +leafProperty({ op: 'replace', path: '$' }); // null (root) +``` + +--- + +## Comparison Serialization + +Serialize enriched comparison trees to plain objects or flat change lists. + +```typescript +import { compare, comparisonToDict, comparisonToFlatList } from 'json-diff-ts'; + +const result = compare( + { name: 'Alice', age: 30, role: 'viewer' }, + { name: 'Bob', age: 30, status: 'active' } +); + +// Recursive plain object +const dict = comparisonToDict(result); +// { +// type: 'CONTAINER', +// value: { +// name: { type: 'UPDATE', value: 'Bob', oldValue: 'Alice' }, +// age: { type: 'UNCHANGED', value: 30 }, +// role: { type: 'REMOVE', oldValue: 'viewer' }, +// status: { type: 'ADD', value: 'active' } +// } +// } + +// Flat list of leaf changes with paths +const flat = comparisonToFlatList(result); +// [ +// { path: '$.name', type: 'UPDATE', value: 'Bob', oldValue: 'Alice' }, +// { path: '$.role', type: 'REMOVE', oldValue: 'viewer' }, +// { path: '$.status', type: 'ADD', value: 'active' } +// ] + +// Include unchanged entries +const all = comparisonToFlatList(result, { includeUnchanged: true }); +``` + --- ## Practical Examples @@ -511,6 +639,8 @@ diff(old, new, { treatTypeChangeAsReplace: false }); | --- | --- | | `compare(oldObj, newObj)` | Create enriched comparison object | | `enrich(obj)` | Create enriched representation | +| `comparisonToDict(node)` | Serialize comparison tree to plain object | +| `comparisonToFlatList(node, options?)` | Flatten comparison to leaf change list | ### Options @@ -569,6 +699,11 @@ Use `$value` as the identity key: `{ arrayIdentityKeys: { tags: '$value' } }`. E ## Release Notes +- **v5.0.0-alpha.2:** + - Delta workflow helpers: `squashDeltas`, `deltaMap`, `deltaStamp`, `deltaGroupBy` + - Delta/operation introspection: `operationSpecDict`, `operationExtensions`, `deltaSpecDict`, `deltaExtensions`, `leafProperty` + - Comparison serialization: `comparisonToDict`, `comparisonToFlatList` + - **v5.0.0-alpha.0:** - JSON Delta API: `diffDelta`, `applyDelta`, `revertDelta`, `invertDelta`, `toDelta`, `fromDelta`, `validateDelta` - Canonical path production with typed filter literals diff --git a/package.json b/package.json index ad1285a..9c9f361 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "json-diff-ts", - "version": "5.0.0-alpha.1", + "version": "5.0.0-alpha.2", "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/deltaHelpers.ts b/src/deltaHelpers.ts new file mode 100644 index 0000000..7edde83 --- /dev/null +++ b/src/deltaHelpers.ts @@ -0,0 +1,200 @@ +import { parseDeltaPath } from './deltaPath.js'; +import { diffDelta, applyDelta } from './jsonDelta.js'; +import type { IDeltaOperation, IJsonDelta, DeltaOptions } from './jsonDelta.js'; + +// ─── Constants ───────────────────────────────────────────────────────────── + +const OP_SPEC_KEYS = new Set(['op', 'path', 'value', 'oldValue']); +const DELTA_SPEC_KEYS = new Set(['format', 'version', 'operations']); + +// ─── Operation Helpers ───────────────────────────────────────────────────── + +/** + * Returns a copy of the operation containing only spec-defined keys + * (`op`, `path`, `value`, `oldValue`). Complement of `operationExtensions`. + */ +export function operationSpecDict(op: IDeltaOperation): IDeltaOperation { + const result: IDeltaOperation = { op: op.op, path: op.path }; + if ('value' in op) result.value = op.value; + if ('oldValue' in op) result.oldValue = op.oldValue; + return result; +} + +/** + * Returns a record of non-spec keys from the operation (extension properties). + * Complement of `operationSpecDict`. + */ +export function operationExtensions(op: IDeltaOperation): Record { + const result: Record = Object.create(null); + for (const key of Object.keys(op)) { + if (!OP_SPEC_KEYS.has(key)) { + result[key] = op[key]; + } + } + return result; +} + +/** + * Returns the terminal property name from the operation's path, or `null` + * for root, index, and filter segments. + */ +export function leafProperty(op: IDeltaOperation): string | null { + const segments = parseDeltaPath(op.path); + if (segments.length === 0) return null; + const last = segments[segments.length - 1]; + return last.type === 'property' ? last.name : null; +} + +// ─── Delta Helpers ───────────────────────────────────────────────────────── + +/** + * Returns a copy of the delta with only spec-defined keys in the envelope + * and each operation. Strips all extension properties. + */ +export function deltaSpecDict(delta: IJsonDelta): IJsonDelta { + return { + format: delta.format, + version: delta.version, + operations: delta.operations.map(operationSpecDict), + }; +} + +/** + * Returns a record of non-spec keys from the delta envelope. + * Complement of `deltaSpecDict`. + */ +export function deltaExtensions(delta: IJsonDelta): Record { + const result: Record = Object.create(null); + for (const key of Object.keys(delta)) { + if (!DELTA_SPEC_KEYS.has(key)) { + result[key] = delta[key]; + } + } + return result; +} + +/** + * Transforms each operation in the delta using the provided function. + * Returns a new delta (immutable). Preserves all envelope properties. + */ +export function deltaMap( + delta: IJsonDelta, + fn: (op: IDeltaOperation, index: number) => IDeltaOperation +): IJsonDelta { + return { ...delta, operations: delta.operations.map((op, i) => fn(op, i)) }; +} + +/** + * Returns a new delta with the given extension properties merged onto every + * operation. Immutable — the original delta is not modified. + */ +export function deltaStamp( + delta: IJsonDelta, + extensions: Record +): IJsonDelta { + return deltaMap(delta, (op) => ({ ...op, ...extensions })); +} + +/** + * Groups operations in the delta by the result of `keyFn`. Returns a record + * mapping each key to a sub-delta containing only the matching operations. + * Each sub-delta preserves all envelope properties. + */ +export function deltaGroupBy( + delta: IJsonDelta, + keyFn: (op: IDeltaOperation) => string +): Record { + const groups: Record = Object.create(null); + for (const op of delta.operations) { + const k = keyFn(op); + if (!groups[k]) groups[k] = []; + groups[k].push(op); + } + + // Build envelope without operations + const envelope: Record = Object.create(null); + for (const key of Object.keys(delta)) { + if (key !== 'operations') { + envelope[key] = delta[key]; + } + } + + const result: Record = Object.create(null); + for (const [k, ops] of Object.entries(groups)) { + result[k] = { ...envelope, operations: ops } as IJsonDelta; + } + return result; +} + +// ─── Squash ──────────────────────────────────────────────────────────────── + +export interface SquashDeltasOptions extends DeltaOptions { + /** Pre-computed final state. When provided with deltas, used instead of computing. */ + target?: any; + /** Verify that `target` matches sequential application of deltas. Default: true. */ + verifyTarget?: boolean; +} + +function deepClone(obj: T): T { + return JSON.parse(JSON.stringify(obj)); +} + +/** + * Compacts multiple deltas into a single net-effect delta. The result is + * equivalent to applying all deltas sequentially, but expressed as a single + * `source → final` diff. + * + * Envelope extensions from all input deltas are merged (last-wins on conflict). + */ +export function squashDeltas( + source: any, + deltas: IJsonDelta[], + options: SquashDeltasOptions = {} +): IJsonDelta { + const { target, verifyTarget = true, ...diffOptions } = options; + + let final: any; + + if (target !== undefined && deltas.length > 0 && verifyTarget) { + // Compute and verify + let computed = deepClone(source); + for (const d of deltas) { + computed = applyDelta(computed, d); + } + const verification = diffDelta(computed, target, diffOptions); + if (verification.operations.length > 0) { + throw new Error( + 'squashDeltas: provided target does not match sequential application of deltas to source' + ); + } + final = target; + } else if (target !== undefined) { + // Trust the provided target + final = target; + } else { + // Compute final by applying all deltas + final = deepClone(source); + for (const d of deltas) { + final = applyDelta(final, d); + } + } + + // Compute the net-effect delta + const result = diffDelta(source, final, diffOptions); + + // Merge envelope extensions from input deltas (last-wins) + for (const d of deltas) { + for (const key of Object.keys(d)) { + if (!DELTA_SPEC_KEYS.has(key)) { + Object.defineProperty(result, key, { + value: d[key], + writable: true, + enumerable: true, + configurable: true, + }); + } + } + } + + return result; +} diff --git a/src/index.ts b/src/index.ts index 958a7f6..7b78d7f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export * from './jsonDiff.js'; export * from './jsonCompare.js'; export * from './jsonDelta.js'; +export * from './deltaHelpers.js'; diff --git a/src/jsonCompare.ts b/src/jsonCompare.ts index 48baa21..4beb661 100644 --- a/src/jsonCompare.ts +++ b/src/jsonCompare.ts @@ -118,4 +118,138 @@ const compare = (oldObject: any, newObject: any): IComparisonEnrichedNode => { return applyChangelist(enrich(oldObject), atomizeChangeset(diff(oldObject, newObject))); }; -export { CompareOperation, IComparisonEnrichedNode, createValue, createContainer, enrich, applyChangelist, compare }; +// ─── Comparison Serialization ────────────────────────────────────────────── + +export interface IComparisonDict { + type: string; + value?: any; + oldValue?: any; +} + +export interface IFlatChange { + path: string; + type: string; + value?: any; + oldValue?: any; +} + +/** + * Recursively serializes an enriched comparison tree to a plain JS object. + * The result is JSON-serializable when contained `value`/`oldValue` are + * themselves JSON-serializable. Includes `value`/`oldValue` based on change + * type, not truthiness — `null` is preserved as a valid JSON value. + */ +const comparisonToDict = (node: IComparisonEnrichedNode): IComparisonDict => { + const result: IComparisonDict = { type: node.type }; + + if (node.type === CompareOperation.CONTAINER) { + if (Array.isArray(node.value)) { + const children = node.value as IComparisonEnrichedNode[]; + const serialized: (IComparisonDict | null)[] = new Array(children.length); + for (let i = 0; i < children.length; i++) { + const child = children[i]; + serialized[i] = child != null ? comparisonToDict(child) : null; + } + result.value = serialized; + } else if (node.value && typeof node.value === 'object') { + const obj: Record = Object.create(null); + for (const [key, child] of Object.entries( + node.value as Record + )) { + if (child == null) continue; + obj[key] = comparisonToDict(child); + } + result.value = obj; + } + } else { + // Leaf: include fields based on change type + if ( + node.type === CompareOperation.UNCHANGED || + node.type === Operation.ADD || + node.type === Operation.UPDATE + ) { + result.value = node.value; + } + if (node.type === Operation.REMOVE || node.type === Operation.UPDATE) { + result.oldValue = node.oldValue; + } + } + + return result; +}; + +const IDENT_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + +/** + * Flattens an enriched comparison tree to a list of leaf changes with paths. + * Uses dot notation for identifier keys and bracket-quote notation for + * non-identifier keys (per spec Section 5.5). + * + * By default, `UNCHANGED` entries are excluded. Set `includeUnchanged: true` + * to include them. + */ +const comparisonToFlatList = ( + node: IComparisonEnrichedNode, + options: { includeUnchanged?: boolean } = {} +): IFlatChange[] => { + const results: IFlatChange[] = []; + flattenNode(node, '$', options.includeUnchanged ?? false, results); + return results; +}; + +function flattenNode( + node: IComparisonEnrichedNode, + path: string, + includeUnchanged: boolean, + results: IFlatChange[] +): void { + if (node.type === CompareOperation.CONTAINER) { + if (Array.isArray(node.value)) { + for (let i = 0; i < (node.value as IComparisonEnrichedNode[]).length; i++) { + const child = (node.value as IComparisonEnrichedNode[])[i]; + if (child == null) continue; + flattenNode(child, `${path}[${i}]`, includeUnchanged, results); + } + } else if (node.value && typeof node.value === 'object') { + for (const [key, child] of Object.entries( + node.value as Record + )) { + if (child == null) continue; + const childPath = IDENT_RE.test(key) + ? `${path}.${key}` + : `${path}['${key.replace(/'/g, "''")}']`; + flattenNode(child, childPath, includeUnchanged, results); + } + } + return; + } + + if (node.type === CompareOperation.UNCHANGED && !includeUnchanged) { + return; + } + + const entry: IFlatChange = { path, type: node.type }; + if ( + node.type === CompareOperation.UNCHANGED || + node.type === Operation.ADD || + node.type === Operation.UPDATE + ) { + entry.value = node.value; + } + if (node.type === Operation.REMOVE || node.type === Operation.UPDATE) { + entry.oldValue = node.oldValue; + } + results.push(entry); +} + +export { + CompareOperation, + IComparisonEnrichedNode, + createValue, + createContainer, + enrich, + applyChangelist, + compare, + comparisonToDict, + comparisonToFlatList, +}; diff --git a/tests/deltaHelpers.test.ts b/tests/deltaHelpers.test.ts new file mode 100644 index 0000000..27ed265 --- /dev/null +++ b/tests/deltaHelpers.test.ts @@ -0,0 +1,396 @@ +import { + operationSpecDict, + operationExtensions, + deltaSpecDict, + deltaExtensions, + leafProperty, + deltaMap, + deltaStamp, + deltaGroupBy, + squashDeltas, +} from '../src/deltaHelpers'; +import { diffDelta, applyDelta, IJsonDelta, IDeltaOperation } from '../src/jsonDelta'; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function deepClone(obj: T): T { + return JSON.parse(JSON.stringify(obj)); +} + +function makeDelta( + operations: IDeltaOperation[], + extras?: Record +): IJsonDelta { + return { format: 'json-delta', version: 1, operations, ...extras }; +} + +// ─── operationSpecDict ───────────────────────────────────────────────────── + +describe('operationSpecDict', () => { + it('strips extension properties', () => { + const op: IDeltaOperation = { + op: 'replace', + path: '$.name', + value: 'Bob', + oldValue: 'Alice', + x_author: 'system', + x_ts: 123, + }; + expect(operationSpecDict(op)).toEqual({ + op: 'replace', + path: '$.name', + value: 'Bob', + oldValue: 'Alice', + }); + }); + + it('handles add op (no oldValue)', () => { + const op: IDeltaOperation = { op: 'add', path: '$.age', value: 30 }; + const result = operationSpecDict(op); + expect(result).toEqual({ op: 'add', path: '$.age', value: 30 }); + expect('oldValue' in result).toBe(false); + }); + + it('handles remove op (no value)', () => { + const op: IDeltaOperation = { op: 'remove', path: '$.age', oldValue: 30 }; + const result = operationSpecDict(op); + expect(result).toEqual({ op: 'remove', path: '$.age', oldValue: 30 }); + expect('value' in result).toBe(false); + }); + + it('omits absent keys from result', () => { + const op: IDeltaOperation = { op: 'remove', path: '$.x' }; + const result = operationSpecDict(op); + expect('value' in result).toBe(false); + expect('oldValue' in result).toBe(false); + }); +}); + +// ─── operationExtensions ─────────────────────────────────────────────────── + +describe('operationExtensions', () => { + it('returns non-spec keys', () => { + const op: IDeltaOperation = { + op: 'replace', + path: '$.name', + value: 'Bob', + x_author: 'system', + x_ts: 123, + }; + expect(operationExtensions(op)).toEqual({ x_author: 'system', x_ts: 123 }); + }); + + it('returns empty object when no extensions', () => { + const op: IDeltaOperation = { op: 'add', path: '$.name', value: 'Bob' }; + expect(operationExtensions(op)).toEqual({}); + }); + + it('spec + extensions partition all keys', () => { + const op: IDeltaOperation = { + op: 'replace', + path: '$.name', + value: 'Bob', + oldValue: 'Alice', + x_batch: 'b1', + }; + expect({ ...operationSpecDict(op), ...operationExtensions(op) }).toEqual(op); + }); +}); + +// ─── deltaSpecDict ───────────────────────────────────────────────────────── + +describe('deltaSpecDict', () => { + it('strips envelope and operation extensions', () => { + const delta = makeDelta( + [{ op: 'replace', path: '$.name', value: 'Bob', x_author: 'sys' }], + { x_metadata: { ts: 1 } } + ); + expect(deltaSpecDict(delta)).toEqual({ + format: 'json-delta', + version: 1, + operations: [{ op: 'replace', path: '$.name', value: 'Bob' }], + }); + }); + + it('preserves spec-only delta as-is', () => { + const delta = makeDelta([{ op: 'add', path: '$.x', value: 1 }]); + expect(deltaSpecDict(delta)).toEqual(delta); + }); +}); + +// ─── deltaExtensions ─────────────────────────────────────────────────────── + +describe('deltaExtensions', () => { + it('returns non-spec envelope keys', () => { + const delta = makeDelta([], { x_source: 'api', x_ts: 42 }); + expect(deltaExtensions(delta)).toEqual({ x_source: 'api', x_ts: 42 }); + }); + + it('returns empty object when no extensions', () => { + const delta = makeDelta([]); + expect(deltaExtensions(delta)).toEqual({}); + }); +}); + +// ─── leafProperty ────────────────────────────────────────────────────────── + +describe('leafProperty', () => { + it('returns property name for simple path', () => { + expect(leafProperty({ op: 'replace', path: '$.user.name' })).toBe('name'); + }); + + it('returns null for root path', () => { + expect(leafProperty({ op: 'replace', path: '$' })).toBeNull(); + }); + + it('returns null for index segment', () => { + expect(leafProperty({ op: 'replace', path: '$.items[0]' })).toBeNull(); + }); + + it('returns null for filter segment', () => { + expect(leafProperty({ op: 'replace', path: '$.items[?(@.id==1)]' })).toBeNull(); + }); + + it('returns property after filter segment', () => { + expect(leafProperty({ op: 'replace', path: "$.items[?(@.id==1)].name" })).toBe('name'); + }); + + it('returns property for single-level path', () => { + expect(leafProperty({ op: 'add', path: '$.age' })).toBe('age'); + }); +}); + +// ─── deltaMap ────────────────────────────────────────────────────────────── + +describe('deltaMap', () => { + it('transforms operations', () => { + const delta = makeDelta([ + { op: 'replace', path: '$.a', value: 1 }, + { op: 'add', path: '$.b', value: 2 }, + ]); + const result = deltaMap(delta, (op) => ({ ...op, x_mapped: true })); + expect(result.operations).toHaveLength(2); + expect(result.operations[0].x_mapped).toBe(true); + expect(result.operations[1].x_mapped).toBe(true); + }); + + it('preserves envelope properties', () => { + const delta = makeDelta( + [{ op: 'add', path: '$.x', value: 1 }], + { x_source: 'test' } + ); + const result = deltaMap(delta, (op) => op); + expect(result.x_source).toBe('test'); + expect(result.format).toBe('json-delta'); + }); + + it('does not mutate the original delta', () => { + const delta = makeDelta([{ op: 'add', path: '$.x', value: 1 }]); + const original = deepClone(delta); + deltaMap(delta, (op) => ({ ...op, x_added: true })); + expect(delta).toEqual(original); + }); + + it('passes index to callback', () => { + const delta = makeDelta([ + { op: 'add', path: '$.a', value: 1 }, + { op: 'add', path: '$.b', value: 2 }, + ]); + const indices: number[] = []; + deltaMap(delta, (op, i) => { indices.push(i); return op; }); + expect(indices).toEqual([0, 1]); + }); +}); + +// ─── deltaStamp ──────────────────────────────────────────────────────────── + +describe('deltaStamp', () => { + it('sets extensions on all operations', () => { + const delta = makeDelta([ + { op: 'replace', path: '$.a', value: 1 }, + { op: 'add', path: '$.b', value: 2 }, + ]); + const result = deltaStamp(delta, { x_batch: 'b1', x_ts: 99 }); + for (const op of result.operations) { + expect(op.x_batch).toBe('b1'); + expect(op.x_ts).toBe(99); + } + }); + + it('does not mutate the original', () => { + const delta = makeDelta([{ op: 'add', path: '$.x', value: 1 }]); + const original = deepClone(delta); + deltaStamp(delta, { x_tag: 'test' }); + expect(delta).toEqual(original); + }); + + it('preserves envelope extensions', () => { + const delta = makeDelta( + [{ op: 'add', path: '$.x', value: 1 }], + { x_source: 'api' } + ); + const result = deltaStamp(delta, { x_tag: 'v1' }); + expect(result.x_source).toBe('api'); + }); +}); + +// ─── deltaGroupBy ────────────────────────────────────────────────────────── + +describe('deltaGroupBy', () => { + it('groups by operation type', () => { + const delta = makeDelta([ + { op: 'add', path: '$.a', value: 1 }, + { op: 'replace', path: '$.b', value: 2, oldValue: 1 }, + { op: 'add', path: '$.c', value: 3 }, + ]); + const groups = deltaGroupBy(delta, (op) => op.op); + expect(Object.keys(groups).sort((a, b) => a.localeCompare(b))).toEqual(['add', 'replace']); + expect(groups.add.operations).toHaveLength(2); + expect(groups.replace.operations).toHaveLength(1); + }); + + it('preserves envelope in each sub-delta', () => { + const delta = makeDelta( + [ + { op: 'add', path: '$.a', value: 1 }, + { op: 'remove', path: '$.b', oldValue: 2 }, + ], + { x_source: 'test' } + ); + const groups = deltaGroupBy(delta, (op) => op.op); + expect(groups.add.format).toBe('json-delta'); + expect(groups.add.version).toBe(1); + expect(groups.add.x_source).toBe('test'); + expect(groups.remove.x_source).toBe('test'); + }); + + it('returns empty record for empty delta', () => { + const delta = makeDelta([]); + expect(deltaGroupBy(delta, (op) => op.op)).toEqual({}); + }); + + it('returns single group when all ops have same key', () => { + const delta = makeDelta([ + { op: 'add', path: '$.a', value: 1 }, + { op: 'add', path: '$.b', value: 2 }, + ]); + const groups = deltaGroupBy(delta, (op) => op.op); + expect(Object.keys(groups)).toEqual(['add']); + expect(groups.add.operations).toHaveLength(2); + }); +}); + +// ─── squashDeltas ────────────────────────────────────────────────────────── + +describe('squashDeltas', () => { + const source = { name: 'Alice', age: 30, role: 'viewer' }; + + it('squashes two successive changes', () => { + const d1 = diffDelta(source, { ...source, name: 'Bob' }); + const d2 = diffDelta( + { ...source, name: 'Bob' }, + { ...source, name: 'Bob', role: 'admin' } + ); + const squashed = squashDeltas(source, [d1, d2]); + expect(squashed.operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ op: 'replace', path: '$.name', value: 'Bob' }), + expect.objectContaining({ op: 'replace', path: '$.role', value: 'admin' }), + ]) + ); + }); + + it('cancels add then remove', () => { + const intermediate = { ...source, newProp: 'hello' }; + const d1 = diffDelta(source, intermediate); + const d2 = diffDelta(intermediate, source); + const squashed = squashDeltas(source, [d1, d2]); + expect(squashed.operations).toHaveLength(0); + }); + + it('handles empty deltas array', () => { + const squashed = squashDeltas(source, []); + expect(squashed.operations).toHaveLength(0); + }); + + it('handles single delta', () => { + const d1 = diffDelta(source, { ...source, age: 31 }); + const squashed = squashDeltas(source, [d1]); + const applied = applyDelta(deepClone(source), squashed); + expect(applied).toEqual({ ...source, age: 31 }); + }); + + it('works with arrayIdentityKeys', () => { + const src = { items: [{ id: 1, v: 'a' }, { id: 2, v: 'b' }] }; + const opts = { arrayIdentityKeys: { items: 'id' } }; + const mid = { items: [{ id: 1, v: 'x' }, { id: 2, v: 'b' }] }; + const end = { items: [{ id: 1, v: 'x' }, { id: 2, v: 'y' }] }; + + const d1 = diffDelta(src, mid, opts); + const d2 = diffDelta(mid, end, opts); + const squashed = squashDeltas(src, [d1, d2], opts); + const applied = applyDelta(deepClone(src), squashed); + expect(applied).toEqual(end); + }); + + it('merges envelope extensions (last-wins)', () => { + const d1 = makeDelta( + [{ op: 'replace', path: '$.name', value: 'Bob', oldValue: 'Alice' }], + { x_batch: 'b1', x_source: 'first' } + ); + const d2 = makeDelta( + [{ op: 'replace', path: '$.name', value: 'Carol', oldValue: 'Bob' }], + { x_batch: 'b2' } + ); + const squashed = squashDeltas(source, [d1, d2]); + expect(squashed.x_batch).toBe('b2'); + expect(squashed.x_source).toBe('first'); + }); + + it('supports direct source→target compaction', () => { + const target = { ...source, name: 'Zara', age: 99 }; + const squashed = squashDeltas(source, [], { target }); + const applied = applyDelta(deepClone(source), squashed); + expect(applied).toEqual(target); + }); + + it('verifyTarget throws on mismatch', () => { + const d1 = diffDelta(source, { ...source, name: 'Bob' }); + const wrongTarget = { ...source, name: 'WRONG' }; + expect(() => + squashDeltas(source, [d1], { target: wrongTarget, verifyTarget: true }) + ).toThrow(/does not match/); + }); + + it('verifyTarget false skips check', () => { + const d1 = diffDelta(source, { ...source, name: 'Bob' }); + const wrongTarget = { ...source, name: 'WRONG' }; + // Should not throw — uses the target as-is + const squashed = squashDeltas(source, [d1], { target: wrongTarget, verifyTarget: false }); + expect(squashed.operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ op: 'replace', path: '$.name', value: 'WRONG' }), + ]) + ); + }); + + it('reversible false omits oldValue', () => { + const d1 = diffDelta(source, { ...source, name: 'Bob' }); + const squashed = squashDeltas(source, [d1], { reversible: false }); + for (const op of squashed.operations) { + expect('oldValue' in op).toBe(false); + } + }); + + it('round-trip: squash equals sequential apply', () => { + const mid = { ...source, name: 'Bob', age: 31 }; + const end = { ...source, name: 'Carol', age: 31, role: 'admin' }; + const d1 = diffDelta(source, mid); + const d2 = diffDelta(mid, end); + + const squashed = squashDeltas(source, [d1, d2]); + const viaSquash = applyDelta(deepClone(source), squashed); + const viaSequential = applyDelta(applyDelta(deepClone(source), d1), d2); + expect(viaSquash).toEqual(viaSequential); + }); +}); diff --git a/tests/jsonCompare.test.ts b/tests/jsonCompare.test.ts index b36f055..e78dae4 100644 --- a/tests/jsonCompare.test.ts +++ b/tests/jsonCompare.test.ts @@ -1,4 +1,4 @@ -import { compare, CompareOperation, IComparisonEnrichedNode, enrich, applyChangelist } from '../src/jsonCompare'; +import { compare, CompareOperation, IComparisonEnrichedNode, enrich, applyChangelist, comparisonToDict, comparisonToFlatList } from '../src/jsonCompare'; import { Operation, diff, atomizeChangeset } from '../src/jsonDiff'; let testedObject: any; @@ -303,3 +303,176 @@ describe('jsonCompare#compare', () => { done(); }); }); + +// ─── comparisonToDict ────────────────────────────────────────────────────── + +describe('comparisonToDict', () => { + it('serializes container with mixed children', () => { + const result = compare( + { name: 'Alice', age: 30, role: 'viewer' }, + { name: 'Bob', age: 30, status: 'active' } + ); + const dict = comparisonToDict(result); + + expect(dict.type).toBe('CONTAINER'); + expect(dict.value.name).toEqual({ type: 'UPDATE', value: 'Bob', oldValue: 'Alice' }); + expect(dict.value.age).toEqual({ type: 'UNCHANGED', value: 30 }); + expect(dict.value.role).toEqual({ type: 'REMOVE', oldValue: 'viewer' }); + expect(dict.value.status).toEqual({ type: 'ADD', value: 'active' }); + }); + + it('preserves null as a valid value', () => { + // Construct node directly — diff engine treats null↔string as type change + const node: IComparisonEnrichedNode = { + type: CompareOperation.CONTAINER, + value: { + x: { type: Operation.UPDATE, value: null, oldValue: 'hello' } as IComparisonEnrichedNode, + }, + }; + const dict = comparisonToDict(node); + expect(dict.value.x).toEqual({ type: 'UPDATE', value: null, oldValue: 'hello' }); + }); + + it('handles nested containers', () => { + const result = compare( + { nested: { deep: { val: 1 } } }, + { nested: { deep: { val: 2 } } } + ); + const dict = comparisonToDict(result); + expect(dict.value.nested.type).toBe('CONTAINER'); + expect(dict.value.nested.value.deep.type).toBe('CONTAINER'); + expect(dict.value.nested.value.deep.value.val).toEqual({ + type: 'UPDATE', + value: 2, + oldValue: 1, + }); + }); + + it('handles arrays', () => { + const result = compare(['a', 'b'], ['a', 'c']); + const dict = comparisonToDict(result); + expect(dict.type).toBe('CONTAINER'); + expect(Array.isArray(dict.value)).toBe(true); + expect(dict.value[0]).toEqual({ type: 'UNCHANGED', value: 'a' }); + expect(dict.value[1]).toEqual({ type: 'UPDATE', value: 'c', oldValue: 'b' }); + }); + + it('result is JSON-serializable', () => { + const result = compare({ a: 1, b: 'x' }, { a: 2, b: 'x', c: true }); + const dict = comparisonToDict(result); + const roundTripped = JSON.parse(JSON.stringify(dict)); + expect(roundTripped).toEqual(dict); + }); +}); + +// ─── comparisonToFlatList ────────────────────────────────────────────────── + +describe('comparisonToFlatList', () => { + it('produces correct paths for simple changes', () => { + const result = compare({ name: 'Alice', age: 30 }, { name: 'Bob', age: 30 }); + const flat = comparisonToFlatList(result); + expect(flat).toEqual([ + { path: '$.name', type: 'UPDATE', value: 'Bob', oldValue: 'Alice' }, + ]); + }); + + it('excludes unchanged by default', () => { + const result = compare({ a: 1, b: 2 }, { a: 1, b: 3 }); + const flat = comparisonToFlatList(result); + expect(flat).toHaveLength(1); + expect(flat[0].path).toBe('$.b'); + }); + + it('includes unchanged when requested', () => { + const result = compare({ a: 1, b: 2 }, { a: 1, b: 3 }); + const flat = comparisonToFlatList(result, { includeUnchanged: true }); + expect(flat).toHaveLength(2); + expect(flat.find((e) => e.path === '$.a')).toEqual({ + path: '$.a', + type: 'UNCHANGED', + value: 1, + }); + }); + + it('uses array index notation', () => { + const result = compare(['a', 'b'], ['a', 'c']); + const flat = comparisonToFlatList(result); + expect(flat).toEqual([ + { path: '$[1]', type: 'UPDATE', value: 'c', oldValue: 'b' }, + ]); + }); + + it('handles nested paths', () => { + const result = compare( + { nested: { deep: { val: 1 } } }, + { nested: { deep: { val: 2 } } } + ); + const flat = comparisonToFlatList(result); + expect(flat).toEqual([ + { path: '$.nested.deep.val', type: 'UPDATE', value: 2, oldValue: 1 }, + ]); + }); + + it('returns empty list for identical documents', () => { + const result = compare({ a: 1 }, { a: 1 }); + expect(comparisonToFlatList(result)).toEqual([]); + }); + + it('uses bracket notation for non-identifier keys', () => { + // Construct node directly — compare/diff treats dots in keys as path separators + const node: IComparisonEnrichedNode = { + type: CompareOperation.CONTAINER, + value: { + 'a.b': { type: Operation.UPDATE, value: 2, oldValue: 1 } as IComparisonEnrichedNode, + }, + }; + const flat = comparisonToFlatList(node); + expect(flat[0].path).toBe("$['a.b']"); + }); + + it('escapes single quotes in keys', () => { + // Construct node directly — keys with quotes need bracket notation + const node: IComparisonEnrichedNode = { + type: CompareOperation.CONTAINER, + value: { + "it's": { type: Operation.UPDATE, value: 2, oldValue: 1 } as IComparisonEnrichedNode, + }, + }; + const flat = comparisonToFlatList(node); + expect(flat[0].path).toBe("$['it''s']"); + }); + + it('uses dot notation for simple identifier keys', () => { + const result = compare({ name: 'a' }, { name: 'b' }); + const flat = comparisonToFlatList(result); + expect(flat[0].path).toBe('$.name'); + }); + + it('handles add and remove operations', () => { + const result = compare({ old: 1 }, { new: 2 }); + const flat = comparisonToFlatList(result); + expect(flat).toEqual( + expect.arrayContaining([ + { path: '$.old', type: 'REMOVE', oldValue: 1 }, + expect.objectContaining({ path: '$.new', type: 'ADD', value: 2 }), + ]) + ); + }); + + it('handles null values', () => { + // Construct node directly — diff engine treats null↔string as type change + const node: IComparisonEnrichedNode = { + type: CompareOperation.CONTAINER, + value: { + x: { type: Operation.UPDATE, value: 'hello', oldValue: null } as IComparisonEnrichedNode, + }, + }; + const flat = comparisonToFlatList(node); + expect(flat[0]).toEqual({ + path: '$.x', + type: 'UPDATE', + value: 'hello', + oldValue: null, + }); + }); +});