4. Document core

The in-memory model is the tree of FMT-001 with every WAL event folded in.

4.1 The in-memory model#

The in-memory model is the tree of FMT-001 with every WAL event folded in. Nodes live in one flat map (ADR-4):

ts
interface DocumentState {
	readonly id: DocId;                         // "doc-…"
	readonly designVersion: string;
	readonly seq: number;                       // last folded event (branch-local counter, §3.6)
	readonly base: { treeHash: StateHash; epoch: string }; // from the wal.base line (FMT-001 §5.4)
	readonly hash: StateHash;                   // "sha256:…" over the canonical folded tree: the state id
	readonly pages: ReadonlyMap<PageId, Page>;  // order by fractional index
	readonly nodes: ReadonlyMap<NodeId, Node>;  // every node of every page
	readonly styles: ReadonlyMap<StyleId, Style>;
	readonly collections: ReadonlyMap<CollectionId, VariableCollection>;
	readonly variables: ReadonlyMap<VariableId, Variable>;
	readonly comments: ReadonlyMap<CommentId, CommentThread>;
	readonly versions: readonly NamedVersion[];
	readonly unknown: Readonly<Record<string, unknown>>; // kept fields (PLT-4)
}

interface NodeBase {
	readonly id: NodeId; readonly type: NodeType;  // "FRAME", "TEXT", "INSTANCE", … (Figma's names)
	readonly parent: NodeId | PageId; readonly index: string; // fractional index among siblings
	readonly name: string;
	// stored properties only, with Figma's names: visible, locked, opacity, blendMode, fills, strokes,
	// effects, cornerRadius, layoutMode, itemSpacing, … ; x/y only where not derived (§5.1)
	readonly unknown?: Readonly<Record<string, unknown>>;
}

Node objects are immutable. A change replaces the node object; unchanged nodes are shared between versions of the state. This makes snapshots, drafts and proposals cheap (structural sharing).

The state hash. hash is the content hash FMT-001 defines: sha256: over the canonical serialisation of the folded tree (sorted keys, no WAL, no seq, no timestamps). It is what --if-version compares (CMD-30), what a named version records, and what library.acceptedHash stores for an accepted library state, because a hash names the same state on every branch and seq does not (§3.6). base is copied from the wal.base marker: treeHash is the hash of the tree the WAL was written against and epoch identifies the consolidation that produced it. Recomputing the hash after every commit would be O(n); the core maintains it incrementally from the change record (a Merkle-style per-node digest combined in id order), and ud validate recomputes it from scratch to check.

Instance sublayers are not stored as nodes. The core derives them from the main component and the instance’s overrides (how overrides are keyed is FMT-001 §4). They get stable composite ids so the Plugin API, selectors and the CLI can address them, as Figma’s I…;… ids do, and setting a property on one becomes an override command on the instance.

4.2 Indexes#

Built once on open, then maintained by fold:

Index Key → value Used by
children parent id → child ids sorted by index (array cached per parent, rebuilt on change of that parent only) everything
byType node type → ids selectors, findAllWithCriteria, lint
byName page id + name path → id name paths (CMD-24)
instancesOf main component id → instance ids propagation (CMP-2), usage (CMP-9)
styleUsers, variableUsers style or variable id → node ids invalidation, get_variable_defs, usage
assetUsers asset hash → node ids asset garbage collection, download_assets
fontUsers family/style → text node ids missing fonts (TXT-6), font replacement
spatial (in src/render) page id → R-tree of absolute render bounds culling, hit testing, marquee (§7.4)

4.3 Event folding#

