Aller au contenu

Metaltura — Workspace Internals

This document covers how the Workspace class works: raw vs linked elements, the loading pipeline, element addressing, the update protocol, and how React stays in sync.

Raw vs Linked Elements

Models are stored and transmitted as plain objects (YAML or JSON). In this raw form, relations are represented as string paths, not object references:

# Raw (on disk / over the wire)
name: Test
instanceOf: core.Model
elements:
  - name: asdf
    instanceOf: core.Package
    elements:
      - name: Car
        instanceOf: core.Class
        extends:
          - core.RootElement        # ← string path, not an object
        properties:
          - name: wheels
            instanceOf: core.Relation
            to: asdf.Wheel          # ← string path
            composite: true
            opposite: asdf.Wheel.car # ← string path
      - name: Wheel
        instanceOf: core.Class
        properties:
          - name: car
            instanceOf: core.Relation
            to: asdf.Car
            opposite: asdf.Car.wheels

After loading, the same data becomes linked: every relation holds a direct object reference:

// After loading
car.instanceOf          // → Class instance (core.Class)
car.extends             // → [Class instance (core.RootElement)]
car.properties[0]       // → Relation instance
car.properties[0].to    // → Class instance (asdf.Wheel)
car.properties[0].opposite // → Relation instance (asdf.Wheel.car)

Relations marked composite: true additionally have their opposite set to point back to the owner (e.g., wheel.car === car).

The Loading Pipeline

Loading a raw model snapshot goes through four steps:

loadModels(rawModels[])
  ├── validate each entry with isRawModel()
  ├── topologicalSort()      ← sort by 'depends' so dependencies load first
  └── for each rawModel:
        loadModel(rawModel, previouslyLoaded)
          ├── create Model instance
          ├── resolve depends[] → Model objects
          ├── for each rawElement: rawToElement(rawElement)
          │     ├── get instanceOf class via workspace.get(path, 'class')
          │     ├── getBestClass() → pick TypeScript class (Class, Relation, etc.)
          │     ├── new BestClass({ name, instanceOf })
          │     └── set primitive props and raw (unlinked) relation values
          ├── set element.container = model
          └── for each element: linkElement(element)
                └── walk non-composite relations, replace string paths with objects

topologicalSort — ensures that if model B depends on model A, A is loaded before B. Elements in B may reference elements in A (e.g., instanceOf: A.SomeClass).

rawToElement — creates the TypeScript class instance that best represents the element. The mapping from metamodel class to TypeScript class is hardcoded in getBestClass() in rawToElement.ts. PrimitiveProperty values are set directly; composite-relation values are recursively rawToElement-d; non-composite relations are left as raw string paths until linkElement runs.

linkElement — walks all non-composite relations on an element and replaces their string values with actual object references using workspace.get(path). For composite relations, it also sets the opposite pointer (e.g., wheel.car = car).

Element Paths

Every element in a workspace is addressed by a dot-separated path. The element.path() method computes it:

Element type Path format Example
Model modelName 'Test'
Element in a Package packageName.elementName 'asdf.Car'
Nested element (property, etc.) ownerPath.elementName 'asdf.Car.wheels'

The path is built by walking up the composer chain — the sequence of composite-relation owners up to (but not including) the Model:

// path.ts
export function path(this: Element): string {
  if (this.isModel()) return this.name
  const composer = this.getComposer()
  if (composer && !composer.isModel())
    return `${composer.path()}.${this.name}`
  return this.name
}

workspace.get(path) walks the element tree by splitting on . and following composite children — it does not do a flat map lookup, so it reflects the live structure.

Test.yaml paths:

Test              → the Model
asdf              → the Package
asdf.Car          → the Car class
asdf.Car.year     → the 'year' PrimitiveProperty
asdf.Car.wheels   → the 'wheels' Relation
asdf.Wheel        → the Wheel class
asdf.Wheel.car    → the 'car' Relation

The Update Protocol

All workspace mutations — both local and server-side — are expressed as Update objects. The full discriminated union:

type Update =
  | SetUpdate
  | UnsetUpdate
  | AddUpdate
  | RemoveUpdate
  | DeleteUpdate
  | Move

set — assign a single property value

// Set a primitive
workspace.update({
  action: 'set',
  element: 'asdf.Car',
  property: 'icon',
  value: 'DirectionsCar'
})

// Set a non-composite relation (value is the target's path)
workspace.update({
  action: 'set',
  element: 'asdf.Car.wheels',
  property: 'to',
  value: 'asdf.Wheel'
})

// Set a composite relation (value is a RawElement)
workspace.update({
  action: 'set',
  element: 'asdf.Car',
  property: 'someCompositeProperty',
  value: { name: 'child', instanceOf: 'core.Class' }
})

