4. Document core
The in-memory model is the tree of FMT-001 with every WAL event folded in.
Esta página está em inglês; a tradução está a caminho.
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):
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#
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):
- Fold
theirs(the external events) onto the common base. - Re-apply
ours(the local commits not yet saved, and the open draft) event by event: a set on a property thattheirsalso set is kept (ours is later in the file) and reported as a conflict; an event on a node thattheirsdeleted is dropped and reported; anode.moveinto a parent thattheirsdeleted 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). - Assign ours fresh
seqvalues 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-versionstay correct.
How the controller finds the base and theirs for a changed file is §10.4.