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
135 changes: 135 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, IJsonDelta>` | Group operations into sub-deltas |
| `operationSpecDict` | `(op) => IDeltaOperation` | Strip extension properties from operation |
| `operationExtensions` | `(op) => Record<string, any>` | Get extension properties from operation |
| `deltaSpecDict` | `(delta) => IJsonDelta` | Strip all extensions from delta |
| `deltaExtensions` | `(delta) => Record<string, any>` | Get envelope extensions from delta |
| `leafProperty` | `(op) => string \| null` | Terminal property name from operation path |

### DeltaOptions

Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
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.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",
Expand Down
200 changes: 200 additions & 0 deletions src/deltaHelpers.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> {
const result: Record<string, any> = 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];

Check warning on line 44 in src/deltaHelpers.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `.at(…)` over `[….length - index]`.

See more on https://sonarcloud.io/project/issues?id=ltwlf_json-diff-ts&issues=AZzcloJ1cN6OHpU4apKU&open=AZzcloJ1cN6OHpU4apKU&pullRequest=383
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<string, any> {
const result: Record<string, any> = 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<string, any>
): 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<string, IJsonDelta> {
const groups: Record<string, IDeltaOperation[]> = 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<string, any> = Object.create(null);
for (const key of Object.keys(delta)) {
if (key !== 'operations') {
envelope[key] = delta[key];
}
}
Comment on lines +114 to +120
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

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

deltaGroupBy builds the envelope with a normal {} and copies arbitrary keys from delta. If an extension key is __proto__/constructor, envelope[key] = ... can trigger special-key behavior (prototype mutation) and the property may be dropped when spreading into the grouped deltas. Use a null-prototype accumulator (e.g., Object.create(null)) for envelope (consistent with the other extension helpers) to avoid prototype pollution and preserve all keys.

Copilot uses AI. Check for mistakes.
Copy link
Owner Author

Choose a reason for hiding this comment

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

Good catch — fixed in 9a15233. envelope now uses Object.create(null), consistent with the groups and result accumulators fixed in the previous commit.


const result: Record<string, IJsonDelta> = 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<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));

Check warning on line 139 in src/deltaHelpers.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `structuredClone(…)` over `JSON.parse(JSON.stringify(…))` to create a deep clone.

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

/**
* 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(

Check failure on line 149 in src/deltaHelpers.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=ltwlf_json-diff-ts&issues=AZzcloJ1cN6OHpU4apKX&open=AZzcloJ1cN6OHpU4apKX&pullRequest=383
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) {

Check warning on line 171 in src/deltaHelpers.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected negated condition.

See more on https://sonarcloud.io/project/issues?id=ltwlf_json-diff-ts&issues=AZzcloJ1cN6OHpU4apKY&open=AZzcloJ1cN6OHpU4apKY&pullRequest=383
// 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;
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './jsonDiff.js';
export * from './jsonCompare.js';
export * from './jsonDelta.js';
export * from './deltaHelpers.js';
Loading
Loading