unset — remove a property value

workspace.update({
  action: 'unset',
  element: 'asdf.Car',
  property: 'icon'
})

add — append to a multiple property

// Add a new element to a composite multiple relation
workspace.update({
  action: 'add',
  element: 'asdf.Car',
  property: 'properties',
  value: { name: 'color', instanceOf: 'core.PrimitiveProperty', type: 'core.StringType' },
  index: 0   // optional, defaults to append
})

remove — remove an item from a multiple property by index

workspace.update({
  action: 'remove',
  element: 'asdf.Car',
  property: 'properties',
  index: 2
})

delete — remove an element from the workspace entirely

Also cleans up all references to it from other elements.

workspace.update({
  action: 'delete',
  element: 'asdf.Car'
})

move — move an element to a different owner

workspace.update({
  action: 'move',
  element: 'asdf.Car.year',
  property: 'properties',
  to: 'asdf.Wheel',   // new owner
  index: 0            // optional position
})

Batching

Any update() call accepts either a single Update or an array. When using an array, all updates are applied sequentially and workspace.next() is called once at the end:

workspace.update([
  { action: 'set', element: 'asdf.Car', property: 'icon', value: 'Car' },
  { action: 'add', element: 'asdf.Car', property: 'properties', value: { ... } }
])

React Reactivity

The Problem

The Workspace uses a mutable object graph: elements hold direct references to other elements. When workspace.update() runs, it mutates properties in place. React does not detect in-place mutations — it only re-renders when state references change.

Simply exposing workspace as a useMemo value is not sufficient, because the workspace instance reference stays the same across local updates, and workspace.models is always the same array (mutated in place).

The Solution — Version Counter

Workspace already has a BehaviorSubject<Model[]> (workspace.subject) that fires via workspace.next() at the end of every update(). The React layer subscribes to it with a useEffect and increments a version counter:

// WorkspaceContext.tsx
const [version, setVersion] = useState(0)

useEffect(() => {
  const subscription = workspace.subject.subscribe(() =>
    setVersion((v) => v + 1)
  )
  return () => subscription.unsubscribe()
}, [workspace])

This version counter is exposed in WorkspaceContext.state.version and flows into MetalturaContext.

How to Use the Version Counter

Any useMemo or useEffect that reads mutable element data must include version in its dependency array. Without it, the memo will return stale values even after a workspace mutation causes a re-render:

// ✅ Correct — rows recompute when element properties change
const { workspace: { state: { version } } } = useMetalturaContext()
const rows = useMemo(() => computeRows(selectedElement), [selectedElement, version])

// ❌ Wrong — workspace.models is the same array reference after mutation
const rows = useMemo(() => ..., [selectedElement, workspace.models])

When Full Reconstruction Happens

Full reconstruction (new Workspace(rawModels)) only happens when the server pushes a new snapshot via the pub/sub subscription. This replaces the workspace instance entirely, which resets version via the useEffect([workspace]) cleanup/re-subscribe cycle. Local optimistic updates (via workspace.update()) never trigger reconstruction.

Metaltura Context Hierarchy

MetalturaContextProvider
└── MetalturaContext (via DefaultMetalturaContext)
    ├── workspace: WorkspaceContext
    │   ├── state.workspace: Workspace   ← the live instance
    │   ├── state.version: number        ← increments on every mutation
    │   └── actions.update(Update)       ← apply + send to server
    ├── selection: SelectionContext
    │   ├── state.selectedElement?: Element
    │   └── actions.selectElement(element)
    ├── editor: EditorContext
    │   ├── state.elements: Element[]    ← open tabs
    │   ├── state.openedTab?: string
    │   └── actions.open / selectTab / closeTab
    └── creation: CreationContext
        └── actions.createElement(params) ← opens dialog

Entry points:

// Full context
const ctx = useMetalturaContext()

// Workspace instance only (shorthand)
const workspace = useWorkspace()

// Diagram state (inside a Diagram component)
const { paper, graph } = useDiagramContext()

Known Pre-existing TypeScript Errors

Running tsc --noEmit reports errors in these workspace files:

  • Workspace/add.ts
  • Workspace/addRelation.ts
  • Workspace/move.ts
  • Workspace/remove.ts
  • Workspace/set.ts
  • Workspace/unset.ts

These errors exist because workspace.get() returns RealValue<Name, Required, false> which includes undefined when Required = false, but the callers use the result without a null-check. The project builds and runs correctly despite these errors — they are pre-existing and not regressions. The fix would be to either add runtime guards or change the required flag on those calls.

Do not be alarmed by these when running a type-check. Do not attempt to fix them without understanding the full call graph.