Skip to content

Commit b56420e

Browse files
maxjayclaude
andcommitted
docs: update README for array semantics, restore, and new diff options
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 4c1fd3c commit b56420e

1 file changed

Lines changed: 77 additions & 11 deletions

File tree

README.md

Lines changed: 77 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ Without a declared identity, arrays are diffed position-by-position. Deleting th
176176

177177
### Identity-keyed: `x-key`
178178

179-
Declare `x-key` on an array schema and patchwork matches elements across `base` and `draft` by that field. One element deleted produces one `remove` op, regardless of what follows it.
179+
Declare `x-key` on an array schema and patchwork matches elements across `base` and `draft` by that field. One element deleted produces one `remove` op, regardless of what follows it. Field changes on a matched element produce one `replace` op at the element level, with a `changes` array of the individual field-level diffs.
180180

181181
```ts
182182
const engine = new Engine(
@@ -202,22 +202,53 @@ const engine = new Engine(
202202
);
203203

204204
engine.delete('$.regions[0]');
205-
206205
engine.diff();
207206
// [ { op: 'remove', path: "$['regions'][0]", value: { id: 'us-east', ... }, identity: 'us-east' } ]
208207
// one op — not a cascade
208+
209+
engine.replace('$.regions[0].capacity', 90);
210+
engine.diff();
211+
// [
212+
// {
213+
// op: 'replace', path: "$['regions'][0]", identity: 'eu-west',
214+
// value: { id: 'eu-west', capacity: 90 }, oldValue: { id: 'eu-west', capacity: 80 },
215+
// displacement: 0,
216+
// changes: [{ op: 'replace', path: "$['regions'][0]['capacity']", oldValue: 80, value: 90 }]
217+
// }
218+
// ]
209219
```
210220

211-
`x-key` nests: arrays inside arrays can each declare their own key. The engine resolves the right field at each depth automatically via path-pattern matching.
221+
`x-key` nests: arrays inside arrays can each declare their own key. By default, field changes in a nested keyed array bubble up and mark the parent element as modified (its `changes` will include them). Pass `cascade: false` to `diff()` to contain changes within their own identity boundary — a nested change will not mark the parent as modified.
212222

213-
The `identity` field on `DiffOp` carries the matched key value directly, so consumers don't need schema knowledge to identify what was added or removed.
223+
The `identity` field on `DiffOp` carries the matched key value, so consumers don't need schema knowledge to identify what was added, removed, or changed.
214224

215225
For a one-off without a schema:
216226

217227
```ts
218228
engine.diff('$.regions', { key: 'id' });
219229
```
220230

231+
### Ordered arrays: `x-ordered`
232+
233+
Add `x-ordered: true` alongside `x-key` to declare that position is meaningful. When an element's index shifts because something was added or removed nearby, patchwork surfaces that as a `move` op — a displacement — rather than hiding it.
234+
235+
```ts
236+
// schema: { 'x-key': 'id', 'x-ordered': true, ... }
237+
238+
engine.delete('$.steps[0]'); // removes step A
239+
240+
engine.diff();
241+
// [
242+
// { op: 'remove', path: "$['steps'][0]", identity: 'A', value: {...} },
243+
// { op: 'move', from: "$['steps'][1]", to: "$['steps'][0]", identity: 'B' },
244+
// { op: 'move', from: "$['steps'][2]", to: "$['steps'][1]", identity: 'C' },
245+
// ]
246+
```
247+
248+
`move` ops from identity-keyed arrays carry `identity` so you know which element was displaced. The `displacement` field on `replace` ops tells you how far an element moved when it was also modified.
249+
250+
To restore a displacement, pass the `move` op to `restore()` — it splices the element back to its base position.
251+
221252
### Set semantics: `x-key: '$self'`
222253

223254
For arrays of primitives that are semantically sets — tags, permission names, status flags — declare `x-key: '$self'`. The item itself is the identity. Reorders are invisible (sets have no order), duplicates collapse (sets have no duplicates), and a single add or remove produces a single op.
@@ -243,6 +274,34 @@ engine.diff();
243274

244275
Restricted to primitive items. For sets of objects, add a stable ID field and use `x-key: '<field>'`.
245276

