Repository files navigation

patchwork

A JSON editing engine with base/draft, diff, undo, ephemeral sessions, and scoped lenses.

Motivation · Install · How it works · JSONPath querying · Array diffing · Scoped lenses · LLM integration · Angular · API


Motivation

Building a config editor, settings panel, or any UI over structured data means wiring up the same three concerns every time:

  • What changed? A diff between the saved state and the current edit.
  • Undo/redo that survives saves, across every operation.
  • Review before commit — inspect pending changes before they land.

Patchwork wraps any JSON document in an Engine that holds two views — base (committed) and draft (working) — and a stack of reversible operations. That single primitive covers all three.

Addressing uses JSONPath (RFC 9535) throughout. The same expression you write to read a value works identically to target a write or scope a diff. Diff output follows the JSON Patch (RFC 6902) operation vocabulary (add, replace, remove, move, copy) so it maps onto existing patch tooling and transports.

Install

npm install @maxjay/patchwork

How it works

1. Wrap any JSON document

import{Engine}from'@maxjay/patchwork';constengine=newEngine({server: {host: 'localhost',port: 8080},debug: false,});

Two independent deep clones are taken on construction — one as base, one as draft. They start identical and diverge as you mutate.

2. Mutate the draft

All mutations target draft. base doesn't move until you accept().

engine.replace('$.server.port',443);engine.add('$.server.ssl',true);engine.delete('$.debug');engine.draft;// { server: { host: 'localhost', port: 443, ssl: true }}engine.base;// { server: { host: 'localhost', port: 8080 }, debug: false }
MethodDescription
.add(path, value)Splice into arrays or set on objects. Creates intermediate nodes on literal paths.
.replace(path, value)Replace matched values. Wildcards replace all matches.
.delete(path)Remove at path. Splices arrays in place.
.move(from, to)Move a value. Source must resolve to exactly one node.
.copy(from, to)Copy a value. Source must resolve to exactly one node.
.revert(path)Reset draft at path back to whatever base has there.

3. See what changed

diff() returns the net structural difference between base and draft as a flat list of DiffOp objects. It's a snapshot comparison — independent of the undo stack.

engine.diff();// [// { op: 'replace', path: "$['server']['port']", oldValue: 8080, value: 443 },// { op: 'add', path: "$['server']['ssl']", value: true },// { op: 'remove', path: "$['debug']", value: false },// ]

Scope the diff with a JSONPath — resolves against both base and draft so deleted nodes are never missed:

engine.diff('$.server');// only ops touching the server subtreeengine.diff('$.items[*]');// only ops touching array elements

4. Undo anything

Every mutation pushes onto a single linear undo stack.

engine.undo();// reverse last opengine.redo();// replay it

accept() and decline() are themselves on the stack — committing doesn't erase history.

5. Commit or discard

engine.accept();// base ← clone(draft). draft untouched.engine.decline();// draft ← clone(base). pending edits discarded.

6. Ephemeral sessions

Some write patterns don't belong on the undo stack — streaming output updating a field on every chunk, hover previews, keystroke-level form binding. beginEphemeral opens a session where mutations proceed normally; commitEphemeral collapses the whole session into one undo entry.

