Skip to content
4 changes: 2 additions & 2 deletions .changesets/README-example.yaml
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# Example Changeset
#
# This is an example changeset file to help you understand the format.
# When you're ready to create real changesets, use: workspace changeset add
# When you're ready to create real changesets, use: workspace changeset create
#
# Important: Changesets should be committed to git as part of your PR.
# They tell the CI/CD pipeline what version bumps to perform on merge.
#
# Workflow:
# 1. Create a changeset: workspace changeset add
# 1. Create a changeset: workspace changeset create
# 2. Commit it with your changes: git add .changesets/your-changeset.yaml
# 3. Push your branch: git push
# 4. When merged to main, CI runs: workspace bump --execute
Expand Down
13 changes: 13 additions & 0 deletions .changesets/feat-rework.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"branch": "feat/rework",
"bump": "minor",
"environments": [
"production"
],
"packages": [
"@websublime/delta"
],
"changes": [],
"created_at": "2026-04-17T09:28:59.285429Z",
"updated_at": "2026-04-17T09:28:59.286518Z"
}
2 changes: 1 addition & 1 deletion .githooks/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ if [ "$count" -eq 0 ]; then
echo "" >&2
echo "pre-push: no changesets queued in $CHANGESET_DIR/" >&2
echo "" >&2
echo " Create one with: workspace changeset add" >&2
echo " Create one with: workspace changeset create" >&2
echo " Or bypass with: git push --no-verify" >&2
echo "" >&2
exit 1
Expand Down
52 changes: 51 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**delta://** — Typed JSON model diffing for TypeScript.

Diff any two JSON values and get a structured, typed result with JSON Pointer paths. Apply it forward with `patch`, reverse it with `unpatch`, or export it as an RFC 6902 patch.
Diff any two JSON values and get a structured, typed result with JSON Pointer paths. Apply it forward with `patch`, reverse it with `unpatch`, extract a sparse snapshot with `snapshot`, or export it as an RFC 6902 patch.

```ts
import { diff, patch, unpatch } from '@websublime/delta'
Expand All @@ -15,6 +15,11 @@ const result = diff(before, after, { arrayIdentity: 'id' })

const forward = patch(before, result) // === after
const backward = unpatch(after, result) // === before

// Extract only what changed — ideal for PATCH payloads or audit logs
import { snapshot } from '@websublime/delta'
const sparse = snapshot(result)
// → { users: { '0': { id: 2, role: 'mod' }, '1': { id: 1, role: 'admin' } } }
```

## Features
Expand All @@ -23,6 +28,7 @@ const backward = unpatch(after, result) // === before
- **Typed operations** — `add | remove | replace | move`, each with the right shape
- **JSON Pointer paths** (RFC 6901) — `/users/0/role`, `~0` and `~1` escaping included
- **Identity-based array diffing** — track items by id across reorders, adds, removes; deterministic even with duplicate ids
- **Sparse snapshot** — `snapshot()` returns a minimal object with only changed fields (removals as `null`) — ready for PATCH payloads, form dirty tracking, or audit logs
- **Bidirectional** — `patch` and `unpatch` both work from the diff result alone; `oldValue` is always present on destructive ops
- **RFC 6902 adapter** — export any diff as a standard JSON Patch
- **Runtime validation** — `patch`/`unpatch` reject malformed inputs with a typed `DeltaError`
Expand Down Expand Up @@ -78,6 +84,50 @@ unpatch(after, result) // { x: 1 }

Neither function mutates its inputs. `unpatch` only needs `after` + the diff result — it never needs `before` because `oldValue` is always stored on destructive operations.

### snapshot

Extract a sparse object containing only the values that changed — useful for HTTP PATCH payloads, form dirty tracking, optimistic UI updates, or audit logs.

