tree-patch is a TypeScript library for immutable, conflict-aware editing of tree-shaped content.
It is designed for workflows like localization, CMS overlays, and content customization where you want to:
- keep a canonical source tree;
- store changes as semantic patches instead of full copies;
- detect conflicts only when the exact touched fields or structures changed;
- diff, apply, validate, materialize, and rebase patches deterministically.
The runtime has zero Node/Bun-specific dependencies. The built library is intended for modern JavaScript runtimes, including browsers.
- Typed tree model with
attrslinked to nodetype - Immutable indexed snapshots via
createDocument() - Conflict-aware patch application with field-scoped guards
- Structural operations: insert, move, bulk child reorder, replace subtree, remove, hide/show
- Deterministic diff generation and patch rebasing
- Conflict-resolution sessions with per-conflict
takeBase/keepLocal - Typed patch authoring with
patchBuilder()andcreateEditor() - Runtime schema support for atomic paths, custom equality, hashing, cloning, and codecs
Install the package from npm:
npm install @hexie/tree-patch
Then import from the package root:
import { createDocument, patchBuilder, createEditor, applyPatch, validatePatch, materialize, preparePatch, createResolutionSession, diffTrees, rebasePatch, } from "@hexie/tree-patch";
TreeDocument: nested source document shapeIndexedTree: immutable indexed snapshot returned bycreateDocument()TreePatch: JSON-serializable patch with semantic operationsMaterializedNode: nested output after applying a patchTreeSchema: runtime adapters and codecs for non-trivial attribute values
An input revision is treated as an opaque external revision and is preserved
when applying a patch makes no semantic change. Changed snapshots receive a
deterministic tree:h3: revision derived from content, explicit visibility,
patch ownership, and document metadata.
Content, subtree, and path hashes carry an h3: algorithm prefix. Hash guards
are intentionally version-specific: a persisted guard produced by a different
hash algorithm conflicts safely instead of being interpreted as the current
digest.
These 128-bit hashes are deterministic, non-cryptographic fingerprints. Child hashes use an incrementally updatable additive aggregate. Guards therefore provide probabilistic conflict detection for trusted CMS/localization workflows; they are not intended to resist adversarial collision construction.
import { applyPatch, createDocument, patchBuilder, type TreeDocument, } from "@hexie/tree-patch"; type ContentTypes = { Page: {}; Hero: { title: string; image: { url: string; alt?: string; }; }; RichText: { html: string; }; }; const sourceDocument: TreeDocument<ContentTypes> = { revision: "rev-1", root: { id: "root", type: "Page", attrs: {}, children: [ { id: "hero", type: "Hero", attrs: { title: "Summer Sale", image: { url: "/img/en.png", }, }, children: [], }, { id: "legal", type: "RichText", attrs: { html: "<p>US only</p>", }, children: [], }, ], }, }; const source = createDocument(sourceDocument); const patch = patchBuilder<ContentTypes>({ source }) .patchId("fr-home") .node("hero", "Hero") .set(["title"], "Promotions d'ete", { expect: "Summer Sale", }) .set(["image", "url"], "/img/fr.png", { expect: "/img/en.png", }) .hideNode("legal") .build(); const result = applyPatch(source, patch, { includeHidden: false }); if (result.status === "applied") { console.log(result.tree.revision); console.log(result.materialized.children[0]?.attrs); }
There are two authoring styles:
patchBuilder()for fluent patch constructioncreateEditor()for editing against an existing source tree with validated node handles
Field edits are intentionally node-scoped:
const patch = patchBuilder<ContentTypes>({ source }) .patchId("promo-flow") .node("hero", "Hero") .set(["title"], "Localized title") .set(["image", "url"], "/img/fr.png") .hideNode("legal") .build();
Using the editor:
const editor = createEditor(source, { patchId: "promo-flow" }); editor.node("hero", "Hero").set(["title"], "Localized title"); editor.node("legal", "RichText").hide(); const patch = editor.build();
When a source tree is available, set() and remove() automatically guard the
current field value (or its absence). An explicit expectation also validates
the builder state immediately:
editor.node("hero", "Hero").set(["subtitle"], "Limited offer", { expectAbsent: true, });
Use { unguarded: true } only when last-writer-wins behavior is intentional.
Builders without a source cannot infer automatic guards, so expect and
expectAbsent remain available for hand-authored patches.
Numeric builder path segments retain their array intent when intermediate containers are absent:
editor.node("page", "Page").set( ["sections", 0, "title"], "Introduction", { expectAbsent: true }, );
This creates sections as an array and its first item as an object. A string
segment such as "0" remains an object property instead. The optional
SetAttrOp.pathKinds wire field preserves this distinction because JSON
pointers alone cannot distinguish an array index from a numeric object key
before the container exists. Autovivified and existing empty arrays can be
initialized at index 0; sparse array creation is rejected.
const validation = validatePatch(source, patch, { mode: "preview" }); const applied = applyPatch(source, patch, { mode: "atomic", includeHidden: false, }); const materialized = materialize(source, patch, { mode: "preview", includeHidden: true, });
Behavior summary:
validatePatch()checks conflicts without producing a preview treeapplyPatch()returns a new immutable, structurally sharedIndexedTreematerialize()uses the same conflict semantics asapplyPatch()preparePatch()returns a deeply frozen clone whose structural validation is cached for repeated validate/apply/rebase callsatomicmode stops at the first conflictpreviewmode keeps successful ops and reports skipped conflicts
The nested result.materialized view and a derived tree.revision (both for
createDocument() snapshots without an explicit revision and for changed
snapshots) are computed on first access and then cached. Callers that only need
the indexed snapshot do not pay for either full-tree traversal; hash caches
fill lazily as well.
const base = createDocument(sourceDocument); const target = createDocument({ ...sourceDocument, root: { ...sourceDocument.root, children: sourceDocument.root.children.map((child) => child.id === "hero" ? { ...child, attrs: { ...child.attrs, title: "Promotions d'ete", }, } : child, ), }, }); const patch = diffTrees(base, target); const rebased = rebasePatch(base, target, patch);
diffTrees() generates deterministic semantic ops. rebasePatch() replays a patch on a new base and keeps only the operations that still apply cleanly.
For localization-style workflows, you can create a resolution session, make per-conflict decisions, and rebuild a fresh patch against the new base.
const session = createResolutionSession(oldBase, newBase, patch); for (const conflict of session.unresolvedConflicts) { if (conflict.opId === "hero-title") { session.keepLocal(conflict.opId); } else { session.takeBase(conflict.opId); } } const result = session.build(); if (result.status === "resolved") { const nextPatch = result.resolvedPatch; }
Notes:
takeBase(opId)drops that operation from the rebuilt patchkeepLocal(opId)replays the original operation intent without its old guardsbuild()still returns a patch; the resolved preview tree is only an in-memory intermediate
Use a TreeSchema when you need custom equality, hashing, cloning, or persistence for runtime values.
import { createDocument, patchBuilder, type TreeSchema } from "@hexie/tree-patch"; type ContentTypes = { Hero: { title: string; publishedAt?: Date; }; }; const dateCodec = { codecId: "date", serialize(value: Date) { return value.toISOString(); }, deserialize(value: string) { return new Date(value); }, }; const schema: TreeSchema<ContentTypes> = { types: { Hero: { adapters: { "/publishedAt": { equals: (left: Date, right: Date) => left.getTime() === right.getTime(), clone: (value: Date) => new Date(value.getTime()), hash: (value: Date) => value.toISOString(), codec: dateCodec, }, }, }, }, };
Notes:
- JSON-compatible values work without codecs
- non-JSON persisted patch values require a codec
equals()defines semantic equality everywhere; ifhash()is present, it must be deterministic and return the same hash for values thatequals()considers equal- in the default
cloneownership mode, adapter-backed values returned from snapshots and materialized trees are defensive clones assumeImmutableskips defensive cloning and is a caller promise that all supplied and returned runtime values remain immutable- adapter and codec descriptors are validated and snapshotted when the schema is compiled
- the envelope shape
{ $codec, value }is reserved for encoded persisted values;diffTrees(), builders, andencodePersistedValue()automatically escape JSON user data with that exact shape using the reserved codec id"$tree-patch/json"(hand-authored patches should use the helper rather than emitting the ambiguous raw shape)
Patch ownership is provenance, not editable content. It participates in derived
revisions and controls which structural operations are legal, but diffTrees()
does not emit operations whose only effect would be changing ownership.
Applying such a diff preserves the base tree's ownership state.
Typed attribute paths include the empty path for replacing the complete attrs
value and are inferred up to eight segments deep. Removing the complete
attribute root is intentionally excluded because removeAttr requires a field
or array element.
Main functions:
createDocument()validatePatch()applyPatch()materialize()diffTrees()rebasePatch()createResolutionSession()patchBuilder()createEditor()pathToPointer()/pointerToPath()
Main exported types:
TreeDocumentIndexedTreeTreePatchPatchOpPatchConflictMaterializedNodeTreeSchemaValueAdapterValueCodec
The library runtime does not depend on Node-only or Bun-only APIs.
It uses standard modern JavaScript features such as:
Map/SetWeakMapstructuredCloneString.prototype.replaceAll
That makes it suitable for modern browsers, Bun, Deno, and Node ESM environments. The npm package declares Node 18 or newer. Older browsers may require transpilation or polyfills.
bun run build
bun run typecheck
bun testThe compiled package is emitted to dist/.
The implementation currently covers the planned v1 feature set of the library.