fold(state, event): FoldResult is a pure reducer with one handler per op (src/core/ops/*.ts). It returns the new state plus a change record: the ids whose stored properties changed, created and deleted ids, and which properties. The change record drives index updates, derived-cache invalidation (§4.5), webview deltas and ud watch.

Tolerance follows FMT-001 §9: an unknown op is skipped with a warning; unknown fields are kept; a set on a node that no longer exists is skipped and reported. The orphan rule (FMT-001 §6.5) has exactly two cases: a node.create whose parent is missing puts the node under its page root, and the existing children of a deleted parent go to the page root. A node.move into a missing parent is skipped and reported; the node stays where it was (FMT-001 §5.11). These rules matter because git union merges (COL-4) can interleave branches in any order; the result is still last-in-file wins per property.

Refusing to fold silently. The reader stops and reports instead of guessing when the file shows that two histories were merged by text: two wal.base lines, a wal.base whose treeHash does not match the hash of the tree it sits under, or duplicate keys in the tree’s JSON (which JSON.parse would resolve last-wins without a word; our parser counts them). The editor then offers ud rebase-branch or Open as Text (PLT-6); headless ud exits 1 with the reason. A WAL with repeated seq values is not such a case: it folds in file order and the overlap is reported as COL-4 evidence, never renumbered on disk.

Folding is incremental: opening a file folds the WAL once on top of the tree; afterwards each commit folds only its new events. A 50 000-node file with a 20 000-event WAL opens within the NFR-1 budget because folding is a map update per event and indexes are built once at the end of the initial fold, not per event.

4.4 Drafts and commit groups#

ts
interface Draft {
	get(id: NodeId): Node | undefined;      // overlay first, then base
	set(id: NodeId, patch: Partial<Node>): void;
	create(node: NewNode): NodeId;
	remove(id: NodeId): void;               // subtree
	move(id: NodeId, parent: NodeId, before?: NodeId): void; // computes a fractional index
	// styles, variables, comments … same shape
	readonly journal: readonly JournalEntry[]; // forward event + inverse event per mutation
}

interface CommitGroup {
	key: string;            // unique, from seq range
	label: string;          // undo label (CMD-12: use_figma description)
	actor: string;          // "by"
	source: string;         // "editor" | "ud" | "use_figma" | "mcp" | "ai" | "merge" | …
	events: CommittedEvent[];
	inverse: Event[];       // computed at apply time, in reverse order
}

A draft is a copy-on-write overlay over an immutable base state: reads see the overlay first. Drafts can be forked (a proposal is a long-lived draft, §10.7) and rebased (§4.7).

4.5 Derived data#

Nothing derived is stored in the file. The core and the layers above compute it on demand and cache it:

Derived Computed by Depends on
Resolved properties (instance = main + overrides; style values; variable values for the mode in effect, VAR-6) core/resolve node, main component chain, styles, variables, explicit modes of ancestors
Text layout (lines, glyph runs, baselines, size for auto-resize) text resolved text properties, fonts, available width
Layout (size and position of auto-layout children, constrained children after a resize) layout resolved layout properties, children, text layout
Absolute transform and bounds, render bounds (with strokes and effects) layout the above, ancestors
Display list per node (§7.1) render the above, paints, images
Spatial index render render bounds

Each cache entry records what it read (node ids, style ids, variable ids, font faces) in a dependency table. The change record of a fold marks dependents dirty; the next read recomputes only those. Reads never see stale data: a getter on a dirty entry recomputes it first, which is how node.width inside a script reflects a change made on the previous line.

4.6 Undo and redo as inverse events#

The WAL is append-only, so undo cannot delete events. Undo commits the inverse events of a group as a new group, and redo commits the forward events again:

  • an inverse of a property set is a set to the value it replaced;
  • an inverse of a create is a remove; an inverse of a remove is a re-create of the subtree with the same ids, index and properties (the journal keeps the removed subtree);
  • an inverse of a move is a move back to the old parent and index.

Undo is selective: before applying an inverse set, the core checks that the property still holds the value the group wrote. If someone (an agent, a merge) changed it since, that property is left alone and the undo reports it as partially undone. This keeps “Ctrl+Z undoes my change” true when an agent is editing the same file.

In VS Code each commit group becomes one CustomDocumentEditEvent with the group’s label; its undo() and redo() call controller.undo(key) and controller.redo(key) (PLT-5). The undo and redo groups are signed by whoever pressed the key, with source: "undo".

4.7 Rebasing over external events#

An open document can receive events it did not create: the file changed on disk (headless ud with --no-live, a git checkout, a merge), or a script’s or a proposal’s draft commits after other edits. All cases use one operation, rebase(base, theirs, ours):

  1. Fold theirs (the external events) onto the common base.
  2. Re-apply ours (the local commits not yet saved, and the open draft) event by event: a set on a property that theirs also set is kept (ours is later in the file) and reported as a conflict; an event on a node that theirs deleted is dropped and reported; a node.move into a parent that theirs deleted is skipped and reported, and the node stays where it was (the same rule as the fold, §4.3; existing children of the deleted parent went to the page root when the delete folded).
  3. Assign ours fresh seq values after theirs (ours were never on disk, so no written line changes; D3’s never-rewrite rule holds); recompute the inverses and the state hash of the rebased groups so undo and --if-version stay correct.

How the controller finds the base and theirs for a changed file is §10.4.