Skip to main content

Patches

Overview

As described in Snapshots, changing a tree node produces a new snapshot. Patches provide a more granular change stream: each patch describes one operation at a path.

Every change generates two kinds of patches: patches from the previous value to the new value (usually just called "patches") and patches from the new value back to the previous value ("inverse patches"). A patch object has this structure:

export interface Patch {
readonly op: "replace" | "remove" | "add"
readonly path: Path
readonly value?: any // value is not available for remove operations
}

Unlike JSON Patch, a mobx-keystone patch stores its path as an array of string or number segments rather than as a JSON Pointer string. This avoids parsing the path during normal use.

Getting patches

onPatches

onPatches(target: object, listener: OnPatchesListener): OnPatchesDisposer

onPatches lets you observe the patches generated for a tree node and all its children:

const disposer = onPatches(todo, (patches, inversePatches) => {
console.log("patches", patches)
console.log("inverse patches", inversePatches)
})

The listener callback runs after deep-change listeners, before the outermost action has completed. Patches are delivered in mutation order, including when a listener makes further edits. Unlike onSnapshot or a MobX reaction, the callback observes individual changes within an action.

onGlobalPatches

onGlobalPatches(listener: OnGlobalPatchesListener): OnPatchesDisposer

onGlobalPatches listens to patch activity anywhere, rather than under one specific subtree:

const disposer = onGlobalPatches((target, patches, inversePatches) => {
console.log("target", target)
console.log("patches", patches)
console.log("inverse patches", inversePatches)
})

This is mostly useful for global tooling, logging, or diagnostics. In application code, prefer onPatches when you already know which subtree you want to observe.

If an onPatches, onGlobalPatches or patchRecorder callback throws, remaining listeners and recorders still run. A lone failure is then rethrown as is; several failures are grouped in a MobxKeystoneAggregateError (a MobxKeystoneError subclass) whose .errors holds the thrown values in order, flattened.

patchRecorder

patchRecorder(target: object, opts?: PatchRecorderOptions): PatchRecorder

patchRecorder collects patches and inverse patches for later use, such as undoing or replaying changes. It records each change synchronously, including edits made inside change listeners. The recording flag and filter are checked when the change occurs.

It can be used like this:

const recorder = patchRecorder(todo, options)

Where the allowed options are:

/**
* Patch recorder options.
*/
export interface PatchRecorderOptions {
/**
* If the patch recorder is initially recording when created.
*/
recording?: boolean

/**
* An optional callback filter to select which patches to record/skip.
* It will be executed before the event is added to the events list.
*
* @param patches Patches about to be recorded.
* @param inversePatches Inverse patches about to be recorded.
* @returns `true` to record the patch, `false` to skip it.
*/
filter?(patches: Patch[], inversePatches: Patch[]): boolean

/**
* An optional callback run once a patch is recorded.
* It will be executed after the event is added to the events list.
*
* @param patches Patches just recorded.
* @param inversePatches Inverse patches just recorded.
*/
onPatches?: OnPatchesListener
}

It returns a recorder with the following properties:

interface PatchRecorder {
/**
* Gets/sets if the patch recorder is currently recording.
*/
recording: boolean

/**
* Observable array of patching events.
*/
readonly events: PatchRecorderEvent[]

/**
* Dispose of the patch recorder.
*/
dispose(): void
}

The PatchRecorderEvent definition is:

interface PatchRecorderEvent {
/**
* Target object.
*/
readonly target: object
/**
* Recorded patches.
*/
readonly patches: Patch[]
/**
* Recorded inverse patches.
*/
readonly inversePatches: Patch[]
}

Applying patches

applyPatches

applyPatches(obj: object, patches: Patch[] | Patch[][], reverse?: boolean): void

Apply recorded patches like this:

applyPatches(todo, patches)

as well as in reverse order (usually used for inverse patches):

applyPatches(todo, patches, true)

When automatic type checking is enabled, applyPatches validates affected models and their typed ancestors after the entire batch has been applied. Nested patch lists form one batch, and reverse replay follows the same rule. Newly created models are also checked at the end of the batch. This allows related patches to pass through an intermediate state that would fail a refinement, provided the completed state is valid. Validation covers every model the batch touched, including models the batch detached from the tree, so a batch that leaves a removed model in an invalid state is still rejected.

If validation fails, the batch is rolled back. Errors thrown by the patches themselves or by a listener are not validation failures: they propagate with the patches applied so far left in place, just as they would with automatic type checking disabled. Patch and deep-change listeners still observe individual mutations; rollback emits compensating changes for mutations already published. Synchronous listener edits, including nested patch calls, join the outer validation batch. An onSnapshot listener may receive a restored snapshot even when its contents equal the pre-batch snapshot.

Patches retain their literal mutation semantics: removing a model property does not restore its default, and changing a field does not run its containing model's snapshot processor. Values representing whole model snapshots still use snapshot reconciliation when inserted or replaced.

Conversion to JSON patches / paths

Paths generated by this library are arrays instead of JSON Pointer strings. The supported operations are add, remove, and replace.

For arrays, add accepts "-" to append, or an integer index from zero through the array length. Negative integer indexes remain an append extension. remove and replace require an existing array index; malformed or out-of-range indexes throw. Replacing the array's "length" resizes it; add and remove do not accept "length".

An empty path with add or replace replaces the root's contents using the same reconciliation rules as applySnapshot, preserving the root node's identity. Removing the root is unsupported and throws.

For compatibility reasons the following conversion functions are provided:

pathToJsonPointer

pathToJsonPointer(path: Path): string

Converts a path into a JSON pointer.

jsonPointerToPath

jsonPointerToPath(jsonPointer: string): Path

Converts a JSON pointer into a path. Malformed escapes throw; only ~0 (tilde) and ~1 (slash) are valid.

patchToJsonPatch

patchToJsonPatch(patch: Patch): JsonPatch

Converts a patch into a JSON patch.

jsonPatchToPatch

jsonPatchToPatch(jsonPatch: JsonPatch): Patch

Converts a JSON patch into a patch.