```ts
import { diff, snapshot } from '@websublime/delta'

const before = { name: 'Alice', age: 30, email: 'alice@example.com' }
const after = { name: 'Bob', age: 30, role: 'admin' }

snapshot(diff(before, after))
// → { name: 'Bob', role: 'admin', email: null }
```

Nested structure is sparse — only the branches that actually changed appear:

```ts
const before = { user: { name: 'Alice', settings: { theme: 'dark', lang: 'en' } } }
const after = { user: { name: 'Alice', settings: { theme: 'light', lang: 'en' } } }

snapshot(diff(before, after))
// → { user: { settings: { theme: 'light' } } }
```

Removed keys appear as `null`:

```ts
snapshot(diff({ a: 1, b: 2 }, { a: 1 }))
// → { b: null }
```

Root replacements return the new value directly:

```ts
snapshot(diff(1, 2)) // → 2
snapshot(diff('hello', { x: 1 })) // → { x: 1 }
```

Returns `null` when nothing changed:

```ts
snapshot(diff({ a: 1 }, { a: 1 })) // → null
```

### Identity-based array diffing

When your array items have a stable identifier, use `arrayIdentity` to track them across reorders, adds and removes:
Expand Down
145 changes: 143 additions & 2 deletions src/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ import { cloneDeep, deepEqual, isArray, isObject, joinPath } from './utils.js';

// ── Option resolution ─────────────────────────

/**
* Compile an {@link Identity} descriptor into a callable {@link IdentityFn}.
*
* - `string` → single-key getter (e.g. `'id'` → `item => item.id`)
* - `string[]` → composite-key getter joined with `::` separator
* - `function` → returned as-is
*
* Primitive (non-object) items always resolve to the sentinel `'__primitive__'`.
*/
function resolveIdentityFn(identity: Identity): IdentityFn {
if (typeof identity === 'function') return identity;

Expand All @@ -40,6 +49,12 @@ function resolveIdentityFn(identity: Identity): IdentityFn {
};
}

