JSON Patch Undo Redo in TypeScript
A command-log approach to JSON Patch undo and redo, covering captured inverses, array hazards, pointer correctness, checkpoints, replay tests, and remote conflicts.
JSON Patch undo redo works only when each forward mutation captures the information its inverse will need before the document changes. Storing patches alone is insufficient for removed values, replaced data, array positions, overlapping moves, and collaborative divergence.
This TypeScript tutorial builds a bounded command history around atomic patch application. It proves a forward-and-inverse round trip, then maps the array, checkpoint, security, and product states a production editor must handle.
Frame JSON Patch undo redo as reversible commands
JSON Patch undo redo needs more than storing the forward operations. An inverse for replace requires the old value; an inverse for remove requires the removed subtree; an inverse for move must remember both source and destination semantics; and arrays require their exact pre-operation indices. Capture inverse information while applying each operation to a known document, not later from a document that may have changed.
The worked fixture replaces a title and adds an owner, then applies remove and replace in reverse order to recover the starting object. It intentionally covers only top-level paths. A production engine must implement complete JSON Pointer traversal, validation, and atomic failure before it can claim RFC-compatible behavior.
A command record stores the document version, forward operations, captured inverse operations, author, timestamp, and human label as one atomic unit. The worked title-and-owner change proves round-trip equality, while larger fixtures add nested objects and arrays so a shallow top-level implementation cannot be mistaken for a complete patch engine.
Derive JSON Patch undo redo inverses immediately
Apply a command transactionally and emit both its next document and inverse patch. For add, the inverse is usually remove, except adding to an existing object member behaves like replacement and needs the prior value. For remove, record the deleted value before mutation and invert with add.
For replace, store the old value. Copy and move need extra care because source and destination can overlap. Inverse JSON Patch operations are ordered opposite the forward batch, since later mutations may depend on earlier paths. Preserve deep clones or immutable values in the history; retaining a mutable reference lets later edits rewrite the supposed past. The command log should include document version, author, timestamp, and a concise user-facing label.
Operation behavior is checked against RFC 6902, including the requirement that array indices are interpreted against the document state at that operation. Capturing the inverse during application preserves removed values and resolved positions; trying to derive it later from the final document loses exactly the information undo needs.
- Declared input
- Inspectable transformation
- Measured output
Runnable artifact: The fixture applies a forward object patch and its captured inverse to prove exact round-trip restoration.
Save this proof as json-patch-inverse.test.mjs and run node json-patch-inverse.test.mjs. Expected final line: PASS: patches reverse.
import assert from "node:assert/strict";
const apply=(doc,p)=>{const out=structuredClone(doc);for(const op of p){const k=op.path.slice(1);if(op.op==="replace"||op.op==="add")out[k]=op.value;else delete out[k]}return out};
const before={title:"Draft",count:1};const forward=[{op:"replace",path:"/title",value:"Final"},{op:"add",path:"/owner",value:"JP"}];const inverse=[{op:"remove",path:"/owner"},{op:"replace",path:"/title",value:"Draft"}];
assert.deepEqual(apply(apply(before,forward),inverse),before);console.log("PASS: patches reverse");
Protect JSON Patch undo redo from array hazards
Array paths are positional, so an insertion shifts every following index. If one command removes /items/2 and another inserts before it, an inverse generated against the wrong version can remove another object. The - append token also needs resolution to the actual inserted index at apply time.
Record stable domain IDs inside values where possible, but remember that JSON Pointer still addresses positions in an array. The hazard diagram compares two operation orders and shows why independent patches may not commute. Before undoing across remote edits, rebase through a conflict-aware transformation or stop for review. Local history can remain simple only while it has exclusive ownership of the document version it is reversing.
Array tests cover add at an index, append with -, remove, move within one array, and a batch whose earlier operation shifts a later path. Each successful forward batch records inverses in reverse execution order, and any failed operation leaves both the document and history stacks unchanged rather than publishing a partial command.
Bound JSON Patch undo redo history with checkpoints
A reversible state history can group keystrokes or drag events into meaningful commands, coalesce repeated edits to the same field, and cap memory by count or byte size. Periodic full-document checkpoints make old history discardable and recovery faster, but the boundary should be explicit: users must not see an enabled Undo action that cannot cross it. Store patch hashes and resulting document hashes so corruption is detected before replay.
If history persists across sessions, version its format and migrate conservatively. My preference is to retain a short, exact command stack near the editor and durable domain revisions on the server. Trying to make one unbounded client undo log serve as collaborative history, backup, and audit trail weakens all three jobs.
Pointer parsing follows the escape rules in RFC 6901, with fixtures for ~0, ~1, an empty member name, and the document root. A property named __proto__ is rejected by local safety policy even though pointer syntax can address arbitrary names; that distinction is documented as an implementation boundary, not attributed to the RFC.
| Before | Forward | Resolved index | Inverse | Risk |
|---|---|---|---|---|
| [A,B] | add /1 X | 1 | remove /1 | Later insert |
| [A,B,C] | remove /1 | 1 | add /1 B | Lost value |
| [A,B] | add /- X | 2 | remove /2 | Append guess |
| [A,B,C] | move /0 → /2 | 2 | captured move | Overlap |
Ground JSON Patch undo redo in RFC semantics
RFC 6902 defines JSON Patch operations and sequential application. RFC 6901 defines JSON Pointer escaping and path resolution, while RFC 5789 defines HTTP PATCH and discusses conditional requests. Read them together before exposing patches across a network.
TypeScript command log validation should reject prototype-pollution keys, malformed escape sequences, nonexistent paths where required, invalid array indices, and partial application. A client-side inverse can improve interaction, but server concurrency still needs ETags or another version precondition. Undoing a locally accepted command against a remotely changed document is a merge operation, not a longer pointer traversal.
The protocol sources define patch syntax, pointer syntax, and HTTP application, but they do not define an editor’s undo history. The article therefore cites standards beside operation facts and labels command grouping, labels, checkpoints, and conflict behavior as product architecture that must be tested against this application’s state model.
Test JSON Patch undo redo with algebraic relations
For every generated valid document and patch, assert that applying the patch and its captured inverse returns a deep-equal document. Also assert failed operations leave the document unchanged, inverse batches run in reverse order, and redo applies the original command only after a successful undo. Generate object keys containing ~ and /, nested arrays, empty containers, numeric-looking object keys, and values such as null.
Continue honest local state with optimistic UI without lying, coordinate server versions through conflict-aware autosave with ETags, preserve creative intent with reversible AI layers, and recover multi-step effects using agent compensation. Each link covers a distinct boundary beyond this local patch engine.
History memory is measured from serialized inverse payloads rather than command count because deleting one large subtree can outweigh hundreds of scalar edits. A checkpoint policy snapshots at a byte threshold, verifies its hash, and discards only commands already represented by that checkpoint, preserving a clear recovery boundary.
- 1Capture
Validate and retain every value needed by the inverse.
- 2Commit
Apply atomically and hash the resulting document.
- 3Group
Coalesce meaningful actions inside a visible history bound.
- 4Reconcile
Stop or transform when a remote version breaks the inverse.
Design JSON Patch undo redo product states
Name commands in user language: Move layer, Rename board, or Delete filter, not replace /nodes/4/x. Disable Undo and Redo accurately, show the boundary after a checkpoint or remote conflict, and preserve focus where the restored object still exists. A destructive undo that would overwrite newer collaborative work must become a comparison or confirmation flow.
Log patch metadata for debugging but redact values according to data classification; patches can contain the whole secret that a user just deleted. The timeline figure distinguishes forward, inverse, checkpoint, and remote divergence. That visual grammar matters because users think in actions and documents, while the implementation thinks in pointers and arrays. The interface must reconcile both without promising impossible time travel.
Collaborative tests attach a base document version to each local command and inject a remote patch before undo. The engine stops when the inverse would target changed state, surfaces the conflicting paths, and offers an explicit compensating edit; it never replays an old inverse merely because the JSON Pointer still resolves.
Ship JSON Patch undo redo with replay evidence
The receipt includes supported operations, pointer parser, validation order, atomicity, inverse rules, cloning policy, array behavior, batching and coalescing, history limits, checkpoint format, hashes, redo invalidation, persistence, collaboration boundary, conditional request policy, command labels, focus recovery, redaction, fuzz seeds, and replay fixtures. Fail release if an invalid batch partly mutates state, inverse values alias live objects, append indices are guessed, escaping is wrong, remote divergence is overwritten, sensitive deleted values leak to analytics, or the UI advertises history beyond its real boundary. Reversibility is then measurable: a command either returns the exact document version it started from or stops with evidence explaining why a merge decision is now required.
The release corpus pairs every fixture with before, forward, after, inverse, and restored JSON. Reviewers can diff those files without running the UI, while fuzz tests generate seeded arrays and nested objects to discover ordering defects that a polished toolbar interaction would otherwise conceal.
JSON Patch undo redo is reliable when every forward mutation records the information its inverse needs. Bound JSON Patch undo redo history with checkpoints while preserving array indices, tests, and conflict evidence.