Developers

Operations & transactions

How Desyn designs change — the eight operation types, transactions, applyTransaction semantics, dropped ops, inverses for undo, and canonical JSON.

On this page

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#

docs/DOCUMENT_MODEL.md
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"[] };
OpUse it to
createInsert 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).
updateShallow merge of top-level properties with set, removal with unset. Nested values are replaced whole. Cannot change id, type, parent or children.
patchDeep set at a path, for fine edits such as ["layout", "gap"] or ["style", "fills", 0, "color"].
deleteRemove a node and its subtree. If the node is already gone, the op is dropped as missing-node and nothing changes.
moveReparent 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.
assetAdd, update or remove AssetRefs.
stylesDeep set into Design.styles (colors, text styles, effect sets, variables).
docChange the design's name, kind, brand kit or meta; unset: ["brandKitId"] detaches the brand kit.

Transactions#

docs/DOCUMENT_MODEL.md
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 in dropped), never throws. applied is the exact (possibly rewritten) op list that took effect.
  • inverse replays to the previous state exactly (undo). Undo groups map 1:1 to transactions.
  • create with index out of range clamps. move into own descendant is dropped. update cannot change id, 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, move index semantics, applied normal forms, inverse ops, path resolution, canonical number algorithm) are specified in spec/fixtures/README.md.

Examples#

Recolor a headline and rename it:

JSON
{
  "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:

JSON
{
  "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:

JSON
[
  { "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:

JSON
{
  "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):

ReasonThe op…
invalid-opis malformed: unknown op, missing or ill-typed fields, a key in both set and unset, or a doc change to an unknown field
missing-nodetargets a node (update, patch, delete, move) that does not exist
missing-parentnames a create or move parent that does not exist
not-containerputs children into a node that is not a root, page, frame, group or boolean
cyclemoves a node into itself or one of its descendants
duplicate-idcreates a node whose id already exists, repeats within the op, or is "root"
immutable-fielduses update or patch on id, type, parent or children
invalid-nodeproduces a node that fails validation, or creates a structurally inconsistent subtree
root-protecteddeletes or moves the root, or creates another root
page-nestingplaces a page anywhere but directly under the root, or anything else directly under the root
invalid-pathpatches an empty path or a path that does not resolve
invalid-valuesets 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:

TypeScript
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.

Popular pages

QuickstartGet started
Keyboard shortcutsEditor
The agentAI
Docker Compose quickstartSelf-hosting
Configuration referenceSelf-hosting
Agent API (v1)Developers