/**
* Normalise raw {@link DiffOptions} into a {@link ResolvedOptions} struct.
*
* Compiles identity descriptors into callable functions, builds the ignore
* set and prefix list, and applies defaults for every optional field.
*/
function resolveOptions(opts: DiffOptions = {}): ResolvedOptions {
const rawIdentity = opts.arrayIdentity;
let getIdentity: ResolvedOptions['getIdentity'];
Expand Down Expand Up @@ -89,6 +104,13 @@ function maybeClone<T extends JsonValue>(val: T, opts: ResolvedOptions): T {

// ── Path filtering ────────────────────────────

/**
* Check whether `path` should be skipped during diffing.
*
* A path is ignored when it matches an entry in `opts.ignore` (exact) or
* when it falls under any prefix listed in `opts.ignorePrefix` (the `/*`
* syntax from {@link DiffOptions.ignore}).
*/
function shouldIgnore(path: string, opts: ResolvedOptions): boolean {
if (opts.ignore.has(path)) return true;
for (const prefix of opts.ignorePrefix) {
Expand All @@ -99,6 +121,46 @@ function shouldIgnore(path: string, opts: ResolvedOptions): boolean {

// ── Main diff entry point ─────────────────────

/**
* Compute the structural diff between two JSON values.
*
* Returns a {@link DiffResult} containing an ordered list of operations that,
* when applied via {@link patch}, transform `before` into `after`.
*
* @param before - The source (original) JSON value.
* @param after - The target (modified) JSON value.
* @param options - Optional {@link DiffOptions} to control identity resolution,
* equality, move detection, recursion depth, path ignoring,
* and value cloning.
* @returns A {@link DiffResult} with operations, summary counters, and changed paths.
*
* @example
* ```ts
* import { diff } from '@websublime/delta';
*
* const before = { name: 'Alice', age: 30 };
* const after = { name: 'Bob', age: 30, role: 'admin' };
*
* const result = diff(before, after);
* // result.operations → [
* // { op: 'replace', path: '/name', value: 'Bob', oldValue: 'Alice' },
* // { op: 'add', path: '/role', value: 'admin' }
* // ]
* // result.summary → { added: 1, removed: 0, replaced: 1, moved: 0, … }
* ```
*
* @example Identity-based array diff
* ```ts
* const before = { items: [{ id: 1, v: 'a' }, { id: 2, v: 'b' }] };
* const after = { items: [{ id: 2, v: 'b' }, { id: 1, v: 'a' }] };
*
* const result = diff(before, after, { arrayIdentity: 'id' });
* // result.operations → [
* // { op: 'move', path: '/items', fromIndex: 0, toIndex: 1, … },
* // { op: 'move', path: '/items', fromIndex: 1, toIndex: 0, … }
* // ]
* ```
*/
export function diff(before: JsonValue, after: JsonValue, options?: DiffOptions): DiffResult {
const opts = resolveOptions(options);
const ops: DiffOp[] = [];
Expand All @@ -108,6 +170,13 @@ export function diff(before: JsonValue, after: JsonValue, options?: DiffOptions)
return buildResult(ops);
}

/**
* Aggregate a flat list of {@link DiffOp} into a {@link DiffResult}.
*
* Counts operations by type, builds the {@link DiffSummary}, collects all
* touched paths into `changedPaths`, and detects `movedAndChanged` entries
* by comparing `value` and `oldValue` on move operations.
*/
function buildResult(ops: DiffOp[]): DiffResult {
const summary: DiffSummary = {
added: 0,
Expand Down Expand Up @@ -151,6 +220,20 @@ function buildResult(ops: DiffOp[]): DiffResult {

// ── Recursive value diff ──────────────────────

/**
* Recursively diff two JSON values and push resulting operations into `out`.
*
* Dispatches to {@link diffObjects}, {@link diffArrays}, or emits a `replace`
* operation depending on the type pair. Respects `maxDepth` — at the limit,
* sub-trees are compared as opaque blobs via `equal()`.
*
* @param before - Source value.
* @param after - Target value.
* @param path - Current RFC 6901 JSON Pointer path.
* @param opts - Resolved diff options.
* @param depth - Current recursion depth (0-based).
* @param out - Accumulator for emitted operations.
*/
function diffValues(
before: JsonValue,
after: JsonValue,
Expand Down Expand Up @@ -189,6 +272,13 @@ function diffValues(

// ── Object diff ───────────────────────────────

/**
* Diff two plain JSON objects by their keys.
*
* Treats `undefined` values as absent (JSON semantics). For each key present
* in either side, emits `remove`, `add`, or recurses via {@link diffValues}
* for keys present in both.
*/
function diffObjects(
before: JsonObject,
after: JsonObject,
Expand Down Expand Up @@ -225,6 +315,13 @@ function diffObjects(

// ── Array diff ────────────────────────────────

/**
* Diff two JSON arrays, routing to the appropriate strategy.
*
* If an identity resolver is configured for this array path (checked by
* probing the first available item), delegates to {@link diffArraysByIdentity}.
* Otherwise falls back to the positional {@link diffArraysByLCS} algorithm.
*/
function diffArrays(
before: JsonValue[],
after: JsonValue[],
Expand All @@ -247,10 +344,18 @@ function diffArrays(

// ── Identity-based array diff ─────────────────

/**
* An entry in the identity map used by {@link diffArraysByIdentity}.
*
* The `key` is a composite string `"${rawId}:${occurrence}"` that uniquely
* identifies each array item — even when duplicate raw identities exist.
*/
interface IdentityEntry {
/** Composite key = `${rawId}:${occurrence}`. Deterministic for duplicates. */
key: string;
/** The array item value. */
item: JsonValue;
/** The item's position in the source or target array. */
index: number;
}

Expand Down Expand Up @@ -279,6 +384,20 @@ function buildIdentityMap(
return map;
}

/**
* Diff two arrays using identity-based matching.
*
* Builds identity maps for both sides, then classifies items into three groups:
* 1. **Removed** — present only in `before`.
* 2. **Matched** — present in both. May have moved (`fromIndex !== toIndex`)
* and/or changed (values differ). Emits `move` or nested diff ops.
* 3. **Added** — present only in `after`.
*
* When `detectMoves` is `false`, reorders are decomposed into remove + add
* pairs instead of `move` operations.
*
* Emission order: removes (desc index) → moves (asc toIndex) → nested → adds (asc index).
*/
function diffArraysByIdentity(
before: JsonValue[],
after: JsonValue[],
Expand Down Expand Up @@ -316,14 +435,27 @@ function diffArraysByIdentity(

if (opts.detectMoves) {
if (moved) {
pendingMoves.push({
const moveOp: OpMove = {
op: 'move',
path,
fromIndex: beforeEntry.index,
toIndex: afterEntry.index,
value: maybeClone(afterEntry.item, opts),
oldValue: maybeClone(beforeEntry.item, opts),
});
};

// When the item also changed, compute a granular diff with paths
// relative to the item root. These are NOT emitted as top-level ops
// (that would break unpatch — destination-index paths reference
// different items after reverse reconstruction). Instead they live
// on the move op as informational metadata.
if (changed) {
const itemOps: DiffOp[] = [];
diffValues(beforeEntry.item, afterEntry.item, '', opts, depth + 1, itemOps);
moveOp.nestedDiff = buildResult(itemOps);
}

pendingMoves.push(moveOp);
} else if (changed) {
// Same position, value changed → recurse for granular ops
diffValues(
Expand Down Expand Up @@ -389,6 +521,15 @@ function diffArraysByIdentity(

// ── LCS-based (positional) array diff ─────────

/**
* Diff two arrays using the Longest Common Subsequence (LCS) algorithm.
*
* Used when no identity resolver is configured for the array path. Items
* are matched purely by position and equality. Does not emit `move` ops —
* reorders appear as a combination of removes and adds.
*
* Emission order: removes (desc index) → nested diffs on kept items → adds (asc index).
*/
function diffArraysByLCS(
before: JsonValue[],
after: JsonValue[],
Expand Down
21 changes: 20 additions & 1 deletion src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ export class DeltaError extends Error {
public readonly code: DeltaErrorCode;
public readonly path: string | undefined;

/**
* @param code - Machine-readable error code from {@link DeltaErrorCode}.
* @param message - Human-readable description of the failure.
* @param path - Optional RFC 6901 JSON Pointer where the error occurred.
* When provided, it is appended to the `message` for diagnostics.
*/
constructor(code: DeltaErrorCode, message: string, path?: string) {
super(path !== undefined ? `${message} (at ${path || '<root>'})` : message);
this.name = 'DeltaError';
Expand All @@ -20,6 +26,17 @@ export class DeltaError extends Error {
}
}

/**
* Machine-readable error codes emitted by the delta library.
*
* Use these to programmatically handle specific failure modes:
* ```ts
* try { patch(doc, result); }
* catch (e) {
* if (e instanceof DeltaError && e.code === 'PATH_NOT_FOUND') { … }
* }
* ```
*/
export type DeltaErrorCode =
/** A cyclic reference was encountered while traversing the document. */
| 'CIRCULAR_REFERENCE'
Expand All @@ -30,4 +47,6 @@ export type DeltaErrorCode =
/** A path references a node that does not exist when it should. */
| 'PATH_NOT_FOUND'
/** A value is not representable as JSON (e.g. undefined in an array slot, function). */
| 'UNSUPPORTED_VALUE';
| 'UNSUPPORTED_VALUE'
/** An array is too large for the O(mn) LCS algorithm. Use `arrayIdentity` instead. */
| 'ARRAY_TOO_LARGE';
Loading
Loading