Developers
Operations & transactions
How Desyn designs change — the eight operation types, transactions, applyTransaction semantics, dropped ops, inverses for undo, and canonical JSON.
Designs never change except through transactions: an ordered list of operations with an id, a label and a source. The editor, collaborators, the AI, workflows, imports and the Agent API all use the same operations, which is why every change is live, undoable and attributable.
Operations#
type Op =
| { op: "create"; nodes: Node[]; parent: Id; index?: number } // nodes[0] is the subtree root (its parent field is overwritten with `parent`); others in `nodes` are its descendants; all ids must be new
| { op: "update"; id: Id; set?: Record<string, unknown>; unset?: string[] } // shallow top-level property merge (nested values are replaced whole)
| { op: "patch"; id: Id; path: (string | number)[]; value: unknown } // deep set at a path (arrays by index; use for fine edits like layout.gap)
| { op: "delete"; id: Id } // removes id and its subtree; no-op when missing
| { op: "move"; id: Id; parent: Id; index?: number } // reparent / reorder; index omitted = top (end of children)
| { op: "asset"; set?: Record<Id, AssetRef>; remove?: Id[] }
| { op: "styles"; path: (string | number)[]; value: unknown } // deep set into Design.styles
| { op: "doc"; set: { name?: string; kind?: DesignKind; brandKitId?: string; meta?: Record<string, unknown> }; unset?: "brandKitId"[] };| Op | Use it to |
|---|---|
create | Insert a subtree. nodes[0] is the subtree root (its parent is overwritten with parent); the other nodes are its descendants. All ids must be new. index positions it among the parent's children (omitted = top). |
update | Shallow merge of top-level properties with set, removal with unset. Nested values are replaced whole. Cannot change id, type, parent or children. |
patch | Deep set at a path, for fine edits such as ["layout", "gap"] or ["style", "fills", 0, "color"]. |
delete | Remove a node and its subtree. If the node is already gone, the op is dropped as missing-node and nothing changes. |
move | Reparent or reorder. index is the node's final position, counted after it is removed from its old place; omitted = top (end of children). Position is not adjusted, so x/y stay relative to whichever parent the node ends up in. |
asset | Add, update or remove AssetRefs. |
styles | Deep set into Design.styles (colors, text styles, effect sets, variables). |
doc | Change the design's name, kind, brand kit or meta; unset: ["brandKitId"] detaches the brand kit. |
Transactions#
interface Transaction {
id: Id; // client-generated, idempotency key
label: string; // human label for history ("Move headline", "AI: make it more premium")
ops: Op[];
source: "user" | "ai" | "workflow" | "import" | "template" | "system";
actor?: { userId?: string; runId?: string; agent?: string };
ts: number; // ms epoch (informational)
}id is generated by the client and is the idempotency key: committing the same transaction twice has no further effect. label is what history shows ("Move headline", "AI: make it more premium"). actor.runId ties AI and workflow transactions to their run so clients can group them into one undo step.
Semantics#
Semantics (identical in Rust and TS, verified by fixtures):
applyTransaction(design, tx) -> { design, inverse: Transaction, applied: Op[], dropped: { index: number; reason: string }[] }. Ops are applied in order. An op that references a missing node/parent, would create a cycle, or violates an invariant is dropped (reported indropped), never throws.appliedis the exact (possibly rewritten) op list that took effect.inversereplays to the previous state exactly (undo). Undo groups map 1:1 to transactions.createwithindexout of range clamps.moveinto own descendant is dropped.updatecannot changeid,type,parent,children(use create/delete/move). Unknown keys are preserved (forward compatibility).- Layout/derived-geometry normalization runs after the op list (Rust core + worker). TS appliers used by Convex do not run layout; they apply ops verbatim and trust the derived values the client included in the same transaction.
- Canonical JSON: keys sorted lexicographically, no insignificant whitespace, numbers normalized (no
-0, max 6 decimals), used for hashing, fixtures and golden comparison. - The exact rules (drop-reason vocabulary and check order,
moveindex semantics,appliednormal forms, inverse ops, path resolution, canonical number algorithm) are specified inspec/fixtures/README.md.
Examples#
Recolor a headline and rename it:
{
"id": "tx1a2b3c4d5e",
"label": "Warmer headline",
"source": "user",
"ts": 1792483260000,
"ops": [
{ "op": "patch", "id": "h1a2b3c4d5e6", "path": ["style", "fills", 0, "color"], "value": "#ffb02e" },
{ "op": "update", "id": "h1a2b3c4d5e6", "set": { "name": "Headline" } }
]
}Add a button (a frame with auto-layout containing a text) at the top of a page:
{
"op": "create",
"parent": "p8k2m4n6q8r0",
"nodes": [
{
"id": "b7u8t9t0o1n2", "type": "frame", "name": "CTA", "role": "cta", "parent": "p8k2m4n6q8r0",
"x": 96, "y": 1150, "width": 220, "height": 64, "rotation": 0,
"opacity": 1, "blend": "normal", "visible": true, "locked": false, "effects": [],
"children": ["l4b5e6l7t8x9"], "clip": false,
"fills": [{ "type": "solid", "color": "#ffb02e" }], "strokes": [], "cornerRadius": 999,
"layout": {
"mode": "row", "gap": 8, "padding": [18, 28, 18, 28],
"mainAlign": "center", "crossAlign": "center"
},
"sizing": { "h": "hug", "v": "hug" }
},
{
"id": "l4b5e6l7t8x9", "type": "text", "name": "Label", "parent": "b7u8t9t0o1n2",
"x": 28, "y": 18, "width": 164, "height": 28, "rotation": 0,
"opacity": 1, "blend": "normal", "visible": true, "locked": false, "effects": [],
"runs": [{ "text": "Shop now" }],
"style": {
"fontFamily": "Instrument Sans", "fontWeight": 600, "fontStyle": "normal", "fontSize": 22,
"fills": [{ "type": "solid", "color": "#0a0a0a" }], "letterSpacing": 0,
"textCase": "none", "decoration": "none"
},
"paragraph": {
"align": "center", "direction": "ltr", "lineHeight": { "unit": "auto", "value": 0 },
"spacing": 0, "indent": 0, "list": "none"
},
"autoResize": "width", "verticalAlign": "middle"
}
]
}Reorder a layer to the bottom of its parent and change the brand color everywhere it is referenced:
[
{ "op": "move", "id": "bg0a1b2c3d4e", "parent": "p8k2m4n6q8r0", "index": 0 },
{ "op": "styles", "path": ["variables", "brand.primary"], "value": "#ff6a1f" }
]Dropped ops#
applyTransaction never throws on bad input. An op that references a missing node or parent, would create a cycle, or would break an invariant is skipped and reported:
{
"applied": [{ "op": "update", "id": "h1a2b3c4d5e6", "set": { "name": "Headline" } }],
"dropped": [{ "index": 0, "reason": "missing-node" }]
}The reason is one of a fixed vocabulary (DropReason in @desyn/schema, specified with the check order for every op in spec/fixtures/README.md):
| Reason | The op… |
|---|---|
invalid-op | is malformed: unknown op, missing or ill-typed fields, a key in both set and unset, or a doc change to an unknown field |
missing-node | targets a node (update, patch, delete, move) that does not exist |
missing-parent | names a create or move parent that does not exist |
not-container | puts children into a node that is not a root, page, frame, group or boolean |
cycle | moves a node into itself or one of its descendants |
duplicate-id | creates a node whose id already exists, repeats within the op, or is "root" |
immutable-field | uses update or patch on id, type, parent or children |
invalid-node | produces a node that fails validation, or creates a structurally inconsistent subtree |
root-protected | deletes or moves the root, or creates another root |
page-nesting | places a page anywhere but directly under the root, or anything else directly under the root |
invalid-path | patches an empty path or a path that does not resolve |
invalid-value | sets an asset, styles or doc value of the wrong shape |
A dropped op changes nothing; the ops after it still run against the state left by the ops before it.
This matters for collaboration: by the time your transaction reaches the server, someone may have deleted the node you edited. Your other ops still apply.
Undo#
Applying a transaction also returns its inverse, a transaction that restores the previous state exactly. Undo applies the inverse; redo re-applies the original. Each transaction is one undo step.
In TypeScript#
@desyn/doc-ops implements the same semantics as the Rust core (verified by shared conformance fixtures in spec/fixtures/), and includes builders for common edits:
import { applyTransaction, makeTransaction } from "@desyn/doc-ops";
const tx = makeTransaction(
[{ op: "patch", id: "h1a2b3c4d5e6", path: ["style", "fontSize"], value: 112 }],
"Bigger headline",
{ source: "ai", actor: { agent: "my-agent" } },
);
const { design: next, inverse, applied, dropped } = applyTransaction(design, tx);TypeScript appliers used by Convex apply ops verbatim and do not run layout; they trust the derived geometry (text boxes, auto-layout positions) the client included in the same transaction. The Rust core and the render worker run layout after every transaction.