Skip to content

Editing internals

How the map editor reads and writes a map on the client, and what the server records for each write. For the server functions’ signatures, see Server functions.

A map’s graph lives on the client in two TanStack DB collections, one for nodes and one for connections. MapWorkspace creates them through useMapCollections and passes the same instances to all three modes, so switching mode never rebuilds them. Only a change of mapId recreates them.

Both collections load through queryClient.fetchQuery(graphQueryOptions(mapId)). TanStack Query dedupes the ['graph', mapId] key, so the two collections cost one getGraph call. fetchQuery respects invalidation, so collection.utils.refetch() reaches the server again. The collections set staleTime: Infinity, turn off refetch on mount, focus and reconnect, and every handler returns { refetch: false }. Without this, a background fetch can race an optimistic insert and drop the new row.

Where the code is: components/map-editor/lib/collections.ts.

insert, update and delete on a collection apply at once. The collection’s handler then calls the server in the background. A thrown error rolls the change back.

Handler Server call
nodes onInsert createNode, then writeInsert seeds the synced store with the new row
nodes onUpdate updateNode per node; see batched moves below
nodes onDelete deleteNode
edges onInsert createConnection, after waiting for either endpoint’s pending createNode
edges onUpdate updateConnection, with swap_endpoints when source/target changed
edges onDelete deleteConnection

The editor tracks in-flight creates in pendingNodeCreates (id → the transaction’s isPersisted promise). Dragging a connection out to a new node creates both in one gesture. The connection’s server call waits for the node’s create to commit.

Batched moves. When an onUpdate transaction moves more than one node and changes nothing but x/y, the moves go up as moveNodes calls of up to 200 each. The Arrange menu uses this. A single drag keeps the updateNode path.

Some edits skip the handlers. They write the collection’s synced state locally and call the server separately:

  • Label, property and field edits on nodes, and every connection edit, use useOptimisticUpdate (hooks/use-optimistic-update.ts). It applies the change at once and debounces the server write by 500 ms, so typing doesn’t send a request per keystroke.
  • Placing and unplacing call placeNodes/unplaceNodes, which are bulk RPCs with their own cap check rather than updateNode.

These writes use writeNodeUpsert/writeEdgeUpsert (wrappers over utils.writeUpsert). writeUpdate throws when the row isn’t in the synced store yet, for example while its create is in flight. writeUpsert doesn’t merge, so the wrappers build the full row from collection.get(id) first. On a failed server write the editor shows a toast and refetches the collection.

Deleting a node. deleteNode cascades the node’s connections on the server. The editor removes those edges locally with edgesCollection.utils.writeDelete. Calling edgesCollection.delete instead would send deleteConnection for rows the server already removed, fail, and roll back the local removal.

The editor creates node and connection ids with crypto.randomUUID() and passes them as createNode’s node_id and createConnection’s id. The database uses coalesce(p_node_id, gen_random_uuid()). The new row renders straight away under its final id, with nothing to swap in when the server replies.

Every write to a map’s data or layout records an app.atomic_change row (event type, forward and reverse payloads, next stack_index) and bumps edit_session.last_activity_at. A map’s recipe reads these rows to tell whether it’s out of date (see Map recipe), so a data change must never go unrecorded.

  • Editor writes take an optional session_id and call captureChange after the write succeeds. Without a session_id, captureChange uses the caller’s active session on the map, starting one if need be (ensureActiveSession). The editor’s first writes can land before startSession returns.
  • Imports and API writes call recordStandaloneChange. It creates a session as collapsing, records one change, and sets it to collapsed. It’s never active, because ensureActiveSession would adopt it and pull the member’s own editor changes into it. If recording fails, it deletes the session and rethrows. The import wizard and the internal import route only log that failure, because the data is already written. The wizard records nothing when the import’s creator has been deleted.

useEditSession calls startSession when the workspace is in Edit or Table mode. startSession and captureChange’s fallback share ensureActiveSession, which returns the caller’s oldest active session on that map, so switching modes doesn’t start a new session. The collections read the session id through a ref, so it resolving after mount doesn’t recreate them.

Every event type is a data event (DATA_EVENT_TYPES), a layout event (LAYOUT_EVENT_TYPES) or a catalogue event (CATALOGUE_EVENT_TYPES). ChangeEventType is their union, so TypeScript rejects an event type in none of the lists. The captureChange server function only accepts data and layout events from the client. Only data events make a recipe out of date. When you add a write, record an event, and put any new event type in one of the lists.

