simple-object-patcher
v0.1.0
Published
Simple RFC6902 object patcher, with options
Maintainers
Readme
simple-object-patcher
RFC6902 (JSON Patch) apply and diff, with the options a live-state application tends to need:
replacing the document at its root, patching destructively or into a clone, creating the parents an
add needs, and an error that says which part of the patch failed and whether anything was written
before it did.
It is a thin layer over rfc6902: that package does the patching, this one wraps it — and keeps its array diff from falling over on long arrays.
Installation
npm install simple-object-patcherESM only, no build step.
Usage
import { applyPatch, createPatch, PatchError } from "simple-object-patcher";
const state = { headline: "old" };
applyPatch(state, [{ op: "replace", path: "/headline", value: "new" }]);
// state is now { headline: "new" }
createPatch({ a: 1 }, { a: 2 });
// [{ op: "replace", path: "/a", value: 2 }]applyPatch(document, patch, options?)
Applies patch (an array of RFC6902 operations) and returns the patched document. By default it
mutates document in place and returns that same object.
| Option | Default | Effect |
| --- | --- | --- |
| nest | true | Allows the patch to replace the document at its root, with path: "" |
| immutable | false | Clones the document and patches the clone, leaving the original alone |
| create | true | Creates the missing parent objects an add needs |
Parts are applied one at a time, in order, and the first failure throws. Parts before the failure have already been applied; parts after it are not attempted.
nest
RFC6902 cannot replace a document at its root, because there is no parent to assign to. With nest
the document is put inside a wrapper, the patch paths are rewritten against it, and the wrapper's
contents are returned — so a whole-document replace works:
applyPatch({ a: 1 }, [{ op: "replace", path: "", value: { b: 2 } }]);
// { b: 2 }A root replace necessarily returns a different object than the one passed in, since it is a new
value rather than a mutation. Every other operation still mutates in place and returns the original
reference. Set nest: false to patch the document directly and reject root-level operations.
immutable
const before = { a: 1 };
const after = applyPatch(before, [{ op: "add", path: "/b", value: 2 }], { immutable: true });
// before is still { a: 1 }, after is { a: 1, b: 2 }A null or undefined document is treated as {}, so a state that does not exist yet can be
patched into being rather than throwing:
applyPatch(undefined, [{ op: "add", path: "/a", value: 1 }], { immutable: true });
// { a: 1 }create
RFC6902 requires the parent of an add to exist. With create, the parents are built first, so a
nested value can be set without walking the object into place by hand:
applyPatch({}, [{ op: "add", path: "/a/b/c", value: 1 }]);
// { a: { b: { c: 1 } } }
applyPatch({}, [{ op: "add", path: "/a/b/c", value: 1 }], { create: false });
// throws PatchErrorcreatePatch(document1, document2, options?)
Returns the patch that turns document1 into document2, or [] if they are already equal.
const patch = createPatch(before, after);
applyPatch(before, patch, { immutable: true }); // deep-equals after| Option | Default | Effect |
| --- | --- | --- |
| positionalArrayLength | 100 | Array length above which elements are matched by position rather than by edit distance |
positionalArrayLength
rfc6902 diffs arrays with an edit-distance search, which finds the shortest patch: an element inserted at the front is described as the one insertion it was, rather than as a change at every position after it. That search compares every element against every other, though, and recurses as it goes — so on a long array it takes time and memory quadratic in the length, and dies outright somewhere past a couple of thousand elements, by call stack or by heap depending on how big the elements are.
Above positionalArrayLength, elements are instead matched up by position — element 0 against
element 0, and so on, with a shorter side made up by appending or trimming the tail. That is a
single pass, so cost stays proportional to the data:
| Elements | Edit distance | By position | | --- | --- | --- | | 500 | 3.4 s | 4 ms | | 1,000 | 21.6 s | 4 ms | | 2,000 | out of memory | 6 ms | | 100,000 | out of memory | 155 ms |
The trade is that position matching does not always give the shortest patch. Editing a cell in a 50,000-row table still gives one operation, but inserting a row at the front gives an operation per row after it. If you are choosing between sending a patch and sending the whole value, compare the two sizes and send the smaller.
Both sides are measured, so a 100-element array growing to 101 crosses a threshold of 100. Set
positionalArrayLength: Infinity to always use the edit-distance search, whatever the length.
createPatch(before, after, { positionalArrayLength: Infinity });PatchError
Thrown when a part of the patch cannot be applied. Beyond message, it carries:
patch— the single operation that failed. Withneston (the default) itspathis the rewritten one, prefixed with the wrapper key.mutated— whether a non-testoperation had been attempted before the throw, meaning the document may already have been changed. A patch that fails on a leadingtestreportsfalse.
try {
applyPatch(state, patch);
}
catch(error) {
if(error instanceof PatchError && error.mutated)
// the document is in a partly-patched state; reload it rather than trusting it
}mutated is deliberately pessimistic: it reports that a write was attempted, not that it
succeeded.
Tests
npm testLicense
MIT