277+
### Rendering full lists with `includeUnchanged`
278+
279+
By default `diff()` returns only changed elements. Pass `includeUnchanged: true` to include every element — changed or not — each labelled with its state. This lets you render a complete list with change highlighting from a single call, without merging the diff against the raw array yourself.
280+
281+
```ts
282+
engine.diff('$.regions', { includeUnchanged: true });
283+
// returns add / replace / remove / move ops for changed elements,
284+
// plus { op: 'unchanged', ... } for every element that stayed the same
285+
```
286+
287+
### Reverting a diff op
288+
289+
`restore(op)` takes any `DiffOp` produced by `diff()` and applies the inverse mutation to draft, pushing it onto the undo stack like any other operation. The diff must reflect the current draft state — if you mutate after diffing, re-diff before restoring.
290+
291+
```ts
292+
const ops = engine.diff('$.regions');
293+
const removeOp = ops.find(o => o.op === 'remove' && o.identity === 'us-east');
294+
engine.restore(removeOp); // re-inserts us-east at its original position
295+
engine.undo(); // un-does the restore
296+
```
297+
298+
| op | what `restore` does |
299+
|---|---|
300+
| `add` | deletes the element |
301+
| `remove` | re-inserts it at its original position |
302+
| `replace` | reverts the element to `oldValue` |
303+
| `move` | splices it back to its base position |
304+
246305
## Scoped lenses
247306

248307
`getNodeEngine(path)` returns a `NodeEngine` — a lens onto a subtree. It owns no state; reads resolve through the parent on every access and writes forward to the parent with paths rewritten. **Both sides see the same physical state.**
@@ -335,7 +394,8 @@ See **[docs/angular.md](docs/angular.md)** for the full API, typed generics, cha
335394
| `.getBase(path)` | Same as `get` but reads from base. |
336395
| `.getValue(path)` | Strict single-match read from draft. Throws `Error` on multi-match; throws `undefined` on no-match. |
337396
| `.getValueBase(path)` | Same as `getValue` but reads from base. |
338-
| `.diff(path?, options?)` | `DiffOp[]` — structural diff between base and draft. |
397+
| `.diff(path?, options?)` | `DiffOp[]` — structural diff between base and draft. `options.key` sets a one-off identity key; `options.includeUnchanged` includes unchanged elements; `options.cascade` (default `true`) controls whether nested identity-array changes bubble up to the parent. |
398+
| `.restore(op)` | Invert a `DiffOp` from `diff()` and push it onto the undo stack. |
339399
| `.undo()` / `.redo()` | Reverse / replay the last operation. |
340400
| `.accept()` | Promote draft into base. Reversible. |
341401
| `.decline()` | Reset draft from base. Reversible. |
@@ -364,17 +424,23 @@ See **[docs/angular.md](docs/angular.md)** for the full API, typed generics, cha
364424

365425
```ts
366426
type DiffOp =
367-
| { op: 'add'; path: string; absolutePath?: string; value: JsonValue; identity?: JsonValue }
368-
| { op: 'replace'; path: string; absolutePath?: string; oldValue?: JsonValue; value: JsonValue; identity?: JsonValue }
369-
| { op: 'remove'; path: string; absolutePath?: string; value?: JsonValue; identity?: JsonValue }
370-
| { op: 'move' | 'copy'; from: string; to: string }
371-
| { op: 'revert'; path: string; absolutePath?: string }
427+
| { op: 'add'; path: string; absolutePath?: string; value: JsonValue; identity?: JsonValue }
428+
| { op: 'replace'; path: string; absolutePath?: string; oldValue?: JsonValue; value: JsonValue;
429+
identity?: JsonValue; displacement?: number; changes?: DiffOp[] }
430+
| { op: 'remove'; path: string; absolutePath?: string; value?: JsonValue; identity?: JsonValue }
431+
| { op: 'move'; from: string; to: string; identity?: JsonValue }
432+
| { op: 'copy'; from: string; to: string }
433+
| { op: 'revert'; path: string; absolutePath?: string }
434+
| { op: 'unchanged'; path: string; absolutePath?: string; value: JsonValue; identity: JsonValue; displacement: number }
372435
```
373436
374437
- `path` — normalized JSONPath (`$['key'][0]`).
375438
- `absolutePath` — present on ops from `NodeEngine.diff()`. Contains the full document path while `path` is relative to the child's `$`.
376-
- `identity`present on `add` / `remove` ops produced by identity-keyed array diffing. The matched key value (or the item itself for `$self`). Not present on field-level `replace` ops or index-zip ops.
439+
- `identity`the matched key value for identity-keyed array ops. Present on `add`, `remove`, `move`, and element-level `replace` ops. The item itself for `$self` arrays.
377440
- `oldValue` — present on `replace` ops; the value that was there before.
441+
- `displacement` — on element-level `replace` and `unchanged` ops from ordered arrays (`x-ordered: true`). Integer delta: `draftIndexbaseIndex`. Zero if position did not change.
442+
- `changes` — on element-level `replace` ops. Flat list of field-level `DiffOp`s describing what changed inside the element. Paths are absolute document paths.
443+
- `unchanged` op — only emitted when `diff()` is called with `includeUnchanged: true`.
378444
379445
### Entrypoints
380446

0 commit comments

Comments
 (0)