Event Kind From
node.added data createNode
node.property_changed data updateNode with a label or properties; promoteNodeProperty
node.deleted data deleteNode (reverse payload snapshots the node, its connections, measures and tags)
node.field_changed / node.field_removed data setNodeField / deleteNodeField
node.tag_added / node.tag_removed data addNodeTag / removeNodeTag
nodes.tags_changed data setTagOnNodes (forward: added/removed node ids and any replaced memberships)
connection.created / connection.property_changed / connection.severed data createConnection / updateConnection / deleteConnection
map.imported data Every import: the import wizard’s ingest, the internal import route (used by Duplicate), and the public API’s /import and /changes, including calls that changed nothing
field.deleted data deleteField
field.converted_to_tag data updateField turning a measure into a tag
connection_type.value_type_changed data updateField, or the public API’s field PATCH, changing whether a connection type records a value
node.moved layout updateNode with a position
nodes.moved layout moveNodes (reverse payload holds every previous position)
node.placed / node.unplaced layout placeNodes / unplaceNodes
tag_group.created / tag_group.renamed / tag_group.deleted catalogue createTagGroup / renameTagGroup / deleteTagGroup
tag.regrouped catalogue moveTagToGroup, or updateField changing a tag’s group

Other catalogue edits (creating or renaming a field, a tag’s icon or colour) and saved views record nothing.

getMapSummaries reads these rows for each map’s last edit, and app.map_recipe_state for the recipe. Nothing else does yet: undoChange, redoChange, collapseSession, discardSession, getRevisionHistory, loadRevision and compareRevision throw NOT_IMPLEMENTED.

Where the code is: server/lib/change-capture.ts, server/functions/revision.ts, components/map-editor/hooks/use-edit-session.ts.

A map’s recipe (app.map_recipe) is out of date (stale) when the map’s data changed after the recipe was last confirmed. That’s read from the change log, not a counter, so nothing outside app is involved.

  • validated_through holds the created_at of the map’s newest data change at the moment the recipe was confirmed, or first saved.
  • app.map_recipe_state(map_id, data_event_types) finds the newest atomic_change of a data event type on the map, joined to edit_session inside app, and compares it with validated_through in SQL, at full precision. A blank recipe is never stale. It’s security invoker, so RLS limits it to maps the caller can open.
  • readRecipe passes DATA_EVENT_TYPES in, so the list of data events has one home.

planRecipeSave (server/lib/map-recipe.ts) decides what a save writes:

  • A changed body stamps updated_by and updated_at.
  • validate confirms against the data as it is now: validated_through, validated_by and validated_at. The editor always validates; the public API only with validated: true.
  • A first save without validate still sets validated_through, with nobody named, so the recipe goes stale at the next data change rather than at once. Duplicate saves the copied recipe this way.
  • A later save without validate leaves the confirmation as it was.

saveRecipe takes an optional baseUpdatedAt. When it’s given and doesn’t match the stored updated_at, the save returns conflict and writes nothing, unless overwrite is set. The update repeats the check in its filter, so a save that lands in between is caught too. The public API passes no base, so the last write wins.

Timestamps travel as the strings Postgres returned and are never parsed, so they keep their microseconds when compared or written back.

On the client, mapRecipeQueryOptions (components/map-workspace/about/queries.ts) polls getMapRecipe every 30 seconds and on window focus. The editor also invalidates it two seconds after its graph last changed.

Where the code is: server/lib/map-recipe.ts, server/functions/map-recipe.ts, server/api/v1/recipe.ts, types/map-recipe.ts, components/map-workspace/about/.

node.pos_x/pos_y are nullable. Null means the node is in the map but not on the Edit canvas. getGraph returns every node. The editor seeds React Flow from the placed subset only.

  • getUnplacedNodes({ map_id, limit, offset }) pages the unplaced nodes, ordered by created_at then id. The id tiebreak matters because an import stamps a whole chunk with the same created_at. Each row carries its properties and field values, so placing it needs no further fetch. Returns total too.
  • placeNodes writes positions through network.set_node_positions in one statement. It refuses with MAX_NODES_EXCEEDED if the map’s placed count would pass 200, and NODE_NOT_IN_MAP for a node outside the map.
  • unplaceNodes nulls the positions. The node and its connections are kept.
  • moveNodes repositions up to 200 placed nodes in one call. It refuses NODE_NOT_PLACED for an unplaced node, so it can’t place nodes past the cap.

The cap is MAX_NODES_IN_EDIT_MODE (200), in components/map-editor/lib/constants.ts and duplicated in server/functions/manual-editing.ts. The client disables Place all when the unplaced total, or placed plus unplaced, would pass it. Place all lays nodes out with gridLayout (lib/placement.ts) below the current bounds.

Two comboboxes share components/ui/entry-combobox.tsx (EntryCombobox<T>). It owns the query state, keyboard model and portalled listbox. Each surface passes a pure buildOptions, defaultIndex, optionKey and renderOption. The combobox writes nothing. It emits an intent, which map-editor.tsx routes to a write path.

The Add row in the node’s details list (inspector/node-attributes-section.tsx). buildNodeEntryOptions (inspector/node-entry-options.ts) returns, in order:

  1. assign: existing measures that match, most used on this map first. Tags are left out; they have their own section.
  2. property for each matching property key other nodes use, each followed by promoteProperty when the key’s values are all numeric.
  3. property with the typed text, then createField (a numeric measure).

defaultOptionIndex picks the first assign, else the first property. It never defaults to create or promote.

handleNodeEntryIntent handles assign, createField and promoteProperty. A property intent never reaches it: the section adds the row locally and saves it with the node’s properties.

