# Patch operations: the registry and how to add one A **patch operation** is one logical kind of change the runtime makes to a stored document. Every write a handler commits is carried to the durable store as a sequence of these ops, the store applies them to rebuild the document, and the reactivity and conflict machinery accounts for the paths each one touches. This note is the single starting point for understanding one op end to end and for adding or changing one safely. For *why* the merge-aware ops exist (the concurrency and stale-base problems they solve), see [mergeable-collection-writes.md](./mergeable-collection-writes.md) and [keyed-collection-writes.md](./keyed-collection-writes.md). This note is about *how* an op is put together across the codebase. ## The family The op tags are the members of the `PatchOp` union in `packages/memory/v2.ts`: | op | shape | how it arises | | ----------------- | ------------------------------ | ------------- | | `replace` | `{ path, value }` | generated by the whole-value diff | | `add` | `{ path, value }` | generated by the whole-value diff | | `remove` | `{ path }` | generated by the whole-value diff | | `move` | `{ from, path }` | applied only (never generated by the runner) | | `splice` | `{ path, index, remove, add }` | generated by the whole-value diff | | `append` | `{ path, values }` | `Cell.push`, `Cell.pushAll` | | `add-unique` | `{ path, values }` | `Cell.addUnique` | | `remove-by-value` | `{ path, value }` | `Cell.removeByValue` | | `increment` | `{ path, by }` | `Cell.increment` | Two overlapping distinctions matter: - **Structural vs value-only.** `add`, `remove`, and `move` change a container's key-set (they add, drop, or reorder a child key). The rest change a value in place or at an array's own path. Structural ops need extra care in the conflict machinery (below). - **Generated vs mergeable.** The structural and `replace`/`splice` ops are *generated* by diffing a handler's whole-value write against the transaction's base snapshot. The **mergeable** ops (`append`, `add-unique`, `increment`, `remove-by-value`) are the ones an author invokes directly; they are recorded as intent and resolved by the server against durable state, so concurrent edits of one collection merge instead of clobbering. The mergeable ops are the family with the widest spread, because they participate at every layer. ## Lifecycle of one mergeable op Take `Cell.increment(2)`: 1. **Author call** — `Cell.increment` (`packages/runner/src/cell.ts`) validates, computes the optimistic local value, writes it, and records the intent by calling `tx.recordMergeableOp(link, { op: "increment", by: 2 })`. 2. **Intent** — the transaction folds the delta into a per-path intent (`packages/runner/src/storage/mergeable-ops.ts`, `foldMergeableIntent`). Repeated same-kind calls at one path combine (increments sum, tail counts sum, removed values accumulate). A different op kind at the same path cannot share one intent, so `recordMergeableOp` poisons the path — it drops the intent and the commit falls back to the whole-array diff for that path (correct, but not merge-friendly), rather than letting the second op replace and silently drop the first. A foreign write that says what the value is — a whole-value `Cell.set` — poisons the path the same way via `poisonMergeableOp`, and for the rest of the transaction: an op recorded ahead of it is dropped, and one that follows it, at that path or beneath it, records no intent. 3. **Commit** — at commit the intent becomes wire ops plus the diff-suppression they imply (`buildMergeableIntent`), and the whole-value diff for the paths the op covers is dropped. The op travels in `ClientCommit.operations`. A builder that finds its intent no longer describes the local value instead *abandons* it, and the commit poisons the path as above: a tail op whose prefix no longer matches its base, or a `remove-by-value` whose removals applied to the base do not reproduce the working array — both meaning the transaction also changed that array outside the op, through a write that poisoned nothing. A whole-document or authoritative transaction abandons every intent it holds and sends each document whole. The commit does the same for one document in two further cases: the document has no base and the transaction wrote something to it besides its ops, or the transaction wrote the document's root. [mergeable-collection-writes.md](./mergeable-collection-writes.md) gives the reason for each. 4. **Apply** — the durable store applies the op against live state (`packages/memory/v2/patch.ts`, `patchOpDescriptors[op].apply`). 5. **React** — the store computes which paths the op touched so stale reads conflict and dependent computations re-run (`packages/memory/v2/engine.ts`). ## The registries — one definition per op The per-op behavior lives in three registration points, joined by the shared op tag. Adding an op is an entry in each; the type system and a consistency test make a missing or mismatched entry a loud failure rather than a silent degradation. ### 1. Wire-op descriptors — `packages/memory/v2/patch.ts` `patchOpDescriptors` is a `Record`, so every tag in the union must have exactly one descriptor and vice versa (a missing or extra one is a compile error). Each descriptor owns: - `apply(state, op)` — the mutation the durable store performs. Used by `applyPatch`, which deep-freezes its input first, so that a container an op finds mutable is one an earlier op of the same call copied: an op mutates such a container in place and copies a frozen one. That is what keeps a patch of `K` ops beneath one `N`-key object to one copy of it rather than `K` (a `move` re-inserts what it moves as a deep-frozen copy, which copies that object again when an earlier op of the same call has thawed it), and it is why an op places a value only through a deep-frozen copy, never a mutable container that could then be reached from two places. - `pointerFields` — which fields hold JSON Pointers (`["path"]`, or `["from", "path"]` for `move`). This is the single source for the op's changed leaf paths. - `structural` — whether the op changes a container's key-set. Two path computations derive from `pointerFields` + `structural` via the exported `touchedPointerPaths` / `patchOpIsStructural` helpers, instead of re-enumerating the ops: - `engine.ts` `touchedLeafPathsForPatch` — the exact leaf paths, for recursive-read commit conflicts and the scheduler reader-dirty index. - `engine.ts` `touchedPathsForPatch` — leaf paths plus the parent path for structural ops, for shape-only (nonRecursive) readers. The client's optimistic pending replay derives no paths: it applies a pending layer's ops to the delivered base through the shared `applyPatchToDocument` (`memory/v2/patch.ts`), skipping the whole layer when its ops cannot apply. ### 2. Mergeable-op descriptors — `packages/runner/src/storage/mergeable-ops.ts` The runtime/commit half of a mergeable op. Each op appears once and owns: - how repeated deltas fold into one intent (`foldMergeableIntent`); - how an intent becomes wire ops and the diff-suppression it implies, or is abandoned in favor of the plain diff (`buildMergeableIntent`). The transaction records intent through the single `recordMergeableOp(address, delta)` method (a discriminated `MergeableOpDelta`), declared on `IStorageTransaction` / `IExtendedStorageTransaction` and implemented once on the transaction and once on each wrapper. There is no per-op transport method to add. ### 3. Author-method catalog — `packages/api` `MERGEABLE_OP_METHODS` The canonical list of mergeable Cell methods: each entry pairs the author method name with the wire tag it records and how the pattern transformer classifies the write (`scalar-writer` vs `array-identity-writer`). The transformer's method classification (`packages/ts-transformers/src/policy/capability-analysis.ts` and `.../transformers/pattern-body-reactive-root-lowering.ts`) folds this catalog into its broader Cell-API sets, so a new mergeable op is classified with no transformer edit. The author-facing type surface — the method signatures on `IWritable` in `packages/api/index.ts` — is separate and stays hand-written TypeScript; a method present in the catalog but not declared there simply cannot be called. ## Adding a mergeable op — checklist Say you are adding a `min` op that lowers a number toward a floor. 1. **Wire descriptor** — add a `PatchOp` variant in `packages/memory/v2.ts`, an apply helper and a `patchOpDescriptors` entry in `packages/memory/v2/patch.ts` (with `pointerFields` and `structural`). The `Record` type forces the entry; apply and all three path computations follow. 2. **Mergeable descriptor** — add the intent/delta variants and a descriptor in `packages/runner/src/storage/mergeable-ops.ts` (fold + build). 3. **Catalog** — add an entry to `MERGEABLE_OP_METHODS` in `packages/api`. 4. **Cell method** — add the method to `IWritable` in `packages/api/index.ts` and implement it in `packages/runner/src/cell.ts`, calling `recordMergeableOp` with the new delta. 5. Run `packages/runner/test/mergeable-op-registry.test.ts` — it cross-checks that the catalog, the runner registry, and the wire descriptors agree. What catches a mistake, and how loudly: - A wire descriptor missing for a union tag (or vice versa) — **compile error**. - A mergeable op whose `wireOp` is not a registered patch op, or a catalog/registry mismatch — **failing consistency test**. - A Cell method in the catalog but not declared on `IWritable` — **the method cannot be called** (type error at the call site). - The transformer classification — **automatic** from the catalog; nothing to forget. ## Where each concern lives (reference) | Concern | Location | | ------- | -------- | | Wire shape (`PatchOp` union) | `memory/v2.ts` | | Apply + `pointerFields` + `structural` | `memory/v2/patch.ts` (`patchOpDescriptors`) | | Conflict / reactivity touched paths | `memory/v2/engine.ts` (derives from the descriptors) | | Client pending replay | `runner storage/v2.ts` `applyPendingVersion` (applies ops via the shared `applyPatchToDocument`) | | Whole-value diff → structural/`replace`/`splice` ops | `runner storage/v2-transaction.ts` | | Mergeable intent fold + build | `runner storage/mergeable-ops.ts` | | Intent transport (`recordMergeableOp`) | `runner storage/interface.ts`, `extended-storage-transaction.ts` | | Author methods (impl) | `runner cell.ts` | | Author methods (types) | `api/index.ts` (`IWritable`) | | Author-method catalog | `api/index.ts` (`MERGEABLE_OP_METHODS`) | | Transformer classification | `ts-transformers` capability-analysis + reactive-root-lowering | | Own-read suppression marker | `runner storage/reactivity-log.ts` (`mergeableOpRead`) |