MallaDocs

The page document

PageDoc, nodes, ops, Yjs and how every edit flows from a gesture to Postgres.

Developer guide

Every page, component and the canvas scratch area is a PageDoc: a flat map of nodes with parent and children links. It is stored as one jsonb value per page, edited as a Yjs document, and rendered by the same renderer everywhere.

Shape

ts
type NodeId = string

interface Node {
  type: 'body' | 'section' | 'frame' | 'text' | 'link' | 'image' | 'button'
      | 'richText' | 'collectionList' | 'form' | 'input' | 'textarea' | 'select'
      | 'checkbox' | 'instance' | …
  parent: NodeId | null
  children: NodeId[]
  props: Record<string, unknown>   // literals, { $expr }, or { $model }
  styles: Styles                   // { base: {…}, 'base:hover': {…}, tablet: {…} }
}

interface PageDoc {
  schema: 1
  root: NodeId
  nodes: Record<NodeId, Node>
  variables?: Record<string, VariableDef>
  queries?: Record<string, QueryDef>
  workflows?: Record<string, WorkflowDef>     // client workflows only
  interactions?: Record<string, Interaction>
}
json
{
  "schema": 1,
  "root": "n_root",
  "nodes": {
    "n_root": { "type": "body", "parent": null, "children": ["n_hero"], "props": {}, "styles": {} },
    "n_hero": {
      "type": "section", "parent": "n_root", "children": ["n_h1"],
      "props": { "classes": ["cls_hero"] },
      "styles": { "base": { "paddingBlock": "120px" }, "mobile": { "paddingBlock": "64px" } }
    },
    "n_h1": {
      "type": "text", "parent": "n_hero", "children": [],
      "props": { "tag": "h1", "text": { "$expr": "item.title" } }, "styles": {}
    }
  }
}
  • children orders siblings; parent gives O(1) lookups. The op layer keeps both consistent.
  • Ids are created on the client for optimistic inserts. Duplicating or pasting a subtree remaps every id.
  • Links are typed ({ type: 'page', pageId } survives renames); assets are referenced by id, never by URL.
  • Identity and structure props (componentId, variant, overrides, query, layout…) cannot be bound.

Why one document per page

Loading is one row, saving is one write, and publishing, versioning and duplicating are document copies. Yjs maps onto it one-to-one. Moves are validated in code without recursive SQL. Typical pages are 200–2,000 nodes. Because the flat map has the shape of rows, moving to a nodes table later would be a script, not a redesign.

Ops

Panels, canvas gestures, the AI and MCP never touch the Yjs document directly. They build typed ops, and the core validates and applies them in one transaction.

ts
type Op =
  | { t: 'setProp'; id: NodeId; path: string[]; value: unknown }
  | { t: 'setStyle'; id: NodeId; key: StyleKey; prop: string; value: string | null }
  | { t: 'setStyleBinding'; id: NodeId; key: StyleKey; prop: string; value: { $expr: string } | null }
  | { t: 'setMeta'; id: NodeId; name?: string; hidden?: boolean; locked?: boolean }
  | { t: 'insert'; id: NodeId; node: Omit<Node, 'parent' | 'children'>; parent: NodeId; index: number }
  | { t: 'insertSubtree'; root: NodeId; nodes: Record<NodeId, Node>; parent: NodeId; index: number }
  | { t: 'move'; id: NodeId; parent: NodeId; index: number }
  | { t: 'delete'; id: NodeId }
  | { t: 'setInteraction'; id: string; interaction: Interaction | null }
  | { t: 'setVariable'; id: string; variable: VariableDef | null }
  | { t: 'setQuery'; id: string; query: QueryDef | null }
  | { t: 'setWorkflow'; id: string; workflow: WorkflowDef | null }

editor.document.transact(ops, { mergeKey })   // one undo step; mergeKey joins a drag

Validation rejects cycles on move, deleting the root, children on leaf types, and props or styles that fail the schemas.

Yjs mapping

  • Each node is a Y.Map; children is a Y.Array so concurrent inserts merge.
  • props and styles are maps, so two people editing different properties of one node never conflict.
  • The undo manager tracks the origins user, ai and mcp; every user undoes only their own changes.
  • Site-level state (pages list, classes, tokens, components, variables) lives in a separate site document.