inspector/connection-combobox.tsx, used in the inspector and inline on the canvas. buildConnectionEntryOptions (inspector/connection-entry-options.ts) returns matching connection types (most used first), then createField, then note, then editField for the current type. defaultOptionIndex picks the first assign, else note. handleConnectionEntryIntent creates a new type as boolean and undirected.

Nothing enforces unique names on measures, tags or connection types. Both builders suppress createField when the typed name matches an existing one, case-insensitively. The node builder also checks known property keys. Both intent handlers reuse a same-named definition if one slips through. A duplicate that looks structured splits clustering and filtering without warning.

Both live in server/lib/node-attributes.ts as functions over an injected client, so integration tests can call them. The server functions only authorise.

  • getNodePropertyKeys({ map_id }) reads every node’s properties and returns each key with nodeCount and numeric. numeric is true when every non-blank value parses as a number. Sorted by nodeCount, then key.
  • promoteNodeProperty({ map_id, key }) turns a property into a measure across the map. It reuses a same-named measure (ilike) or creates one, upserts node_measure for each node with a non-blank value, then removes the key from every node carrying it. Blank values are dropped, not written as 0. It refuses PROPERTY_KEY_REQUIRED, PROPERTY_KEY_NOT_FOUND and PROPERTY_NOT_NUMERIC. The editor then invalidates the property keys and fields and refetches the graph.

On the client a node’s tags and measures share one fields map: a tag’s value is true, a measure’s is a number. useNodeWrites (hooks/use-node-writes.ts) dispatches the server write:

Client call Server write
writeSetNodeField with a boolean addNodeTag
writeSetNodeField with a number setNodeField (upserts node_measure on (node_id, measure_id))
writeRemoveNodeField both deleteNodeField and removeNodeTag; the one that doesn’t apply deletes nothing

addNodeTag upserts on (node_id, tag_id), so it is idempotent. It copies the tag’s tag_group_id onto the row. unique (node_id, tag_group_id) makes every group exclusive, and a violation returns TAG_GROUP_CONFLICT. The inspector shows a group’s tags as one choice, and choosing one calls setTagOnNodes, which replaces the group’s other tag in the same request. The multi-selection’s tagging and dropping a row on a grouped Nodes list section use it too.

The table view uses the same hook, so its edits land in change capture the same way.

The editor’s Group by is a display setting (hooks/use-node-grouping.ts). The group in effect is the person’s own pick if they’ve made one, else the map’s app.map.default_group_by. The pick and the hidden sections, per group, are kept in localStorage under mm:node-grouping:<mapId>. A group that no longer exists reads as none.

lib/node-grouping.ts puts each node in the section of the first of the group’s tags it has, or NO_TAG_KEY. Hidden sections hide their nodes with React Flow’s hidden flag, so they keep their place in the canvas state. Their connections are dropped from the drawn edges, and Arrange skips them.

A tag’s icon and colour (lib/tag-style.ts, resolveTagStyles) are its stored ones, or defaults: the palette slot of its position in its group, oldest first (ties by id), and an icon guessed from whole words in its name. Every path that adds a tag to a group (moving one, creating one in a group, imports, the starter Type group) first calls settleGroupColours (fields.ts). It stores the colour each of the group’s tags is showing, and gives the newcomer the first colour none of them uses, so a group’s colours never shift. The canvas node draws an absolutely placed badge, so the handles don’t move (see custom-node.tsx’s EMPHASIS_OUTLINE_WIDTH note). Visualise reads the same styles for colour_nodes_by_group.

Definedness. isConnectionDefined (graph/definedness.ts) is true when a connection has a type, or has any property (such as a note). A typed connection is defined with or without a value. Undefined connections are stored and drawn as placeholders in the editor. They never reach the visualisation or the weight function. Every definedness check calls this function.

A type or a note. A connection means one thing. handleConnectionEntryIntent removes properties.reason when it assigns a type, and sets metric_id and value to null when it saves a note. The server doesn’t enforce this. Assigning a boolean type sets value to true. Assigning a numeric type sets it to null and highlights the value input.

Direction comes from the assigned type’s directionality. A directed type always reads source → target. swap_endpoints is the only way to reverse a connection.

Dates. A connection has optional occurredOn, validFrom and validTo. Each is a partial date: a year, a month or a day. lib/connection-dates.ts is the one parser and formatter for the editor, visualiser, server and importers:

  • parsePartialDate reads 2024, 2024-06 or 2024-06-15 (ignoring a trailing time). It rejects impossible days and years below 1000.
  • toPartialString writes the wire form. periodOf gives the inclusive first and last day.
  • datesProblem refuses a period that ends before it starts. The database’s connection_valid_period_order constraint checks the same.
  • matchesTimeFilter decides whether a connection passes the visualiser’s time filter.

The database stores each date as the first day of its period plus a precision column (server/lib/connection-dates-db.ts). parseDatesInput throws CONNECTION_DATE_INVALID for a malformed date or an out-of-order period. In the editor, applyDatesEdit builds the optimistic value and the server validates it.