engine.beginEphemeral();forawait(constchunkofstream){engine.replace('$.response',chunk);// draft updates live}engine.commitEphemeral();// one undo() snaps back to the pre-stream state

discardEphemeral() cancels instead — unwinds all session mutations, no history trace.

7. Export and replay

constops=engine.exportChanges();// DiffOp[] from the undo stackconstother=newEngine(originalDoc);other.importChanges(ops);// other.draft is now identical to engine.draft

JSONPath querying

Every operation in patchwork — reads, writes, diffs — accepts the same JSONPath (RFC 9535) expression. There is no separate addressing system for mutations vs queries.

// Readsengine.get('$.servers[*].host');// all hostsengine.get('$..*[?@.enabled == true]');// any enabled node, anywhereengine.getValue('$.config.timeout');// strict single-match// Writes — same pathsengine.replace('$.servers[*].host','prod');// replace all hostsengine.delete('$..*[?@.deprecated]');// remove any deprecated node// Diff — same pathsengine.diff('$.servers[*]');// ops touching any server

Paths returned by get() come back in normalized form ($['key'][0]) and can be fed straight back into replace, delete, etc.

Selector reference:

SyntaxMatches
$.key / $['key']Named property
$[0]Array index
$[*] / $['*']All children
$..*All descendants (recursive descent)
$[?@.x == 1]Filter — elements where condition holds
$[2:5]Slice

Array diffing

Default: index-zip

Without a declared identity, arrays are diffed position-by-position. Deleting the first element shifts every following element, producing a cascade of false replace ops — one per element that moved. This is correct for fixed-position arrays (tuples, coordinate pairs) but wrong for most everything else.

Identity-keyed: x-key

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.

constengine=newEngine({regions: [{id: 'us-east',capacity: 100},{id: 'eu-west',capacity: 80},{id: 'ap-south',capacity: 60},],},{schema: {type: 'object',properties: {regions: {type: 'array','x-key': 'id',items: {type: 'object'},},},},},);engine.delete('$.regions[0]');engine.diff();// [ { op: 'remove', path: "$['regions'][0]", value: { id: 'us-east', ... }, identity: 'us-east' } ]// one op — not a cascadeengine.replace('$.regions[0].capacity',90);engine.diff();// [// {// op: 'replace', path: "$['regions'][0]", identity: 'eu-west',// value: { id: 'eu-west', capacity: 90 }, oldValue: { id: 'eu-west', capacity: 80 },// displacement: 0,// changes: [{ op: 'replace', path: "$['regions'][0]['capacity']", oldValue: 80, value: 90 }]// }// ]

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.

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.

For a one-off without a schema:

engine.diff('$.regions',{key: 'id'});

Ordered arrays: x-ordered

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.

// schema: { 'x-key': 'id', 'x-ordered': true, ... }engine.delete('$.steps[0]');// removes step Aengine.diff();// [// { op: 'remove', path: "$['steps'][0]", identity: 'A', value: {...}},// { op: 'move', from: "$['steps'][1]", to: "$['steps'][0]", identity: 'B' },// { op: 'move', from: "$['steps'][2]", to: "$['steps'][1]", identity: 'C' },// ]

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.

To restore a displacement, pass the move op to restore() — it splices the element back to its base position.

Set semantics: x-key: '$self'

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.

constengine=newEngine({permissions: ['read','write','admin']},{schema: {type: 'object',properties: {permissions: {type: 'array','x-key': '$self',items: {type: 'string'}},},},},);engine.delete('$.permissions[1]');engine.diff();// [ { op: 'remove', path: "$['permissions'][1]", value: 'write', identity: 'write' } ]

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

Rendering full lists with includeUnchanged

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.

engine.diff('$.regions',{includeUnchanged: true});// returns add / replace / remove / move ops for changed elements,// plus { op: 'unchanged', ... } for every element that stayed the same

Reverting a diff op

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.

constops=engine.diff('$.regions');constremoveOp=ops.find(o=>o.op==='remove'&&o.identity==='us-east');engine.restore(removeOp);// re-inserts us-east at its original positionengine.undo();// un-does the restore
opwhat restore does
adddeletes the element
removere-inserts it at its original position
replacereverts the element to oldValue
movesplices it back to its base position

Scoped lenses

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.

constengine=newEngine({cars: [{color: 'red'}],trucks: [{color: 'red'}],});constcars=engine.getNodeEngine('$.cars');cars.replace('$[0].color','yellow');engine.draft.cars[0].color;// 'yellow'cars.draft[0].color;// 'yellow'

Subtree-scoped behavior on the lens:

  • cars.diff() — ops touching cars only, paths relative to $; each op also carries absolutePath with the full document path.
  • cars.accept() — commits the cars subtree into base. The trucks subtree is unaffected.
  • cars.undo() / cars.redo() — delegate to the parent; there is one shared history.

Lenses compose — getNodeEngine on a NodeEngine joins paths and creates a further-scoped lens against the same root parent.

LLM integration

createEngineTools builds a framework-neutral tool set that any LLM can call to read and edit the draft. The design is intentional: accept, decline, undo, and redo are not exposed — the LLM writes to draft, the human commits.

import{createEngineTools}from'@maxjay/patchwork/tools';consttools=createEngineTools(engine);// 9 tools: add, replace, delete, move, copy, revert, get, getValue, diff

Scope the LLM to a subtree by passing a NodeEngine:

constscoped=engine.getNodeEngine('$.userSettings');consttools=createEngineTools(scoped);// the model can only touch userSettings — the rest is unreachable

For MCP servers and agentic loops, see docs/llms.md.

Angular integration

@maxjay/patchwork/angular wraps an Engine in a reactive store built on Angular Signals (Angular 16+). All reads are exposed as Signals; mutations fire them automatically — no ChangeDetectorRef, no NgZone.

import{createPatchworkStore}from'@maxjay/patchwork/angular';
@Component({template: ` <input [value]="port()" (input)="setPort($event)"> <button (click)="store.accept()" [disabled]="!diff().length">Save</button> <button (click)="store.decline()" [disabled]="!diff().length">Discard</button> `,})classServerSettings{store=createPatchworkStore({server: {port: 8080}});port=this.store.getValue<number>('$.server.port');diff=this.store.diff();setPort(e: Event){this.store.replace('$.server.port',+(e.targetasHTMLInputElement).value);}}

See docs/angular.md for the full API, typed generics, change-highlighting UI, ephemeral form binding, scoped sub-stores, and service patterns.

API

Engine<T>

MemberDescription
new Engine(base, options?)Wrap a JSON value. options.schema enables identity-based array diffing.
.base / .draftThe committed and working views.
.add(path, value)Add or splice. Creates intermediate nodes on literal paths.
.replace(path, value)Replace at path. Wildcards replace all matches.
.delete(path)Remove at path.
.move(from, to)Move. Source must resolve to exactly one node.
.copy(from, to)Copy. Source must resolve to exactly one node.
.revert(path)Reset draft at path to base.
.get(path)Array<{ path, value }> — every match in draft with normalized paths.
.getBase(path)Same as get but reads from base.
.getValue(path)Strict single-match read from draft. Throws Error on multi-match; throws undefined on no-match.
.getValueBase(path)Same as getValue but reads from base.
.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.
.restore(op)Invert a DiffOp from diff() and push it onto the undo stack.
.undo() / .redo()Reverse / replay the last operation.
.accept()Promote draft into base. Reversible.
.decline()Reset draft from base. Reversible.
.exportChanges()DiffOp[] — structural mutations on the undo stack.
.importChanges(ops)Apply a DiffOp[] stream.
.getNodeEngine<U>(path)Scoped lens onto a subtree.
.beginEphemeral()Open an ephemeral session.
.commitEphemeral()Collapse the session into one undo entry.
.discardEphemeral()Unwind the session with no history trace.

NodeEngine<T>

MemberDescription
.base / .draftThe subtree from parent state.
.add / .replace / .delete / .move / .copy / .revertMutations forwarded to parent with paths rewritten.
.get(path) / .getBase(path)Reads draft / base in child frame, forwarded to parent.
.getValue(path) / .getValueBase(path)Strict single-match reads from draft / base.
.diff(path?, options?)Ops touching this subtree. Paths relative to child $; each op also carries absolutePath.
.accept()Commits this subtree into parent's base.
.decline()Resets this subtree in parent's draft from parent's base.
.undo() / .redo()Delegate to parent — one shared history.
.getNodeEngine<U>(path)Compose a further-scoped lens.

DiffOp

typeDiffOp=|{op: 'add';path: string;absolutePath?: string;value: JsonValue;identity?: JsonValue}|{op: 'replace';path: string;absolutePath?: string;oldValue?: JsonValue;value: JsonValue;identity?: JsonValue;displacement?: number;changes?: DiffOp[]}|{op: 'remove';path: string;absolutePath?: string;value?: JsonValue;identity?: JsonValue}|{op: 'move';from: string;to: string;identity?: JsonValue}|{op: 'copy';from: string;to: string}|{op: 'revert';path: string;absolutePath?: string}|{op: 'unchanged';path: string;absolutePath?: string;value: JsonValue;identity: JsonValue;displacement: number}
  • path — normalized JSONPath ($['key'][0]).
  • absolutePath — present on ops from NodeEngine.diff(). Contains the full document path while path is relative to the child's $.
  • 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.
  • oldValue — present on replace ops; the value that was there before.
  • displacement — on element-level replace and unchanged ops from ordered arrays (x-ordered: true). Integer delta: draftIndex − baseIndex. Zero if position did not change.
  • changes — on element-level replace ops. Flat list of field-level DiffOps describing what changed inside the element. Paths are absolute document paths.
  • unchanged op — only emitted when diff() is called with includeUnchanged: true.

Entrypoints

@maxjay/patchwork Engine, NodeEngine, DiffOp, OpType
@maxjay/patchwork/tools createEngineTools, Tool, EngineLike
@maxjay/patchwork/chat runAgentLoop, AgentMessage, ModelAdapter, NativeAdapter, PromptAdapter, toAgentTools
@maxjay/patchwork/mcp toMcpTools, handleMcpCall
@maxjay/patchwork/angular createPatchworkStore, fromEngine, PatchworkStore

For deeper coverage of the engine internals, see docs/engine.md. For LLM integration, adapters, and MCP, see docs/llms.md. For the Angular Signals adapter, see docs/angular.md.

Contributors

License

Apache-2.0

About

Patchwork is an AI-native TypeScript configuration framework for editors, admin tools, complex forms, and other structured JSON applications, with built-in agentic LLM tools and MCP support. RFC 9535 JSONPath querying, semantic diffs, undo/redo, scoped state, and framework integrations replace layers of custom state code with a few calls.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

patchwork

A JSON editing engine with base/draft, diff, undo, ephemeral sessions, and scoped lenses.

Motivation · Install · How it works · JSONPath querying · Array diffing · Scoped lenses · LLM integration · Angular · API


Motivation

Building a config editor, settings panel, or any UI over structured data means wiring up the same three concerns every time:

  • What changed? A diff between the saved state and the current edit.
  • Undo/redo that survives saves, across every operation.
  • Review before commit — inspect pending changes before they land.

Patchwork wraps any JSON document in an Engine that holds two views — base (committed) and draft (working) — and a stack of reversible operations. That single primitive covers all three.

Addressing uses JSONPath (RFC 9535) throughout. The same expression you write to read a value works identically to target a write or scope a diff. Diff output follows the JSON Patch (RFC 6902) operation vocabulary (add, replace, remove, move, copy) so it maps onto existing patch tooling and transports.

Install

npm install @maxjay/patchwork

How it works

1. Wrap any JSON document

import{Engine}from'@maxjay/patchwork';constengine=newEngine({server: {host: 'localhost',port: 8080},debug: false,});

Two independent deep clones are taken on construction — one as base, one as draft. They start identical and diverge as you mutate.

2. Mutate the draft

All mutations target draft. base doesn't move until you accept().

engine.replace('$.server.port',443);engine.add('$.server.ssl',true);engine.delete('$.debug');engine.draft;// { server: { host: 'localhost', port: 443, ssl: true }}engine.base;// { server: { host: 'localhost', port: 8080 }, debug: false }
MethodDescription
.add(path, value)Splice into arrays or set on objects. Creates intermediate nodes on literal paths.
.replace(path, value)Replace matched values. Wildcards replace all matches.
.delete(path)Remove at path. Splices arrays in place.
.move(from, to)Move a value. Source must resolve to exactly one node.
.copy(from, to)Copy a value. Source must resolve to exactly one node.
.revert(path)Reset draft at path back to whatever base has there.

3. See what changed

diff() returns the net structural difference between base and draft as a flat list of DiffOp objects. It's a snapshot comparison — independent of the undo stack.

engine.diff();// [// { op: 'replace', path: "$['server']['port']", oldValue: 8080, value: 443 },// { op: 'add', path: "$['server']['ssl']", value: true },// { op: 'remove', path: "$['debug']", value: false },// ]

Scope the diff with a JSONPath — resolves against both base and draft so deleted nodes are never missed:

engine.diff('$.server');// only ops touching the server subtreeengine.diff('$.items[*]');// only ops touching array elements

4. Undo anything

Every mutation pushes onto a single linear undo stack.

engine.undo();// reverse last opengine.redo();// replay it

accept() and decline() are themselves on the stack — committing doesn't erase history.

5. Commit or discard

engine.accept();// base ← clone(draft). draft untouched.engine.decline();// draft ← clone(base). pending edits discarded.

6. Ephemeral sessions

Some write patterns don't belong on the undo stack — streaming output updating a field on every chunk, hover previews, keystroke-level form binding. beginEphemeral opens a session where mutations proceed normally; commitEphemeral collapses the whole session into one undo entry.

engine.beginEphemeral();forawait(constchunkofstream){engine.replace('$.response',chunk);// draft updates live}engine.commitEphemeral();// one undo() snaps back to the pre-stream state

discardEphemeral() cancels instead — unwinds all session mutations, no history trace.

7. Export and replay

constops=engine.exportChanges();// DiffOp[] from the undo stackconstother=newEngine(originalDoc);other.importChanges(ops);// other.draft is now identical to engine.draft

JSONPath querying

Every operation in patchwork — reads, writes, diffs — accepts the same JSONPath (RFC 9535) expression. There is no separate addressing system for mutations vs queries.

// Readsengine.get('$.servers[*].host');// all hostsengine.get('$..*[?@.enabled == true]');// any enabled node, anywhereengine.getValue('$.config.timeout');// strict single-match// Writes — same pathsengine.replace('$.servers[*].host','prod');// replace all hostsengine.delete('$..*[?@.deprecated]');// remove any deprecated node// Diff — same pathsengine.diff('$.servers[*]');// ops touching any server

Paths returned by get() come back in normalized form ($['key'][0]) and can be fed straight back into replace, delete, etc.

Selector reference:

SyntaxMatches
$.key / $['key']Named property
$[0]Array index
$[*] / $['*']All children
$..*All descendants (recursive descent)
$[?@.x == 1]Filter — elements where condition holds
$[2:5]Slice

Array diffing

Default: index-zip

Without a declared identity, arrays are diffed position-by-position. Deleting the first element shifts every following element, producing a cascade of false replace ops — one per element that moved. This is correct for fixed-position arrays (tuples, coordinate pairs) but wrong for most everything else.

Identity-keyed: x-key

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.

constengine=newEngine({regions: [{id: 'us-east',capacity: 100},{id: 'eu-west',capacity: 80},{id: 'ap-south',capacity: 60},],},{schema: {type: 'object',properties: {regions: {type: 'array','x-key': 'id',items: {type: 'object'},},},},},);engine.delete('$.regions[0]');engine.diff();// [ { op: 'remove', path: "$['regions'][0]", value: { id: 'us-east', ... }, identity: 'us-east' } ]// one op — not a cascadeengine.replace('$.regions[0].capacity',90);engine.diff();// [// {// op: 'replace', path: "$['regions'][0]", identity: 'eu-west',// value: { id: 'eu-west', capacity: 90 }, oldValue: { id: 'eu-west', capacity: 80 },// displacement: 0,// changes: [{ op: 'replace', path: "$['regions'][0]['capacity']", oldValue: 80, value: 90 }]// }// ]

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.

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.

For a one-off without a schema:

engine.diff('$.regions',{key: 'id'});

Ordered arrays: x-ordered

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.

// schema: { 'x-key': 'id', 'x-ordered': true, ... }engine.delete('$.steps[0]');// removes step Aengine.diff();// [// { op: 'remove', path: "$['steps'][0]", identity: 'A', value: {...}},// { op: 'move', from: "$['steps'][1]", to: "$['steps'][0]", identity: 'B' },// { op: 'move', from: "$['steps'][2]", to: "$['steps'][1]", identity: 'C' },// ]

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.

To restore a displacement, pass the move op to restore() — it splices the element back to its base position.

Set semantics: x-key: '$self'

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.

constengine=newEngine({permissions: ['read','write','admin']},{schema: {type: 'object',properties: {permissions: {type: 'array','x-key': '$self',items: {type: 'string'}},},},},);engine.delete('$.permissions[1]');engine.diff();// [ { op: 'remove', path: "$['permissions'][1]", value: 'write', identity: 'write' } ]

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

Rendering full lists with includeUnchanged

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.

engine.diff('$.regions',{includeUnchanged: true});// returns add / replace / remove / move ops for changed elements,// plus { op: 'unchanged', ... } for every element that stayed the same

Reverting a diff op

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.

constops=engine.diff('$.regions');constremoveOp=ops.find(o=>o.op==='remove'&&o.identity==='us-east');engine.restore(removeOp);// re-inserts us-east at its original positionengine.undo();// un-does the restore
opwhat restore does
adddeletes the element
removere-inserts it at its original position
replacereverts the element to oldValue
movesplices it back to its base position

Scoped lenses

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.

constengine=newEngine({cars: [{color: 'red'}],trucks: [{color: 'red'}],});constcars=engine.getNodeEngine('$.cars');cars.replace('$[0].color','yellow');engine.draft.cars[0].color;// 'yellow'cars.draft[0].color;// 'yellow'

Subtree-scoped behavior on the lens:

  • cars.diff() — ops touching cars only, paths relative to $; each op also carries absolutePath with the full document path.
  • cars.accept() — commits the cars subtree into base. The trucks subtree is unaffected.
  • cars.undo() / cars.redo() — delegate to the parent; there is one shared history.

Lenses compose — getNodeEngine on a NodeEngine joins paths and creates a further-scoped lens against the same root parent.

LLM integration

createEngineTools builds a framework-neutral tool set that any LLM can call to read and edit the draft. The design is intentional: accept, decline, undo, and redo are not exposed — the LLM writes to draft, the human commits.

import{createEngineTools}from'@maxjay/patchwork/tools';consttools=createEngineTools(engine);// 9 tools: add, replace, delete, move, copy, revert, get, getValue, diff

Scope the LLM to a subtree by passing a NodeEngine:

constscoped=engine.getNodeEngine('$.userSettings');consttools=createEngineTools(scoped);// the model can only touch userSettings — the rest is unreachable

For MCP servers and agentic loops, see docs/llms.md.

Angular integration

@maxjay/patchwork/angular wraps an Engine in a reactive store built on Angular Signals (Angular 16+). All reads are exposed as Signals; mutations fire them automatically — no ChangeDetectorRef, no NgZone.

import{createPatchworkStore}from'@maxjay/patchwork/angular';
@Component({template: ` <input [value]="port()" (input)="setPort($event)"> <button (click)="store.accept()" [disabled]="!diff().length">Save</button> <button (click)="store.decline()" [disabled]="!diff().length">Discard</button> `,})classServerSettings{store=createPatchworkStore({server: {port: 8080}});port=this.store.getValue<number>('$.server.port');diff=this.store.diff();setPort(e: Event){this.store.replace('$.server.port',+(e.targetasHTMLInputElement).value);}}

See docs/angular.md for the full API, typed generics, change-highlighting UI, ephemeral form binding, scoped sub-stores, and service patterns.

API

Engine<T>

MemberDescription
new Engine(base, options?)Wrap a JSON value. options.schema enables identity-based array diffing.
.base / .draftThe committed and working views.
.add(path, value)Add or splice. Creates intermediate nodes on literal paths.
.replace(path, value)Replace at path. Wildcards replace all matches.
.delete(path)Remove at path.
.move(from, to)Move. Source must resolve to exactly one node.
.copy(from, to)Copy. Source must resolve to exactly one node.
.revert(path)Reset draft at path to base.
.get(path)Array<{ path, value }> — every match in draft with normalized paths.
.getBase(path)Same as get but reads from base.
.getValue(path)Strict single-match read from draft. Throws Error on multi-match; throws undefined on no-match.
.getValueBase(path)Same as getValue but reads from base.
.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.
.restore(op)Invert a DiffOp from diff() and push it onto the undo stack.
.undo() / .redo()Reverse / replay the last operation.
.accept()Promote draft into base. Reversible.
.decline()Reset draft from base. Reversible.
.exportChanges()DiffOp[] — structural mutations on the undo stack.
.importChanges(ops)Apply a DiffOp[] stream.
.getNodeEngine<U>(path)Scoped lens onto a subtree.
.beginEphemeral()Open an ephemeral session.
.commitEphemeral()Collapse the session into one undo entry.
.discardEphemeral()Unwind the session with no history trace.

NodeEngine<T>

MemberDescription
.base / .draftThe subtree from parent state.
.add / .replace / .delete / .move / .copy / .revertMutations forwarded to parent with paths rewritten.
.get(path) / .getBase(path)Reads draft / base in child frame, forwarded to parent.
.getValue(path) / .getValueBase(path)Strict single-match reads from draft / base.
.diff(path?, options?)Ops touching this subtree. Paths relative to child $; each op also carries absolutePath.
.accept()Commits this subtree into parent's base.
.decline()Resets this subtree in parent's draft from parent's base.
.undo() / .redo()Delegate to parent — one shared history.
.getNodeEngine<U>(path)Compose a further-scoped lens.

DiffOp

typeDiffOp=|{op: 'add';path: string;absolutePath?: string;value: JsonValue;identity?: JsonValue}|{op: 'replace';path: string;absolutePath?: string;oldValue?: JsonValue;value: JsonValue;identity?: JsonValue;displacement?: number;changes?: DiffOp[]}|{op: 'remove';path: string;absolutePath?: string;value?: JsonValue;identity?: JsonValue}|{op: 'move';from: string;to: string;identity?: JsonValue}|{op: 'copy';from: string;to: string}|{op: 'revert';path: string;absolutePath?: string}|{op: 'unchanged';path: string;absolutePath?: string;value: JsonValue;identity: JsonValue;displacement: number}
  • path — normalized JSONPath ($['key'][0]).
  • absolutePath — present on ops from NodeEngine.diff(). Contains the full document path while path is relative to the child's $.
  • 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.
  • oldValue — present on replace ops; the value that was there before.
  • displacement — on element-level replace and unchanged ops from ordered arrays (x-ordered: true). Integer delta: draftIndex − baseIndex. Zero if position did not change.
  • changes — on element-level replace ops. Flat list of field-level DiffOps describing what changed inside the element. Paths are absolute document paths.
  • unchanged op — only emitted when diff() is called with includeUnchanged: true.

Entrypoints

@maxjay/patchwork Engine, NodeEngine, DiffOp, OpType
@maxjay/patchwork/tools createEngineTools, Tool, EngineLike
@maxjay/patchwork/chat runAgentLoop, AgentMessage, ModelAdapter, NativeAdapter, PromptAdapter, toAgentTools
@maxjay/patchwork/mcp toMcpTools, handleMcpCall
@maxjay/patchwork/angular createPatchworkStore, fromEngine, PatchworkStore

For deeper coverage of the engine internals, see docs/engine.md. For LLM integration, adapters, and MCP, see docs/llms.md. For the Angular Signals adapter, see docs/angular.md.

Contributors

License

Apache-2.0

About

Patchwork is an AI-native TypeScript configuration framework for editors, admin tools, complex forms, and other structured JSON applications, with built-in agentic LLM tools and MCP support. RFC 9535 JSONPath querying, semantic diffs, undo/redo, scoped state, and framework integrations replace layers of custom state code with a few calls.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

patchwork

A JSON editing engine with base/draft, diff, undo, ephemeral sessions, and scoped lenses.

Motivation · Install · How it works · JSONPath querying · Array diffing · Scoped lenses · LLM integration · Angular · API


Motivation

Building a config editor, settings panel, or any UI over structured data means wiring up the same three concerns every time:

  • What changed? A diff between the saved state and the current edit.
  • Undo/redo that survives saves, across every operation.
  • Review before commit — inspect pending changes before they land.

Patchwork wraps any JSON document in an Engine that holds two views — base (committed) and draft (working) — and a stack of reversible operations. That single primitive covers all three.

Addressing uses JSONPath (RFC 9535) throughout. The same expression you write to read a value works identically to target a write or scope a diff. Diff output follows the JSON Patch (RFC 6902) operation vocabulary (add, replace, remove, move, copy) so it maps onto existing patch tooling and transports.

Install

npm install @maxjay/patchwork

How it works

1. Wrap any JSON document

import{Engine}from'@maxjay/patchwork';constengine=newEngine({server: {host: 'localhost',port: 8080},debug: false,});

Two independent deep clones are taken on construction — one as base, one as draft. They start identical and diverge as you mutate.

2. Mutate the draft

All mutations target draft. base doesn't move until you accept().

engine.replace('$.server.port',443);engine.add('$.server.ssl',true);engine.delete('$.debug');engine.draft;// { server: { host: 'localhost', port: 443, ssl: true }}engine.base;// { server: { host: 'localhost', port: 8080 }, debug: false }
MethodDescription
.add(path, value)Splice into arrays or set on objects. Creates intermediate nodes on literal paths.
.replace(path, value)Replace matched values. Wildcards replace all matches.
.delete(path)Remove at path. Splices arrays in place.
.move(from, to)Move a value. Source must resolve to exactly one node.
.copy(from, to)Copy a value. Source must resolve to exactly one node.
.revert(path)Reset draft at path back to whatever base has there.

3. See what changed

diff() returns the net structural difference between base and draft as a flat list of DiffOp objects. It's a snapshot comparison — independent of the undo stack.

engine.diff();// [// { op: 'replace', path: "$['server']['port']", oldValue: 8080, value: 443 },// { op: 'add', path: "$['server']['ssl']", value: true },// { op: 'remove', path: "$['debug']", value: false },// ]

Scope the diff with a JSONPath — resolves against both base and draft so deleted nodes are never missed:

engine.diff('$.server');// only ops touching the server subtreeengine.diff('$.items[*]');// only ops touching array elements

4. Undo anything

Every mutation pushes onto a single linear undo stack.

engine.undo();// reverse last opengine.redo();// replay it

accept() and decline() are themselves on the stack — committing doesn't erase history.

5. Commit or discard

engine.accept();// base ← clone(draft). draft untouched.engine.decline();// draft ← clone(base). pending edits discarded.

6. Ephemeral sessions

Some write patterns don't belong on the undo stack — streaming output updating a field on every chunk, hover previews, keystroke-level form binding. beginEphemeral opens a session where mutations proceed normally; commitEphemeral collapses the whole session into one undo entry.

engine.beginEphemeral();forawait(constchunkofstream){engine.replace('$.response',chunk);// draft updates live}engine.commitEphemeral();// one undo() snaps back to the pre-stream state

discardEphemeral() cancels instead — unwinds all session mutations, no history trace.

7. Export and replay

constops=engine.exportChanges();// DiffOp[] from the undo stackconstother=newEngine(originalDoc);other.importChanges(ops);// other.draft is now identical to engine.draft

JSONPath querying

Every operation in patchwork — reads, writes, diffs — accepts the same JSONPath (RFC 9535) expression. There is no separate addressing system for mutations vs queries.

// Readsengine.get('$.servers[*].host');// all hostsengine.get('$..*[?@.enabled == true]');// any enabled node, anywhereengine.getValue('$.config.timeout');// strict single-match// Writes — same pathsengine.replace('$.servers[*].host','prod');// replace all hostsengine.delete('$..*[?@.deprecated]');// remove any deprecated node// Diff — same pathsengine.diff('$.servers[*]');// ops touching any server

Paths returned by get() come back in normalized form ($['key'][0]) and can be fed straight back into replace, delete, etc.

Selector reference:

SyntaxMatches
$.key / $['key']Named property
$[0]Array index
$[*] / $['*']All children
$..*All descendants (recursive descent)
$[?@.x == 1]Filter — elements where condition holds
$[2:5]Slice

Array diffing

Default: index-zip

Without a declared identity, arrays are diffed position-by-position. Deleting the first element shifts every following element, producing a cascade of false replace ops — one per element that moved. This is correct for fixed-position arrays (tuples, coordinate pairs) but wrong for most everything else.

Identity-keyed: x-key

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.

constengine=newEngine({regions: [{id: 'us-east',capacity: 100},{id: 'eu-west',capacity: 80},{id: 'ap-south',capacity: 60},],},{schema: {type: 'object',properties: {regions: {type: 'array','x-key': 'id',items: {type: 'object'},},},},},);engine.delete('$.regions[0]');engine.diff();// [ { op: 'remove', path: "$['regions'][0]", value: { id: 'us-east', ... }, identity: 'us-east' } ]// one op — not a cascadeengine.replace('$.regions[0].capacity',90);engine.diff();// [// {// op: 'replace', path: "$['regions'][0]", identity: 'eu-west',// value: { id: 'eu-west', capacity: 90 }, oldValue: { id: 'eu-west', capacity: 80 },// displacement: 0,// changes: [{ op: 'replace', path: "$['regions'][0]['capacity']", oldValue: 80, value: 90 }]// }// ]

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.

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.

For a one-off without a schema:

engine.diff('$.regions',{key: 'id'});

Ordered arrays: x-ordered

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.

// schema: { 'x-key': 'id', 'x-ordered': true, ... }engine.delete('$.steps[0]');// removes step Aengine.diff();// [// { op: 'remove', path: "$['steps'][0]", identity: 'A', value: {...}},// { op: 'move', from: "$['steps'][1]", to: "$['steps'][0]", identity: 'B' },// { op: 'move', from: "$['steps'][2]", to: "$['steps'][1]", identity: 'C' },// ]

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.

To restore a displacement, pass the move op to restore() — it splices the element back to its base position.

Set semantics: x-key: '$self'

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.

constengine=newEngine({permissions: ['read','write','admin']},{schema: {type: 'object',properties: {permissions: {type: 'array','x-key': '$self',items: {type: 'string'}},},},},);engine.delete('$.permissions[1]');engine.diff();// [ { op: 'remove', path: "$['permissions'][1]", value: 'write', identity: 'write' } ]

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

Rendering full lists with includeUnchanged

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.

engine.diff('$.regions',{includeUnchanged: true});// returns add / replace / remove / move ops for changed elements,// plus { op: 'unchanged', ... } for every element that stayed the same

Reverting a diff op

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.

constops=engine.diff('$.regions');constremoveOp=ops.find(o=>o.op==='remove'&&o.identity==='us-east');engine.restore(removeOp);// re-inserts us-east at its original positionengine.undo();// un-does the restore
opwhat restore does
adddeletes the element
removere-inserts it at its original position
replacereverts the element to oldValue
movesplices it back to its base position

Scoped lenses

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.

constengine=newEngine({cars: [{color: 'red'}],trucks: [{color: 'red'}],});constcars=engine.getNodeEngine('$.cars');cars.replace('$[0].color','yellow');engine.draft.cars[0].color;// 'yellow'cars.draft[0].color;// 'yellow'

Subtree-scoped behavior on the lens:

  • cars.diff() — ops touching cars only, paths relative to $; each op also carries absolutePath with the full document path.
  • cars.accept() — commits the cars subtree into base. The trucks subtree is unaffected.
  • cars.undo() / cars.redo() — delegate to the parent; there is one shared history.

Lenses compose — getNodeEngine on a NodeEngine joins paths and creates a further-scoped lens against the same root parent.

LLM integration

createEngineTools builds a framework-neutral tool set that any LLM can call to read and edit the draft. The design is intentional: accept, decline, undo, and redo are not exposed — the LLM writes to draft, the human commits.

import{createEngineTools}from'@maxjay/patchwork/tools';consttools=createEngineTools(engine);// 9 tools: add, replace, delete, move, copy, revert, get, getValue, diff

Scope the LLM to a subtree by passing a NodeEngine:

constscoped=engine.getNodeEngine('$.userSettings');consttools=createEngineTools(scoped);// the model can only touch userSettings — the rest is unreachable

For MCP servers and agentic loops, see docs/llms.md.

Angular integration

@maxjay/patchwork/angular wraps an Engine in a reactive store built on Angular Signals (Angular 16+). All reads are exposed as Signals; mutations fire them automatically — no ChangeDetectorRef, no NgZone.

import{createPatchworkStore}from'@maxjay/patchwork/angular';
@Component({template: ` <input [value]="port()" (input)="setPort($event)"> <button (click)="store.accept()" [disabled]="!diff().length">Save</button> <button (click)="store.decline()" [disabled]="!diff().length">Discard</button> `,})classServerSettings{store=createPatchworkStore({server: {port: 8080}});port=this.store.getValue<number>('$.server.port');diff=this.store.diff();setPort(e: Event){this.store.replace('$.server.port',+(e.targetasHTMLInputElement).value);}}

See docs/angular.md for the full API, typed generics, change-highlighting UI, ephemeral form binding, scoped sub-stores, and service patterns.

API

Engine<T>

MemberDescription
new Engine(base, options?)Wrap a JSON value. options.schema enables identity-based array diffing.
.base / .draftThe committed and working views.
.add(path, value)Add or splice. Creates intermediate nodes on literal paths.
.replace(path, value)Replace at path. Wildcards replace all matches.
.delete(path)Remove at path.
.move(from, to)Move. Source must resolve to exactly one node.
.copy(from, to)Copy. Source must resolve to exactly one node.
.revert(path)Reset draft at path to base.
.get(path)Array<{ path, value }> — every match in draft with normalized paths.
.getBase(path)Same as get but reads from base.
.getValue(path)Strict single-match read from draft. Throws Error on multi-match; throws undefined on no-match.
.getValueBase(path)Same as getValue but reads from base.
.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.
.restore(op)Invert a DiffOp from diff() and push it onto the undo stack.
.undo() / .redo()Reverse / replay the last operation.
.accept()Promote draft into base. Reversible.
.decline()Reset draft from base. Reversible.
.exportChanges()DiffOp[] — structural mutations on the undo stack.
.importChanges(ops)Apply a DiffOp[] stream.
.getNodeEngine<U>(path)Scoped lens onto a subtree.
.beginEphemeral()Open an ephemeral session.
.commitEphemeral()Collapse the session into one undo entry.
.discardEphemeral()Unwind the session with no history trace.

NodeEngine<T>

MemberDescription
.base / .draftThe subtree from parent state.
.add / .replace / .delete / .move / .copy / .revertMutations forwarded to parent with paths rewritten.
.get(path) / .getBase(path)Reads draft / base in child frame, forwarded to parent.
.getValue(path) / .getValueBase(path)Strict single-match reads from draft / base.
.diff(path?, options?)Ops touching this subtree. Paths relative to child $; each op also carries absolutePath.
.accept()Commits this subtree into parent's base.
.decline()Resets this subtree in parent's draft from parent's base.
.undo() / .redo()Delegate to parent — one shared history.
.getNodeEngine<U>(path)Compose a further-scoped lens.

DiffOp

typeDiffOp=|{op: 'add';path: string;absolutePath?: string;value: JsonValue;identity?: JsonValue}|{op: 'replace';path: string;absolutePath?: string;oldValue?: JsonValue;value: JsonValue;identity?: JsonValue;displacement?: number;changes?: DiffOp[]}|{op: 'remove';path: string;absolutePath?: string;value?: JsonValue;identity?: JsonValue}|{op: 'move';from: string;to: string;identity?: JsonValue}|{op: 'copy';from: string;to: string}|{op: 'revert';path: string;absolutePath?: string}|{op: 'unchanged';path: string;absolutePath?: string;value: JsonValue;identity: JsonValue;displacement: number}
  • path — normalized JSONPath ($['key'][0]).
  • absolutePath — present on ops from NodeEngine.diff(). Contains the full document path while path is relative to the child's $.
  • 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.
  • oldValue — present on replace ops; the value that was there before.
  • displacement — on element-level replace and unchanged ops from ordered arrays (x-ordered: true). Integer delta: draftIndex − baseIndex. Zero if position did not change.
  • changes — on element-level replace ops. Flat list of field-level DiffOps describing what changed inside the element. Paths are absolute document paths.
  • unchanged op — only emitted when diff() is called with includeUnchanged: true.

Entrypoints

@maxjay/patchwork Engine, NodeEngine, DiffOp, OpType
@maxjay/patchwork/tools createEngineTools, Tool, EngineLike
@maxjay/patchwork/chat runAgentLoop, AgentMessage, ModelAdapter, NativeAdapter, PromptAdapter, toAgentTools
@maxjay/patchwork/mcp toMcpTools, handleMcpCall
@maxjay/patchwork/angular createPatchworkStore, fromEngine, PatchworkStore

For deeper coverage of the engine internals, see docs/engine.md. For LLM integration, adapters, and MCP, see docs/llms.md. For the Angular Signals adapter, see docs/angular.md.

Contributors

License

Apache-2.0

About

Patchwork is an AI-native TypeScript configuration framework for editors, admin tools, complex forms, and other structured JSON applications, with built-in agentic LLM tools and MCP support. RFC 9535 JSONPath querying, semantic diffs, undo/redo, scoped state, and framework integrations replace layers of custom state code with a few calls.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

patchwork

A JSON editing engine with base/draft, diff, undo, ephemeral sessions, and scoped lenses.

Motivation · Install · How it works · JSONPath querying · Array diffing · Scoped lenses · LLM integration · Angular · API


Motivation

Building a config editor, settings panel, or any UI over structured data means wiring up the same three concerns every time:

  • What changed? A diff between the saved state and the current edit.
  • Undo/redo that survives saves, across every operation.
  • Review before commit — inspect pending changes before they land.

Patchwork wraps any JSON document in an Engine that holds two views — base (committed) and draft (working) — and a stack of reversible operations. That single primitive covers all three.

Addressing uses JSONPath (RFC 9535) throughout. The same expression you write to read a value works identically to target a write or scope a diff. Diff output follows the JSON Patch (RFC 6902) operation vocabulary (add, replace, remove, move, copy) so it maps onto existing patch tooling and transports.

Install

npm install @maxjay/patchwork

How it works

1. Wrap any JSON document

import{Engine}from'@maxjay/patchwork';constengine=newEngine({server: {host: 'localhost',port: 8080},debug: false,});

Two independent deep clones are taken on construction — one as base, one as draft. They start identical and diverge as you mutate.

2. Mutate the draft

All mutations target draft. base doesn't move until you accept().

engine.replace('$.server.port',443);engine.add('$.server.ssl',true);engine.delete('$.debug');engine.draft;// { server: { host: 'localhost', port: 443, ssl: true }}engine.base;// { server: { host: 'localhost', port: 8080 }, debug: false }
MethodDescription
.add(path, value)Splice into arrays or set on objects. Creates intermediate nodes on literal paths.
.replace(path, value)Replace matched values. Wildcards replace all matches.
.delete(path)Remove at path. Splices arrays in place.
.move(from, to)Move a value. Source must resolve to exactly one node.
.copy(from, to)Copy a value. Source must resolve to exactly one node.
.revert(path)Reset draft at path back to whatever base has there.

3. See what changed

diff() returns the net structural difference between base and draft as a flat list of DiffOp objects. It's a snapshot comparison — independent of the undo stack.

engine.diff();// [// { op: 'replace', path: "$['server']['port']", oldValue: 8080, value: 443 },// { op: 'add', path: "$['server']['ssl']", value: true },// { op: 'remove', path: "$['debug']", value: false },// ]

Scope the diff with a JSONPath — resolves against both base and draft so deleted nodes are never missed:

engine.diff('$.server');// only ops touching the server subtreeengine.diff('$.items[*]');// only ops touching array elements

4. Undo anything

Every mutation pushes onto a single linear undo stack.

engine.undo();// reverse last opengine.redo();// replay it

accept() and decline() are themselves on the stack — committing doesn't erase history.

5. Commit or discard

engine.accept();// base ← clone(draft). draft untouched.engine.decline();// draft ← clone(base). pending edits discarded.

6. Ephemeral sessions

Some write patterns don't belong on the undo stack — streaming output updating a field on every chunk, hover previews, keystroke-level form binding. beginEphemeral opens a session where mutations proceed normally; commitEphemeral collapses the whole session into one undo entry.

engine.beginEphemeral();forawait(constchunkofstream){engine.replace('$.response',chunk);// draft updates live}engine.commitEphemeral();// one undo() snaps back to the pre-stream state

discardEphemeral() cancels instead — unwinds all session mutations, no history trace.

7. Export and replay

constops=engine.exportChanges();// DiffOp[] from the undo stackconstother=newEngine(originalDoc);other.importChanges(ops);// other.draft is now identical to engine.draft

JSONPath querying

Every operation in patchwork — reads, writes, diffs — accepts the same JSONPath (RFC 9535) expression. There is no separate addressing system for mutations vs queries.

// Readsengine.get('$.servers[*].host');// all hostsengine.get('$..*[?@.enabled == true]');// any enabled node, anywhereengine.getValue('$.config.timeout');// strict single-match// Writes — same pathsengine.replace('$.servers[*].host','prod');// replace all hostsengine.delete('$..*[?@.deprecated]');// remove any deprecated node// Diff — same pathsengine.diff('$.servers[*]');// ops touching any server

Paths returned by get() come back in normalized form ($['key'][0]) and can be fed straight back into replace, delete, etc.

Selector reference:

SyntaxMatches
$.key / $['key']Named property
$[0]Array index
$[*] / $['*']All children
$..*All descendants (recursive descent)
$[?@.x == 1]Filter — elements where condition holds
$[2:5]Slice

Array diffing

Default: index-zip

Without a declared identity, arrays are diffed position-by-position. Deleting the first element shifts every following element, producing a cascade of false replace ops — one per element that moved. This is correct for fixed-position arrays (tuples, coordinate pairs) but wrong for most everything else.

Identity-keyed: x-key

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.

constengine=newEngine({regions: [{id: 'us-east',capacity: 100},{id: 'eu-west',capacity: 80},{id: 'ap-south',capacity: 60},],},{schema: {type: 'object',properties: {regions: {type: 'array','x-key': 'id',items: {type: 'object'},},},},},);engine.delete('$.regions[0]');engine.diff();// [ { op: 'remove', path: "$['regions'][0]", value: { id: 'us-east', ... }, identity: 'us-east' } ]// one op — not a cascadeengine.replace('$.regions[0].capacity',90);engine.diff();// [// {// op: 'replace', path: "$['regions'][0]", identity: 'eu-west',// value: { id: 'eu-west', capacity: 90 }, oldValue: { id: 'eu-west', capacity: 80 },// displacement: 0,// changes: [{ op: 'replace', path: "$['regions'][0]['capacity']", oldValue: 80, value: 90 }]// }// ]

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.

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.

For a one-off without a schema:

engine.diff('$.regions',{key: 'id'});

Ordered arrays: x-ordered

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.

// schema: { 'x-key': 'id', 'x-ordered': true, ... }engine.delete('$.steps[0]');// removes step Aengine.diff();// [// { op: 'remove', path: "$['steps'][0]", identity: 'A', value: {...}},// { op: 'move', from: "$['steps'][1]", to: "$['steps'][0]", identity: 'B' },// { op: 'move', from: "$['steps'][2]", to: "$['steps'][1]", identity: 'C' },// ]

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.

To restore a displacement, pass the move op to restore() — it splices the element back to its base position.

Set semantics: x-key: '$self'

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.

constengine=newEngine({permissions: ['read','write','admin']},{schema: {type: 'object',properties: {permissions: {type: 'array','x-key': '$self',items: {type: 'string'}},},},},);engine.delete('$.permissions[1]');engine.diff();// [ { op: 'remove', path: "$['permissions'][1]", value: 'write', identity: 'write' } ]

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

Rendering full lists with includeUnchanged

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.

engine.diff('$.regions',{includeUnchanged: true});// returns add / replace / remove / move ops for changed elements,// plus { op: 'unchanged', ... } for every element that stayed the same

Reverting a diff op

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.

constops=engine.diff('$.regions');constremoveOp=ops.find(o=>o.op==='remove'&&o.identity==='us-east');engine.restore(removeOp);// re-inserts us-east at its original positionengine.undo();// un-does the restore
opwhat restore does
adddeletes the element
removere-inserts it at its original position
replacereverts the element to oldValue
movesplices it back to its base position

Scoped lenses

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.

constengine=newEngine({cars: [{color: 'red'}],trucks: [{color: 'red'}],});constcars=engine.getNodeEngine('$.cars');cars.replace('$[0].color','yellow');engine.draft.cars[0].color;// 'yellow'cars.draft[0].color;// 'yellow'

Subtree-scoped behavior on the lens:

  • cars.diff() — ops touching cars only, paths relative to $; each op also carries absolutePath with the full document path.
  • cars.accept() — commits the cars subtree into base. The trucks subtree is unaffected.
  • cars.undo() / cars.redo() — delegate to the parent; there is one shared history.

Lenses compose — getNodeEngine on a NodeEngine joins paths and creates a further-scoped lens against the same root parent.

LLM integration

createEngineTools builds a framework-neutral tool set that any LLM can call to read and edit the draft. The design is intentional: accept, decline, undo, and redo are not exposed — the LLM writes to draft, the human commits.

import{createEngineTools}from'@maxjay/patchwork/tools';consttools=createEngineTools(engine);// 9 tools: add, replace, delete, move, copy, revert, get, getValue, diff

Scope the LLM to a subtree by passing a NodeEngine:

constscoped=engine.getNodeEngine('$.userSettings');consttools=createEngineTools(scoped);// the model can only touch userSettings — the rest is unreachable

For MCP servers and agentic loops, see docs/llms.md.

Angular integration

@maxjay/patchwork/angular wraps an Engine in a reactive store built on Angular Signals (Angular 16+). All reads are exposed as Signals; mutations fire them automatically — no ChangeDetectorRef, no NgZone.

import{createPatchworkStore}from'@maxjay/patchwork/angular';
@Component({template: ` <input [value]="port()" (input)="setPort($event)"> <button (click)="store.accept()" [disabled]="!diff().length">Save</button> <button (click)="store.decline()" [disabled]="!diff().length">Discard</button> `,})classServerSettings{store=createPatchworkStore({server: {port: 8080}});port=this.store.getValue<number>('$.server.port');diff=this.store.diff();setPort(e: Event){this.store.replace('$.server.port',+(e.targetasHTMLInputElement).value);}}

See docs/angular.md for the full API, typed generics, change-highlighting UI, ephemeral form binding, scoped sub-stores, and service patterns.

API

Engine<T>

MemberDescription
new Engine(base, options?)Wrap a JSON value. options.schema enables identity-based array diffing.
.base / .draftThe committed and working views.
.add(path, value)Add or splice. Creates intermediate nodes on literal paths.
.replace(path, value)Replace at path. Wildcards replace all matches.
.delete(path)Remove at path.
.move(from, to)Move. Source must resolve to exactly one node.
.copy(from, to)Copy. Source must resolve to exactly one node.
.revert(path)Reset draft at path to base.
.get(path)Array<{ path, value }> — every match in draft with normalized paths.
.getBase(path)Same as get but reads from base.
.getValue(path)Strict single-match read from draft. Throws Error on multi-match; throws undefined on no-match.
.getValueBase(path)Same as getValue but reads from base.
.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.
.restore(op)Invert a DiffOp from diff() and push it onto the undo stack.
.undo() / .redo()Reverse / replay the last operation.
.accept()Promote draft into base. Reversible.
.decline()Reset draft from base. Reversible.
.exportChanges()DiffOp[] — structural mutations on the undo stack.
.importChanges(ops)Apply a DiffOp[] stream.
.getNodeEngine<U>(path)Scoped lens onto a subtree.
.beginEphemeral()Open an ephemeral session.
.commitEphemeral()Collapse the session into one undo entry.
.discardEphemeral()Unwind the session with no history trace.

NodeEngine<T>

MemberDescription
.base / .draftThe subtree from parent state.
.add / .replace / .delete / .move / .copy / .revertMutations forwarded to parent with paths rewritten.
.get(path) / .getBase(path)Reads draft / base in child frame, forwarded to parent.
.getValue(path) / .getValueBase(path)Strict single-match reads from draft / base.
.diff(path?, options?)Ops touching this subtree. Paths relative to child $; each op also carries absolutePath.
.accept()Commits this subtree into parent's base.
.decline()Resets this subtree in parent's draft from parent's base.
.undo() / .redo()Delegate to parent — one shared history.
.getNodeEngine<U>(path)Compose a further-scoped lens.

DiffOp

typeDiffOp=|{op: 'add';path: string;absolutePath?: string;value: JsonValue;identity?: JsonValue}|{op: 'replace';path: string;absolutePath?: string;oldValue?: JsonValue;value: JsonValue;identity?: JsonValue;displacement?: number;changes?: DiffOp[]}|{op: 'remove';path: string;absolutePath?: string;value?: JsonValue;identity?: JsonValue}|{op: 'move';from: string;to: string;identity?: JsonValue}|{op: 'copy';from: string;to: string}|{op: 'revert';path: string;absolutePath?: string}|{op: 'unchanged';path: string;absolutePath?: string;value: JsonValue;identity: JsonValue;displacement: number}
  • path — normalized JSONPath ($['key'][0]).
  • absolutePath — present on ops from NodeEngine.diff(). Contains the full document path while path is relative to the child's $.
  • 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.
  • oldValue — present on replace ops; the value that was there before.
  • displacement — on element-level replace and unchanged ops from ordered arrays (x-ordered: true). Integer delta: draftIndex − baseIndex. Zero if position did not change.
  • changes — on element-level replace ops. Flat list of field-level DiffOps describing what changed inside the element. Paths are absolute document paths.
  • unchanged op — only emitted when diff() is called with includeUnchanged: true.

Entrypoints

@maxjay/patchwork Engine, NodeEngine, DiffOp, OpType
@maxjay/patchwork/tools createEngineTools, Tool, EngineLike
@maxjay/patchwork/chat runAgentLoop, AgentMessage, ModelAdapter, NativeAdapter, PromptAdapter, toAgentTools
@maxjay/patchwork/mcp toMcpTools, handleMcpCall
@maxjay/patchwork/angular createPatchworkStore, fromEngine, PatchworkStore

For deeper coverage of the engine internals, see docs/engine.md. For LLM integration, adapters, and MCP, see docs/llms.md. For the Angular Signals adapter, see docs/angular.md.

Contributors

License

Apache-2.0

About

Patchwork is an AI-native TypeScript configuration framework for editors, admin tools, complex forms, and other structured JSON applications, with built-in agentic LLM tools and MCP support. RFC 9535 JSONPath querying, semantic diffs, undo/redo, scoped state, and framework integrations replace layers of custom state code with a few calls.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

patchwork

A JSON editing engine with base/draft, diff, undo, ephemeral sessions, and scoped lenses.

Motivation · Install · How it works · JSONPath querying · Array diffing · Scoped lenses · LLM integration · Angular · API


Motivation

Building a config editor, settings panel, or any UI over structured data means wiring up the same three concerns every time:

  • What changed? A diff between the saved state and the current edit.
  • Undo/redo that survives saves, across every operation.
  • Review before commit — inspect pending changes before they land.

Patchwork wraps any JSON document in an Engine that holds two views — base (committed) and draft (working) — and a stack of reversible operations. That single primitive covers all three.

Addressing uses JSONPath (RFC 9535) throughout. The same expression you write to read a value works identically to target a write or scope a diff. Diff output follows the JSON Patch (RFC 6902) operation vocabulary (add, replace, remove, move, copy) so it maps onto existing patch tooling and transports.

Install

npm install @maxjay/patchwork

How it works

1. Wrap any JSON document

import{Engine}from'@maxjay/patchwork';constengine=newEngine({server: {host: 'localhost',port: 8080},debug: false,});

Two independent deep clones are taken on construction — one as base, one as draft. They start identical and diverge as you mutate.

2. Mutate the draft

All mutations target draft. base doesn't move until you accept().

engine.replace('$.server.port',443);engine.add('$.server.ssl',true);engine.delete('$.debug');engine.draft;// { server: { host: 'localhost', port: 443, ssl: true }}engine.base;// { server: { host: 'localhost', port: 8080 }, debug: false }
MethodDescription
.add(path, value)Splice into arrays or set on objects. Creates intermediate nodes on literal paths.
.replace(path, value)Replace matched values. Wildcards replace all matches.
.delete(path)Remove at path. Splices arrays in place.
.move(from, to)Move a value. Source must resolve to exactly one node.
.copy(from, to)Copy a value. Source must resolve to exactly one node.
.revert(path)Reset draft at path back to whatever base has there.

3. See what changed

diff() returns the net structural difference between base and draft as a flat list of DiffOp objects. It's a snapshot comparison — independent of the undo stack.

engine.diff();// [// { op: 'replace', path: "$['server']['port']", oldValue: 8080, value: 443 },// { op: 'add', path: "$['server']['ssl']", value: true },// { op: 'remove', path: "$['debug']", value: false },// ]

Scope the diff with a JSONPath — resolves against both base and draft so deleted nodes are never missed:

engine.diff('$.server');// only ops touching the server subtreeengine.diff('$.items[*]');// only ops touching array elements

4. Undo anything

Every mutation pushes onto a single linear undo stack.

engine.undo();// reverse last opengine.redo();// replay it

accept() and decline() are themselves on the stack — committing doesn't erase history.

5. Commit or discard

engine.accept();// base ← clone(draft). draft untouched.engine.decline();// draft ← clone(base). pending edits discarded.

6. Ephemeral sessions

Some write patterns don't belong on the undo stack — streaming output updating a field on every chunk, hover previews, keystroke-level form binding. beginEphemeral opens a session where mutations proceed normally; commitEphemeral collapses the whole session into one undo entry.

engine.beginEphemeral();forawait(constchunkofstream){engine.replace('$.response',chunk);// draft updates live}engine.commitEphemeral();// one undo() snaps back to the pre-stream state

discardEphemeral() cancels instead — unwinds all session mutations, no history trace.

7. Export and replay

constops=engine.exportChanges();// DiffOp[] from the undo stackconstother=newEngine(originalDoc);other.importChanges(ops);// other.draft is now identical to engine.draft

JSONPath querying

Every operation in patchwork — reads, writes, diffs — accepts the same JSONPath (RFC 9535) expression. There is no separate addressing system for mutations vs queries.

// Readsengine.get('$.servers[*].host');// all hostsengine.get('$..*[?@.enabled == true]');// any enabled node, anywhereengine.getValue('$.config.timeout');// strict single-match// Writes — same pathsengine.replace('$.servers[*].host','prod');// replace all hostsengine.delete('$..*[?@.deprecated]');// remove any deprecated node// Diff — same pathsengine.diff('$.servers[*]');// ops touching any server

Paths returned by get() come back in normalized form ($['key'][0]) and can be fed straight back into replace, delete, etc.

Selector reference:

SyntaxMatches
$.key / $['key']Named property
$[0]Array index
$[*] / $['*']All children
$..*All descendants (recursive descent)
$[?@.x == 1]Filter — elements where condition holds
$[2:5]Slice

Array diffing

Default: index-zip

Without a declared identity, arrays are diffed position-by-position. Deleting the first element shifts every following element, producing a cascade of false replace ops — one per element that moved. This is correct for fixed-position arrays (tuples, coordinate pairs) but wrong for most everything else.

Identity-keyed: x-key

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.

constengine=newEngine({regions: [{id: 'us-east',capacity: 100},{id: 'eu-west',capacity: 80},{id: 'ap-south',capacity: 60},],},{schema: {type: 'object',properties: {regions: {type: 'array','x-key': 'id',items: {type: 'object'},},},},},);engine.delete('$.regions[0]');engine.diff();// [ { op: 'remove', path: "$['regions'][0]", value: { id: 'us-east', ... }, identity: 'us-east' } ]// one op — not a cascadeengine.replace('$.regions[0].capacity',90);engine.diff();// [// {// op: 'replace', path: "$['regions'][0]", identity: 'eu-west',// value: { id: 'eu-west', capacity: 90 }, oldValue: { id: 'eu-west', capacity: 80 },// displacement: 0,// changes: [{ op: 'replace', path: "$['regions'][0]['capacity']", oldValue: 80, value: 90 }]// }// ]

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.

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.

For a one-off without a schema:

engine.diff('$.regions',{key: 'id'});

Ordered arrays: x-ordered

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.

// schema: { 'x-key': 'id', 'x-ordered': true, ... }engine.delete('$.steps[0]');// removes step Aengine.diff();// [// { op: 'remove', path: "$['steps'][0]", identity: 'A', value: {...}},// { op: 'move', from: "$['steps'][1]", to: "$['steps'][0]", identity: 'B' },// { op: 'move', from: "$['steps'][2]", to: "$['steps'][1]", identity: 'C' },// ]

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.

To restore a displacement, pass the move op to restore() — it splices the element back to its base position.

Set semantics: x-key: '$self'

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.

constengine=newEngine({permissions: ['read','write','admin']},{schema: {type: 'object',properties: {permissions: {type: 'array','x-key': '$self',items: {type: 'string'}},},},},);engine.delete('$.permissions[1]');engine.diff();// [ { op: 'remove', path: "$['permissions'][1]", value: 'write', identity: 'write' } ]

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

Rendering full lists with includeUnchanged

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.

engine.diff('$.regions',{includeUnchanged: true});// returns add / replace / remove / move ops for changed elements,// plus { op: 'unchanged', ... } for every element that stayed the same

Reverting a diff op

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.

constops=engine.diff('$.regions');constremoveOp=ops.find(o=>o.op==='remove'&&o.identity==='us-east');engine.restore(removeOp);// re-inserts us-east at its original positionengine.undo();// un-does the restore
opwhat restore does
adddeletes the element
removere-inserts it at its original position
replacereverts the element to oldValue
movesplices it back to its base position

Scoped lenses

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.

constengine=newEngine({cars: [{color: 'red'}],trucks: [{color: 'red'}],});constcars=engine.getNodeEngine('$.cars');cars.replace('$[0].color','yellow');engine.draft.cars[0].color;// 'yellow'cars.draft[0].color;// 'yellow'

Subtree-scoped behavior on the lens:

  • cars.diff() — ops touching cars only, paths relative to $; each op also carries absolutePath with the full document path.
  • cars.accept() — commits the cars subtree into base. The trucks subtree is unaffected.
  • cars.undo() / cars.redo() — delegate to the parent; there is one shared history.

Lenses compose — getNodeEngine on a NodeEngine joins paths and creates a further-scoped lens against the same root parent.

LLM integration

createEngineTools builds a framework-neutral tool set that any LLM can call to read and edit the draft. The design is intentional: accept, decline, undo, and redo are not exposed — the LLM writes to draft, the human commits.

import{createEngineTools}from'@maxjay/patchwork/tools';consttools=createEngineTools(engine);// 9 tools: add, replace, delete, move, copy, revert, get, getValue, diff

Scope the LLM to a subtree by passing a NodeEngine:

constscoped=engine.getNodeEngine('$.userSettings');consttools=createEngineTools(scoped);// the model can only touch userSettings — the rest is unreachable

For MCP servers and agentic loops, see docs/llms.md.

Angular integration

@maxjay/patchwork/angular wraps an Engine in a reactive store built on Angular Signals (Angular 16+). All reads are exposed as Signals; mutations fire them automatically — no ChangeDetectorRef, no NgZone.

import{createPatchworkStore}from'@maxjay/patchwork/angular';
@Component({template: ` <input [value]="port()" (input)="setPort($event)"> <button (click)="store.accept()" [disabled]="!diff().length">Save</button> <button (click)="store.decline()" [disabled]="!diff().length">Discard</button> `,})classServerSettings{store=createPatchworkStore({server: {port: 8080}});port=this.store.getValue<number>('$.server.port');diff=this.store.diff();setPort(e: Event){this.store.replace('$.server.port',+(e.targetasHTMLInputElement).value);}}

See docs/angular.md for the full API, typed generics, change-highlighting UI, ephemeral form binding, scoped sub-stores, and service patterns.

API

Engine<T>

MemberDescription
new Engine(base, options?)Wrap a JSON value. options.schema enables identity-based array diffing.
.base / .draftThe committed and working views.
.add(path, value)Add or splice. Creates intermediate nodes on literal paths.
.replace(path, value)Replace at path. Wildcards replace all matches.
.delete(path)Remove at path.
.move(from, to)Move. Source must resolve to exactly one node.
.copy(from, to)Copy. Source must resolve to exactly one node.
.revert(path)Reset draft at path to base.
.get(path)Array<{ path, value }> — every match in draft with normalized paths.
.getBase(path)Same as get but reads from base.
.getValue(path)Strict single-match read from draft. Throws Error on multi-match; throws undefined on no-match.
.getValueBase(path)Same as getValue but reads from base.
.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.
.restore(op)Invert a DiffOp from diff() and push it onto the undo stack.
.undo() / .redo()Reverse / replay the last operation.
.accept()Promote draft into base. Reversible.
.decline()Reset draft from base. Reversible.
.exportChanges()DiffOp[] — structural mutations on the undo stack.
.importChanges(ops)Apply a DiffOp[] stream.
.getNodeEngine<U>(path)Scoped lens onto a subtree.
.beginEphemeral()Open an ephemeral session.
.commitEphemeral()Collapse the session into one undo entry.
.discardEphemeral()Unwind the session with no history trace.

NodeEngine<T>

MemberDescription
.base / .draftThe subtree from parent state.
.add / .replace / .delete / .move / .copy / .revertMutations forwarded to parent with paths rewritten.
.get(path) / .getBase(path)Reads draft / base in child frame, forwarded to parent.
.getValue(path) / .getValueBase(path)Strict single-match reads from draft / base.
.diff(path?, options?)Ops touching this subtree. Paths relative to child $; each op also carries absolutePath.
.accept()Commits this subtree into parent's base.
.decline()Resets this subtree in parent's draft from parent's base.
.undo() / .redo()Delegate to parent — one shared history.
.getNodeEngine<U>(path)Compose a further-scoped lens.

DiffOp

typeDiffOp=|{op: 'add';path: string;absolutePath?: string;value: JsonValue;identity?: JsonValue}|{op: 'replace';path: string;absolutePath?: string;oldValue?: JsonValue;value: JsonValue;identity?: JsonValue;displacement?: number;changes?: DiffOp[]}|{op: 'remove';path: string;absolutePath?: string;value?: JsonValue;identity?: JsonValue}|{op: 'move';from: string;to: string;identity?: JsonValue}|{op: 'copy';from: string;to: string}|{op: 'revert';path: string;absolutePath?: string}|{op: 'unchanged';path: string;absolutePath?: string;value: JsonValue;identity: JsonValue;displacement: number}
  • path — normalized JSONPath ($['key'][0]).
  • absolutePath — present on ops from NodeEngine.diff(). Contains the full document path while path is relative to the child's $.
  • 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.
  • oldValue — present on replace ops; the value that was there before.
  • displacement — on element-level replace and unchanged ops from ordered arrays (x-ordered: true). Integer delta: draftIndex − baseIndex. Zero if position did not change.
  • changes — on element-level replace ops. Flat list of field-level DiffOps describing what changed inside the element. Paths are absolute document paths.
  • unchanged op — only emitted when diff() is called with includeUnchanged: true.

Entrypoints

@maxjay/patchwork Engine, NodeEngine, DiffOp, OpType
@maxjay/patchwork/tools createEngineTools, Tool, EngineLike
@maxjay/patchwork/chat runAgentLoop, AgentMessage, ModelAdapter, NativeAdapter, PromptAdapter, toAgentTools
@maxjay/patchwork/mcp toMcpTools, handleMcpCall
@maxjay/patchwork/angular createPatchworkStore, fromEngine, PatchworkStore

For deeper coverage of the engine internals, see docs/engine.md. For LLM integration, adapters, and MCP, see docs/llms.md. For the Angular Signals adapter, see docs/angular.md.

Contributors

License

Apache-2.0

About

Patchwork is an AI-native TypeScript configuration framework for editors, admin tools, complex forms, and other structured JSON applications, with built-in agentic LLM tools and MCP support. RFC 9535 JSONPath querying, semantic diffs, undo/redo, scoped state, and framework integrations replace layers of custom state code with a few calls.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

patchwork

A JSON editing engine with base/draft, diff, undo, ephemeral sessions, and scoped lenses.

Motivation · Install · How it works · JSONPath querying · Array diffing · Scoped lenses · LLM integration · Angular · API


Motivation

Building a config editor, settings panel, or any UI over structured data means wiring up the same three concerns every time:

  • What changed? A diff between the saved state and the current edit.
  • Undo/redo that survives saves, across every operation.
  • Review before commit — inspect pending changes before they land.

Patchwork wraps any JSON document in an Engine that holds two views — base (committed) and draft (working) — and a stack of reversible operations. That single primitive covers all three.

Addressing uses JSONPath (RFC 9535) throughout. The same expression you write to read a value works identically to target a write or scope a diff. Diff output follows the JSON Patch (RFC 6902) operation vocabulary (add, replace, remove, move, copy) so it maps onto existing patch tooling and transports.

Install

npm install @maxjay/patchwork

How it works

1. Wrap any JSON document

import{Engine}from'@maxjay/patchwork';constengine=newEngine({server: {host: 'localhost',port: 8080},debug: false,});

Two independent deep clones are taken on construction — one as base, one as draft. They start identical and diverge as you mutate.

2. Mutate the draft

All mutations target draft. base doesn't move until you accept().

engine.replace('$.server.port',443);engine.add('$.server.ssl',true);engine.delete('$.debug');engine.draft;// { server: { host: 'localhost', port: 443, ssl: true }}engine.base;// { server: { host: 'localhost', port: 8080 }, debug: false }
MethodDescription
.add(path, value)Splice into arrays or set on objects. Creates intermediate nodes on literal paths.
.replace(path, value)Replace matched values. Wildcards replace all matches.
.delete(path)Remove at path. Splices arrays in place.
.move(from, to)Move a value. Source must resolve to exactly one node.
.copy(from, to)Copy a value. Source must resolve to exactly one node.
.revert(path)Reset draft at path back to whatever base has there.

3. See what changed

diff() returns the net structural difference between base and draft as a flat list of DiffOp objects. It's a snapshot comparison — independent of the undo stack.

engine.diff();// [// { op: 'replace', path: "$['server']['port']", oldValue: 8080, value: 443 },// { op: 'add', path: "$['server']['ssl']", value: true },// { op: 'remove', path: "$['debug']", value: false },// ]

Scope the diff with a JSONPath — resolves against both base and draft so deleted nodes are never missed:

engine.diff('$.server');// only ops touching the server subtreeengine.diff('$.items[*]');// only ops touching array elements

4. Undo anything

Every mutation pushes onto a single linear undo stack.

engine.undo();// reverse last opengine.redo();// replay it

accept() and decline() are themselves on the stack — committing doesn't erase history.

5. Commit or discard

engine.accept();// base ← clone(draft). draft untouched.engine.decline();// draft ← clone(base). pending edits discarded.

6. Ephemeral sessions

Some write patterns don't belong on the undo stack — streaming output updating a field on every chunk, hover previews, keystroke-level form binding. beginEphemeral opens a session where mutations proceed normally; commitEphemeral collapses the whole session into one undo entry.

engine.beginEphemeral();forawait(constchunkofstream){engine.replace('$.response',chunk);// draft updates live}engine.commitEphemeral();// one undo() snaps back to the pre-stream state

discardEphemeral() cancels instead — unwinds all session mutations, no history trace.

7. Export and replay

constops=engine.exportChanges();// DiffOp[] from the undo stackconstother=newEngine(originalDoc);other.importChanges(ops);// other.draft is now identical to engine.draft

JSONPath querying

Every operation in patchwork — reads, writes, diffs — accepts the same JSONPath (RFC 9535) expression. There is no separate addressing system for mutations vs queries.

// Readsengine.get('$.servers[*].host');// all hostsengine.get('$..*[?@.enabled == true]');// any enabled node, anywhereengine.getValue('$.config.timeout');// strict single-match// Writes — same pathsengine.replace('$.servers[*].host','prod');// replace all hostsengine.delete('$..*[?@.deprecated]');// remove any deprecated node// Diff — same pathsengine.diff('$.servers[*]');// ops touching any server

Paths returned by get() come back in normalized form ($['key'][0]) and can be fed straight back into replace, delete, etc.

Selector reference:

SyntaxMatches
$.key / $['key']Named property
$[0]Array index
$[*] / $['*']All children
$..*All descendants (recursive descent)
$[?@.x == 1]Filter — elements where condition holds
$[2:5]Slice

Array diffing

Default: index-zip

Without a declared identity, arrays are diffed position-by-position. Deleting the first element shifts every following element, producing a cascade of false replace ops — one per element that moved. This is correct for fixed-position arrays (tuples, coordinate pairs) but wrong for most everything else.

Identity-keyed: x-key

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.

constengine=newEngine({regions: [{id: 'us-east',capacity: 100},{id: 'eu-west',capacity: 80},{id: 'ap-south',capacity: 60},],},{schema: {type: 'object',properties: {regions: {type: 'array','x-key': 'id',items: {type: 'object'},},},},},);engine.delete('$.regions[0]');engine.diff();// [ { op: 'remove', path: "$['regions'][0]", value: { id: 'us-east', ... }, identity: 'us-east' } ]// one op — not a cascadeengine.replace('$.regions[0].capacity',90);engine.diff();// [// {// op: 'replace', path: "$['regions'][0]", identity: 'eu-west',// value: { id: 'eu-west', capacity: 90 }, oldValue: { id: 'eu-west', capacity: 80 },// displacement: 0,// changes: [{ op: 'replace', path: "$['regions'][0]['capacity']", oldValue: 80, value: 90 }]// }// ]

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.

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.

For a one-off without a schema:

engine.diff('$.regions',{key: 'id'});

Ordered arrays: x-ordered

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.

// schema: { 'x-key': 'id', 'x-ordered': true, ... }engine.delete('$.steps[0]');// removes step Aengine.diff();// [// { op: 'remove', path: "$['steps'][0]", identity: 'A', value: {...}},// { op: 'move', from: "$['steps'][1]", to: "$['steps'][0]", identity: 'B' },// { op: 'move', from: "$['steps'][2]", to: "$['steps'][1]", identity: 'C' },// ]

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.

To restore a displacement, pass the move op to restore() — it splices the element back to its base position.

Set semantics: x-key: '$self'

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.

constengine=newEngine({permissions: ['read','write','admin']},{schema: {type: 'object',properties: {permissions: {type: 'array','x-key': '$self',items: {type: 'string'}},},},},);engine.delete('$.permissions[1]');engine.diff();// [ { op: 'remove', path: "$['permissions'][1]", value: 'write', identity: 'write' } ]

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

Rendering full lists with includeUnchanged

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.

engine.diff('$.regions',{includeUnchanged: true});// returns add / replace / remove / move ops for changed elements,// plus { op: 'unchanged', ... } for every element that stayed the same

Reverting a diff op

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.

constops=engine.diff('$.regions');constremoveOp=ops.find(o=>o.op==='remove'&&o.identity==='us-east');engine.restore(removeOp);// re-inserts us-east at its original positionengine.undo();// un-does the restore
opwhat restore does
adddeletes the element
removere-inserts it at its original position
replacereverts the element to oldValue
movesplices it back to its base position

Scoped lenses

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.

constengine=newEngine({cars: [{color: 'red'}],trucks: [{color: 'red'}],});constcars=engine.getNodeEngine('$.cars');cars.replace('$[0].color','yellow');engine.draft.cars[0].color;// 'yellow'cars.draft[0].color;// 'yellow'

Subtree-scoped behavior on the lens:

  • cars.diff() — ops touching cars only, paths relative to $; each op also carries absolutePath with the full document path.
  • cars.accept() — commits the cars subtree into base. The trucks subtree is unaffected.
  • cars.undo() / cars.redo() — delegate to the parent; there is one shared history.

Lenses compose — getNodeEngine on a NodeEngine joins paths and creates a further-scoped lens against the same root parent.

LLM integration

createEngineTools builds a framework-neutral tool set that any LLM can call to read and edit the draft. The design is intentional: accept, decline, undo, and redo are not exposed — the LLM writes to draft, the human commits.

import{createEngineTools}from'@maxjay/patchwork/tools';consttools=createEngineTools(engine);// 9 tools: add, replace, delete, move, copy, revert, get, getValue, diff

Scope the LLM to a subtree by passing a NodeEngine:

constscoped=engine.getNodeEngine('$.userSettings');consttools=createEngineTools(scoped);// the model can only touch userSettings — the rest is unreachable

For MCP servers and agentic loops, see docs/llms.md.

Angular integration

@maxjay/patchwork/angular wraps an Engine in a reactive store built on Angular Signals (Angular 16+). All reads are exposed as Signals; mutations fire them automatically — no ChangeDetectorRef, no NgZone.

import{createPatchworkStore}from'@maxjay/patchwork/angular';
@Component({template: ` <input [value]="port()" (input)="setPort($event)"> <button (click)="store.accept()" [disabled]="!diff().length">Save</button> <button (click)="store.decline()" [disabled]="!diff().length">Discard</button> `,})classServerSettings{store=createPatchworkStore({server: {port: 8080}});port=this.store.getValue<number>('$.server.port');diff=this.store.diff();setPort(e: Event){this.store.replace('$.server.port',+(e.targetasHTMLInputElement).value);}}

See docs/angular.md for the full API, typed generics, change-highlighting UI, ephemeral form binding, scoped sub-stores, and service patterns.

API

Engine<T>

MemberDescription
new Engine(base, options?)Wrap a JSON value. options.schema enables identity-based array diffing.
.base / .draftThe committed and working views.
.add(path, value)Add or splice. Creates intermediate nodes on literal paths.
.replace(path, value)Replace at path. Wildcards replace all matches.
.delete(path)Remove at path.
.move(from, to)Move. Source must resolve to exactly one node.
.copy(from, to)Copy. Source must resolve to exactly one node.
.revert(path)Reset draft at path to base.
.get(path)Array<{ path, value }> — every match in draft with normalized paths.
.getBase(path)Same as get but reads from base.
.getValue(path)Strict single-match read from draft. Throws Error on multi-match; throws undefined on no-match.
.getValueBase(path)Same as getValue but reads from base.
.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.
.restore(op)Invert a DiffOp from diff() and push it onto the undo stack.
.undo() / .redo()Reverse / replay the last operation.
.accept()Promote draft into base. Reversible.
.decline()Reset draft from base. Reversible.
.exportChanges()DiffOp[] — structural mutations on the undo stack.
.importChanges(ops)Apply a DiffOp[] stream.
.getNodeEngine<U>(path)Scoped lens onto a subtree.
.beginEphemeral()Open an ephemeral session.
.commitEphemeral()Collapse the session into one undo entry.
.discardEphemeral()Unwind the session with no history trace.

NodeEngine<T>

MemberDescription
.base / .draftThe subtree from parent state.
.add / .replace / .delete / .move / .copy / .revertMutations forwarded to parent with paths rewritten.
.get(path) / .getBase(path)Reads draft / base in child frame, forwarded to parent.
.getValue(path) / .getValueBase(path)Strict single-match reads from draft / base.
.diff(path?, options?)Ops touching this subtree. Paths relative to child $; each op also carries absolutePath.
.accept()Commits this subtree into parent's base.
.decline()Resets this subtree in parent's draft from parent's base.
.undo() / .redo()Delegate to parent — one shared history.
.getNodeEngine<U>(path)Compose a further-scoped lens.

DiffOp

typeDiffOp=|{op: 'add';path: string;absolutePath?: string;value: JsonValue;identity?: JsonValue}|{op: 'replace';path: string;absolutePath?: string;oldValue?: JsonValue;value: JsonValue;identity?: JsonValue;displacement?: number;changes?: DiffOp[]}|{op: 'remove';path: string;absolutePath?: string;value?: JsonValue;identity?: JsonValue}|{op: 'move';from: string;to: string;identity?: JsonValue}|{op: 'copy';from: string;to: string}|{op: 'revert';path: string;absolutePath?: string}|{op: 'unchanged';path: string;absolutePath?: string;value: JsonValue;identity: JsonValue;displacement: number}
  • path — normalized JSONPath ($['key'][0]).
  • absolutePath — present on ops from NodeEngine.diff(). Contains the full document path while path is relative to the child's $.
  • 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.
  • oldValue — present on replace ops; the value that was there before.
  • displacement — on element-level replace and unchanged ops from ordered arrays (x-ordered: true). Integer delta: draftIndex − baseIndex. Zero if position did not change.
  • changes — on element-level replace ops. Flat list of field-level DiffOps describing what changed inside the element. Paths are absolute document paths.
  • unchanged op — only emitted when diff() is called with includeUnchanged: true.

Entrypoints

@maxjay/patchwork Engine, NodeEngine, DiffOp, OpType
@maxjay/patchwork/tools createEngineTools, Tool, EngineLike
@maxjay/patchwork/chat runAgentLoop, AgentMessage, ModelAdapter, NativeAdapter, PromptAdapter, toAgentTools
@maxjay/patchwork/mcp toMcpTools, handleMcpCall
@maxjay/patchwork/angular createPatchworkStore, fromEngine, PatchworkStore

For deeper coverage of the engine internals, see docs/engine.md. For LLM integration, adapters, and MCP, see docs/llms.md. For the Angular Signals adapter, see docs/angular.md.

Contributors

License

Apache-2.0

About

Patchwork is an AI-native TypeScript configuration framework for editors, admin tools, complex forms, and other structured JSON applications, with built-in agentic LLM tools and MCP support. RFC 9535 JSONPath querying, semantic diffs, undo/redo, scoped state, and framework integrations replace layers of custom state code with a few calls.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

patchwork

A JSON editing engine with base/draft, diff, undo, ephemeral sessions, and scoped lenses.

Motivation · Install · How it works · JSONPath querying · Array diffing · Scoped lenses · LLM integration · Angular · API


Motivation

Building a config editor, settings panel, or any UI over structured data means wiring up the same three concerns every time:

  • What changed? A diff between the saved state and the current edit.
  • Undo/redo that survives saves, across every operation.
  • Review before commit — inspect pending changes before they land.

Patchwork wraps any JSON document in an Engine that holds two views — base (committed) and draft (working) — and a stack of reversible operations. That single primitive covers all three.

Addressing uses JSONPath (RFC 9535) throughout. The same expression you write to read a value works identically to target a write or scope a diff. Diff output follows the JSON Patch (RFC 6902) operation vocabulary (add, replace, remove, move, copy) so it maps onto existing patch tooling and transports.

Install

npm install @maxjay/patchwork

How it works

1. Wrap any JSON document

import{Engine}from'@maxjay/patchwork';constengine=newEngine({server: {host: 'localhost',port: 8080},debug: false,});

Two independent deep clones are taken on construction — one as base, one as draft. They start identical and diverge as you mutate.

2. Mutate the draft

All mutations target draft. base doesn't move until you accept().

engine.replace('$.server.port',443);engine.add('$.server.ssl',true);engine.delete('$.debug');engine.draft;// { server: { host: 'localhost', port: 443, ssl: true }}engine.base;// { server: { host: 'localhost', port: 8080 }, debug: false }
MethodDescription
.add(path, value)Splice into arrays or set on objects. Creates intermediate nodes on literal paths.
.replace(path, value)Replace matched values. Wildcards replace all matches.
.delete(path)Remove at path. Splices arrays in place.
.move(from, to)Move a value. Source must resolve to exactly one node.
.copy(from, to)Copy a value. Source must resolve to exactly one node.
.revert(path)Reset draft at path back to whatever base has there.

3. See what changed

diff() returns the net structural difference between base and draft as a flat list of DiffOp objects. It's a snapshot comparison — independent of the undo stack.

engine.diff();// [// { op: 'replace', path: "$['server']['port']", oldValue: 8080, value: 443 },// { op: 'add', path: "$['server']['ssl']", value: true },// { op: 'remove', path: "$['debug']", value: false },// ]

Scope the diff with a JSONPath — resolves against both base and draft so deleted nodes are never missed:

engine.diff('$.server');// only ops touching the server subtreeengine.diff('$.items[*]');// only ops touching array elements

4. Undo anything

Every mutation pushes onto a single linear undo stack.

engine.undo();// reverse last opengine.redo();// replay it

accept() and decline() are themselves on the stack — committing doesn't erase history.

5. Commit or discard

engine.accept();// base ← clone(draft). draft untouched.engine.decline();// draft ← clone(base). pending edits discarded.

6. Ephemeral sessions

Some write patterns don't belong on the undo stack — streaming output updating a field on every chunk, hover previews, keystroke-level form binding. beginEphemeral opens a session where mutations proceed normally; commitEphemeral collapses the whole session into one undo entry.

engine.beginEphemeral();forawait(constchunkofstream){engine.replace('$.response',chunk);// draft updates live}engine.commitEphemeral();// one undo() snaps back to the pre-stream state

discardEphemeral() cancels instead — unwinds all session mutations, no history trace.

7. Export and replay

constops=engine.exportChanges();// DiffOp[] from the undo stackconstother=newEngine(originalDoc);other.importChanges(ops);// other.draft is now identical to engine.draft

JSONPath querying

Every operation in patchwork — reads, writes, diffs — accepts the same JSONPath (RFC 9535) expression. There is no separate addressing system for mutations vs queries.

// Readsengine.get('$.servers[*].host');// all hostsengine.get('$..*[?@.enabled == true]');// any enabled node, anywhereengine.getValue('$.config.timeout');// strict single-match// Writes — same pathsengine.replace('$.servers[*].host','prod');// replace all hostsengine.delete('$..*[?@.deprecated]');// remove any deprecated node// Diff — same pathsengine.diff('$.servers[*]');// ops touching any server

Paths returned by get() come back in normalized form ($['key'][0]) and can be fed straight back into replace, delete, etc.

Selector reference:

SyntaxMatches
$.key / $['key']Named property
$[0]Array index
$[*] / $['*']All children
$..*All descendants (recursive descent)
$[?@.x == 1]Filter — elements where condition holds
$[2:5]Slice

Array diffing

Default: index-zip

Without a declared identity, arrays are diffed position-by-position. Deleting the first element shifts every following element, producing a cascade of false replace ops — one per element that moved. This is correct for fixed-position arrays (tuples, coordinate pairs) but wrong for most everything else.

Identity-keyed: x-key

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.

constengine=newEngine({regions: [{id: 'us-east',capacity: 100},{id: 'eu-west',capacity: 80},{id: 'ap-south',capacity: 60},],},{schema: {type: 'object',properties: {regions: {type: 'array','x-key': 'id',items: {type: 'object'},},},},},);engine.delete('$.regions[0]');engine.diff();// [ { op: 'remove', path: "$['regions'][0]", value: { id: 'us-east', ... }, identity: 'us-east' } ]// one op — not a cascadeengine.replace('$.regions[0].capacity',90);engine.diff();// [// {// op: 'replace', path: "$['regions'][0]", identity: 'eu-west',// value: { id: 'eu-west', capacity: 90 }, oldValue: { id: 'eu-west', capacity: 80 },// displacement: 0,// changes: [{ op: 'replace', path: "$['regions'][0]['capacity']", oldValue: 80, value: 90 }]// }// ]

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.

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.

For a one-off without a schema:

engine.diff('$.regions',{key: 'id'});

Ordered arrays: x-ordered

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.

// schema: { 'x-key': 'id', 'x-ordered': true, ... }engine.delete('$.steps[0]');// removes step Aengine.diff();// [// { op: 'remove', path: "$['steps'][0]", identity: 'A', value: {...}},// { op: 'move', from: "$['steps'][1]", to: "$['steps'][0]", identity: 'B' },// { op: 'move', from: "$['steps'][2]", to: "$['steps'][1]", identity: 'C' },// ]

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.

To restore a displacement, pass the move op to restore() — it splices the element back to its base position.

Set semantics: x-key: '$self'

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.

constengine=newEngine({permissions: ['read','write','admin']},{schema: {type: 'object',properties: {permissions: {type: 'array','x-key': '$self',items: {type: 'string'}},},},},);engine.delete('$.permissions[1]');engine.diff();// [ { op: 'remove', path: "$['permissions'][1]", value: 'write', identity: 'write' } ]

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

Rendering full lists with includeUnchanged

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.

engine.diff('$.regions',{includeUnchanged: true});// returns add / replace / remove / move ops for changed elements,// plus { op: 'unchanged', ... } for every element that stayed the same

Reverting a diff op

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.

constops=engine.diff('$.regions');constremoveOp=ops.find(o=>o.op==='remove'&&o.identity==='us-east');engine.restore(removeOp);// re-inserts us-east at its original positionengine.undo();// un-does the restore
opwhat restore does
adddeletes the element
removere-inserts it at its original position
replacereverts the element to oldValue
movesplices it back to its base position

Scoped lenses

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.

constengine=newEngine({cars: [{color: 'red'}],trucks: [{color: 'red'}],});constcars=engine.getNodeEngine('$.cars');cars.replace('$[0].color','yellow');engine.draft.cars[0].color;// 'yellow'cars.draft[0].color;// 'yellow'

Subtree-scoped behavior on the lens:

  • cars.diff() — ops touching cars only, paths relative to $; each op also carries absolutePath with the full document path.
  • cars.accept() — commits the cars subtree into base. The trucks subtree is unaffected.
  • cars.undo() / cars.redo() — delegate to the parent; there is one shared history.

Lenses compose — getNodeEngine on a NodeEngine joins paths and creates a further-scoped lens against the same root parent.

LLM integration

createEngineTools builds a framework-neutral tool set that any LLM can call to read and edit the draft. The design is intentional: accept, decline, undo, and redo are not exposed — the LLM writes to draft, the human commits.

import{createEngineTools}from'@maxjay/patchwork/tools';consttools=createEngineTools(engine);// 9 tools: add, replace, delete, move, copy, revert, get, getValue, diff

Scope the LLM to a subtree by passing a NodeEngine:

constscoped=engine.getNodeEngine('$.userSettings');consttools=createEngineTools(scoped);// the model can only touch userSettings — the rest is unreachable

For MCP servers and agentic loops, see docs/llms.md.

Angular integration

@maxjay/patchwork/angular wraps an Engine in a reactive store built on Angular Signals (Angular 16+). All reads are exposed as Signals; mutations fire them automatically — no ChangeDetectorRef, no NgZone.

import{createPatchworkStore}from'@maxjay/patchwork/angular';
@Component({template: ` <input [value]="port()" (input)="setPort($event)"> <button (click)="store.accept()" [disabled]="!diff().length">Save</button> <button (click)="store.decline()" [disabled]="!diff().length">Discard</button> `,})classServerSettings{store=createPatchworkStore({server: {port: 8080}});port=this.store.getValue<number>('$.server.port');diff=this.store.diff();setPort(e: Event){this.store.replace('$.server.port',+(e.targetasHTMLInputElement).value);}}

See docs/angular.md for the full API, typed generics, change-highlighting UI, ephemeral form binding, scoped sub-stores, and service patterns.

API

Engine<T>

MemberDescription
new Engine(base, options?)Wrap a JSON value. options.schema enables identity-based array diffing.
.base / .draftThe committed and working views.
.add(path, value)Add or splice. Creates intermediate nodes on literal paths.
.replace(path, value)Replace at path. Wildcards replace all matches.
.delete(path)Remove at path.
.move(from, to)Move. Source must resolve to exactly one node.
.copy(from, to)Copy. Source must resolve to exactly one node.
.revert(path)Reset draft at path to base.
.get(path)Array<{ path, value }> — every match in draft with normalized paths.
.getBase(path)Same as get but reads from base.
.getValue(path)Strict single-match read from draft. Throws Error on multi-match; throws undefined on no-match.
.getValueBase(path)Same as getValue but reads from base.
.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.
.restore(op)Invert a DiffOp from diff() and push it onto the undo stack.
.undo() / .redo()Reverse / replay the last operation.
.accept()Promote draft into base. Reversible.
.decline()Reset draft from base. Reversible.
.exportChanges()DiffOp[] — structural mutations on the undo stack.
.importChanges(ops)Apply a DiffOp[] stream.
.getNodeEngine<U>(path)Scoped lens onto a subtree.
.beginEphemeral()Open an ephemeral session.
.commitEphemeral()Collapse the session into one undo entry.
.discardEphemeral()Unwind the session with no history trace.

NodeEngine<T>

MemberDescription
.base / .draftThe subtree from parent state.
.add / .replace / .delete / .move / .copy / .revertMutations forwarded to parent with paths rewritten.
.get(path) / .getBase(path)Reads draft / base in child frame, forwarded to parent.
.getValue(path) / .getValueBase(path)Strict single-match reads from draft / base.
.diff(path?, options?)Ops touching this subtree. Paths relative to child $; each op also carries absolutePath.
.accept()Commits this subtree into parent's base.
.decline()Resets this subtree in parent's draft from parent's base.
.undo() / .redo()Delegate to parent — one shared history.
.getNodeEngine<U>(path)Compose a further-scoped lens.

DiffOp

typeDiffOp=|{op: 'add';path: string;absolutePath?: string;value: JsonValue;identity?: JsonValue}|{op: 'replace';path: string;absolutePath?: string;oldValue?: JsonValue;value: JsonValue;identity?: JsonValue;displacement?: number;changes?: DiffOp[]}|{op: 'remove';path: string;absolutePath?: string;value?: JsonValue;identity?: JsonValue}|{op: 'move';from: string;to: string;identity?: JsonValue}|{op: 'copy';from: string;to: string}|{op: 'revert';path: string;absolutePath?: string}|{op: 'unchanged';path: string;absolutePath?: string;value: JsonValue;identity: JsonValue;displacement: number}
  • path — normalized JSONPath ($['key'][0]).
  • absolutePath — present on ops from NodeEngine.diff(). Contains the full document path while path is relative to the child's $.
  • 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.
  • oldValue — present on replace ops; the value that was there before.
  • displacement — on element-level replace and unchanged ops from ordered arrays (x-ordered: true). Integer delta: draftIndex − baseIndex. Zero if position did not change.
  • changes — on element-level replace ops. Flat list of field-level DiffOps describing what changed inside the element. Paths are absolute document paths.
  • unchanged op — only emitted when diff() is called with includeUnchanged: true.

Entrypoints

@maxjay/patchwork Engine, NodeEngine, DiffOp, OpType
@maxjay/patchwork/tools createEngineTools, Tool, EngineLike
@maxjay/patchwork/chat runAgentLoop, AgentMessage, ModelAdapter, NativeAdapter, PromptAdapter, toAgentTools
@maxjay/patchwork/mcp toMcpTools, handleMcpCall
@maxjay/patchwork/angular createPatchworkStore, fromEngine, PatchworkStore

For deeper coverage of the engine internals, see docs/engine.md. For LLM integration, adapters, and MCP, see docs/llms.md. For the Angular Signals adapter, see docs/angular.md.

Contributors

License

Apache-2.0

About

Patchwork is an AI-native TypeScript configuration framework for editors, admin tools, complex forms, and other structured JSON applications, with built-in agentic LLM tools and MCP support. RFC 9535 JSONPath querying, semantic diffs, undo/redo, scoped state, and framework integrations replace layers of custom state code with a few calls.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

patchwork

A JSON editing engine with base/draft, diff, undo, ephemeral sessions, and scoped lenses.

Motivation · Install · How it works · JSONPath querying · Array diffing · Scoped lenses · LLM integration · Angular · API


Motivation

Building a config editor, settings panel, or any UI over structured data means wiring up the same three concerns every time:

  • What changed? A diff between the saved state and the current edit.
  • Undo/redo that survives saves, across every operation.
  • Review before commit — inspect pending changes before they land.

Patchwork wraps any JSON document in an Engine that holds two views — base (committed) and draft (working) — and a stack of reversible operations. That single primitive covers all three.

Addressing uses JSONPath (RFC 9535) throughout. The same expression you write to read a value works identically to target a write or scope a diff. Diff output follows the JSON Patch (RFC 6902) operation vocabulary (add, replace, remove, move, copy) so it maps onto existing patch tooling and transports.

Install

npm install @maxjay/patchwork

How it works

1. Wrap any JSON document

import{Engine}from'@maxjay/patchwork';constengine=newEngine({server: {host: 'localhost',port: 8080},debug: false,});

Two independent deep clones are taken on construction — one as base, one as draft. They start identical and diverge as you mutate.

2. Mutate the draft

All mutations target draft. base doesn't move until you accept().

engine.replace('$.server.port',443);engine.add('$.server.ssl',true);engine.delete('$.debug');engine.draft;// { server: { host: 'localhost', port: 443, ssl: true }}engine.base;// { server: { host: 'localhost', port: 8080 }, debug: false }
MethodDescription
.add(path, value)Splice into arrays or set on objects. Creates intermediate nodes on literal paths.
.replace(path, value)Replace matched values. Wildcards replace all matches.
.delete(path)Remove at path. Splices arrays in place.
.move(from, to)Move a value. Source must resolve to exactly one node.
.copy(from, to)Copy a value. Source must resolve to exactly one node.
.revert(path)Reset draft at path back to whatever base has there.

3. See what changed

diff() returns the net structural difference between base and draft as a flat list of DiffOp objects. It's a snapshot comparison — independent of the undo stack.

engine.diff();// [// { op: 'replace', path: "$['server']['port']", oldValue: 8080, value: 443 },// { op: 'add', path: "$['server']['ssl']", value: true },// { op: 'remove', path: "$['debug']", value: false },// ]

Scope the diff with a JSONPath — resolves against both base and draft so deleted nodes are never missed:

engine.diff('$.server');// only ops touching the server subtreeengine.diff('$.items[*]');// only ops touching array elements

4. Undo anything

Every mutation pushes onto a single linear undo stack.

engine.undo();// reverse last opengine.redo();// replay it

accept() and decline() are themselves on the stack — committing doesn't erase history.

5. Commit or discard

engine.accept();// base ← clone(draft). draft untouched.engine.decline();// draft ← clone(base). pending edits discarded.

6. Ephemeral sessions

Some write patterns don't belong on the undo stack — streaming output updating a field on every chunk, hover previews, keystroke-level form binding. beginEphemeral opens a session where mutations proceed normally; commitEphemeral collapses the whole session into one undo entry.

engine.beginEphemeral();forawait(constchunkofstream){engine.replace('$.response',chunk);// draft updates live}engine.commitEphemeral();// one undo() snaps back to the pre-stream state

discardEphemeral() cancels instead — unwinds all session mutations, no history trace.

7. Export and replay

constops=engine.exportChanges();// DiffOp[] from the undo stackconstother=newEngine(originalDoc);other.importChanges(ops);// other.draft is now identical to engine.draft

JSONPath querying

Every operation in patchwork — reads, writes, diffs — accepts the same JSONPath (RFC 9535) expression. There is no separate addressing system for mutations vs queries.

// Readsengine.get('$.servers[*].host');// all hostsengine.get('$..*[?@.enabled == true]');// any enabled node, anywhereengine.getValue('$.config.timeout');// strict single-match// Writes — same pathsengine.replace('$.servers[*].host','prod');// replace all hostsengine.delete('$..*[?@.deprecated]');// remove any deprecated node// Diff — same pathsengine.diff('$.servers[*]');// ops touching any server

Paths returned by get() come back in normalized form ($['key'][0]) and can be fed straight back into replace, delete, etc.

Selector reference:

SyntaxMatches
$.key / $['key']Named property
$[0]Array index
$[*] / $['*']All children
$..*All descendants (recursive descent)
$[?@.x == 1]Filter — elements where condition holds
$[2:5]Slice

Array diffing

Default: index-zip

Without a declared identity, arrays are diffed position-by-position. Deleting the first element shifts every following element, producing a cascade of false replace ops — one per element that moved. This is correct for fixed-position arrays (tuples, coordinate pairs) but wrong for most everything else.

Identity-keyed: x-key

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.

constengine=newEngine({regions: [{id: 'us-east',capacity: 100},{id: 'eu-west',capacity: 80},{id: 'ap-south',capacity: 60},],},{schema: {type: 'object',properties: {regions: {type: 'array','x-key': 'id',items: {type: 'object'},},},},},);engine.delete('$.regions[0]');engine.diff();// [ { op: 'remove', path: "$['regions'][0]", value: { id: 'us-east', ... }, identity: 'us-east' } ]// one op — not a cascadeengine.replace('$.regions[0].capacity',90);engine.diff();// [// {// op: 'replace', path: "$['regions'][0]", identity: 'eu-west',// value: { id: 'eu-west', capacity: 90 }, oldValue: { id: 'eu-west', capacity: 80 },// displacement: 0,// changes: [{ op: 'replace', path: "$['regions'][0]['capacity']", oldValue: 80, value: 90 }]// }// ]

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.

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.

For a one-off without a schema:

engine.diff('$.regions',{key: 'id'});

Ordered arrays: x-ordered

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.

// schema: { 'x-key': 'id', 'x-ordered': true, ... }engine.delete('$.steps[0]');// removes step Aengine.diff();// [// { op: 'remove', path: "$['steps'][0]", identity: 'A', value: {...}},// { op: 'move', from: "$['steps'][1]", to: "$['steps'][0]", identity: 'B' },// { op: 'move', from: "$['steps'][2]", to: "$['steps'][1]", identity: 'C' },// ]

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.

To restore a displacement, pass the move op to restore() — it splices the element back to its base position.

Set semantics: x-key: '$self'

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.

constengine=newEngine({permissions: ['read','write','admin']},{schema: {type: 'object',properties: {permissions: {type: 'array','x-key': '$self',items: {type: 'string'}},},},},);engine.delete('$.permissions[1]');engine.diff();// [ { op: 'remove', path: "$['permissions'][1]", value: 'write', identity: 'write' } ]

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

Rendering full lists with includeUnchanged

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.

engine.diff('$.regions',{includeUnchanged: true});// returns add / replace / remove / move ops for changed elements,// plus { op: 'unchanged', ... } for every element that stayed the same

Reverting a diff op

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.

constops=engine.diff('$.regions');constremoveOp=ops.find(o=>o.op==='remove'&&o.identity==='us-east');engine.restore(removeOp);// re-inserts us-east at its original positionengine.undo();// un-does the restore
opwhat restore does
adddeletes the element
removere-inserts it at its original position
replacereverts the element to oldValue
movesplices it back to its base position

Scoped lenses

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.

constengine=newEngine({cars: [{color: 'red'}],trucks: [{color: 'red'}],});constcars=engine.getNodeEngine('$.cars');cars.replace('$[0].color','yellow');engine.draft.cars[0].color;// 'yellow'cars.draft[0].color;// 'yellow'

Subtree-scoped behavior on the lens:

  • cars.diff() — ops touching cars only, paths relative to $; each op also carries absolutePath with the full document path.
  • cars.accept() — commits the cars subtree into base. The trucks subtree is unaffected.
  • cars.undo() / cars.redo() — delegate to the parent; there is one shared history.

Lenses compose — getNodeEngine on a NodeEngine joins paths and creates a further-scoped lens against the same root parent.

LLM integration

createEngineTools builds a framework-neutral tool set that any LLM can call to read and edit the draft. The design is intentional: accept, decline, undo, and redo are not exposed — the LLM writes to draft, the human commits.

import{createEngineTools}from'@maxjay/patchwork/tools';consttools=createEngineTools(engine);// 9 tools: add, replace, delete, move, copy, revert, get, getValue, diff

Scope the LLM to a subtree by passing a NodeEngine:

constscoped=engine.getNodeEngine('$.userSettings');consttools=createEngineTools(scoped);// the model can only touch userSettings — the rest is unreachable

For MCP servers and agentic loops, see docs/llms.md.

Angular integration

@maxjay/patchwork/angular wraps an Engine in a reactive store built on Angular Signals (Angular 16+). All reads are exposed as Signals; mutations fire them automatically — no ChangeDetectorRef, no NgZone.

import{createPatchworkStore}from'@maxjay/patchwork/angular';
@Component({template: ` <input [value]="port()" (input)="setPort($event)"> <button (click)="store.accept()" [disabled]="!diff().length">Save</button> <button (click)="store.decline()" [disabled]="!diff().length">Discard</button> `,})classServerSettings{store=createPatchworkStore({server: {port: 8080}});port=this.store.getValue<number>('$.server.port');diff=this.store.diff();setPort(e: Event){this.store.replace('$.server.port',+(e.targetasHTMLInputElement).value);}}

See docs/angular.md for the full API, typed generics, change-highlighting UI, ephemeral form binding, scoped sub-stores, and service patterns.

API

Engine<T>

MemberDescription
new Engine(base, options?)Wrap a JSON value. options.schema enables identity-based array diffing.
.base / .draftThe committed and working views.
.add(path, value)Add or splice. Creates intermediate nodes on literal paths.
.replace(path, value)Replace at path. Wildcards replace all matches.
.delete(path)Remove at path.
.move(from, to)Move. Source must resolve to exactly one node.
.copy(from, to)Copy. Source must resolve to exactly one node.
.revert(path)Reset draft at path to base.
.get(path)Array<{ path, value }> — every match in draft with normalized paths.
.getBase(path)Same as get but reads from base.
.getValue(path)Strict single-match read from draft. Throws Error on multi-match; throws undefined on no-match.
.getValueBase(path)Same as getValue but reads from base.
.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.
.restore(op)Invert a DiffOp from diff() and push it onto the undo stack.
.undo() / .redo()Reverse / replay the last operation.
.accept()Promote draft into base. Reversible.
.decline()Reset draft from base. Reversible.
.exportChanges()DiffOp[] — structural mutations on the undo stack.
.importChanges(ops)Apply a DiffOp[] stream.
.getNodeEngine<U>(path)Scoped lens onto a subtree.
.beginEphemeral()Open an ephemeral session.
.commitEphemeral()Collapse the session into one undo entry.
.discardEphemeral()Unwind the session with no history trace.

NodeEngine<T>

MemberDescription
.base / .draftThe subtree from parent state.
.add / .replace / .delete / .move / .copy / .revertMutations forwarded to parent with paths rewritten.
.get(path) / .getBase(path)Reads draft / base in child frame, forwarded to parent.
.getValue(path) / .getValueBase(path)Strict single-match reads from draft / base.
.diff(path?, options?)Ops touching this subtree. Paths relative to child $; each op also carries absolutePath.
.accept()Commits this subtree into parent's base.
.decline()Resets this subtree in parent's draft from parent's base.
.undo() / .redo()Delegate to parent — one shared history.
.getNodeEngine<U>(path)Compose a further-scoped lens.

DiffOp

typeDiffOp=|{op: 'add';path: string;absolutePath?: string;value: JsonValue;identity?: JsonValue}|{op: 'replace';path: string;absolutePath?: string;oldValue?: JsonValue;value: JsonValue;identity?: JsonValue;displacement?: number;changes?: DiffOp[]}|{op: 'remove';path: string;absolutePath?: string;value?: JsonValue;identity?: JsonValue}|{op: 'move';from: string;to: string;identity?: JsonValue}|{op: 'copy';from: string;to: string}|{op: 'revert';path: string;absolutePath?: string}|{op: 'unchanged';path: string;absolutePath?: string;value: JsonValue;identity: JsonValue;displacement: number}
  • path — normalized JSONPath ($['key'][0]).
  • absolutePath — present on ops from NodeEngine.diff(). Contains the full document path while path is relative to the child's $.
  • 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.
  • oldValue — present on replace ops; the value that was there before.
  • displacement — on element-level replace and unchanged ops from ordered arrays (x-ordered: true). Integer delta: draftIndex − baseIndex. Zero if position did not change.
  • changes — on element-level replace ops. Flat list of field-level DiffOps describing what changed inside the element. Paths are absolute document paths.
  • unchanged op — only emitted when diff() is called with includeUnchanged: true.

Entrypoints

@maxjay/patchwork Engine, NodeEngine, DiffOp, OpType
@maxjay/patchwork/tools createEngineTools, Tool, EngineLike
@maxjay/patchwork/chat runAgentLoop, AgentMessage, ModelAdapter, NativeAdapter, PromptAdapter, toAgentTools
@maxjay/patchwork/mcp toMcpTools, handleMcpCall
@maxjay/patchwork/angular createPatchworkStore, fromEngine, PatchworkStore

For deeper coverage of the engine internals, see docs/engine.md. For LLM integration, adapters, and MCP, see docs/llms.md. For the Angular Signals adapter, see docs/angular.md.

Contributors

License

Apache-2.0

About

Patchwork is an AI-native TypeScript configuration framework for editors, admin tools, complex forms, and other structured JSON applications, with built-in agentic LLM tools and MCP support. RFC 9535 JSONPath querying, semantic diffs, undo/redo, scoped state, and framework integrations replace layers of custom state code with a few calls.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages