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.
Node and connection collections
Section titled “Node and connection collections”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.
Structural writes
Section titled “Structural writes”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.
Direct writes
Section titled “Direct writes”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 thanupdateNode.
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.
Client-minted ids
Section titled “Client-minted ids”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.
Change capture
Section titled “Change capture”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_idand callcaptureChangeafter the write succeeds. Without asession_id,captureChangeuses the caller’sactivesession on the map, starting one if need be (ensureActiveSession). The editor’s first writes can land beforestartSessionreturns. - Imports and API writes call
recordStandaloneChange. It creates a session ascollapsing, records one change, and sets it tocollapsed. It’s neveractive, becauseensureActiveSessionwould 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.
Map recipe
Section titled “Map recipe”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_throughholds thecreated_atof 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 newestatomic_changeof a data event type on the map, joined toedit_sessioninsideapp, and compares it withvalidated_throughin SQL, at full precision. A blank recipe is never stale. It’ssecurity invoker, so RLS limits it to maps the caller can open.readRecipepassesDATA_EVENT_TYPESin, 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_byandupdated_at. validateconfirms against the data as it is now:validated_through,validated_byandvalidated_at. The editor always validates; the public API only withvalidated: true.- A first save without
validatestill setsvalidated_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
validateleaves 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/.
Placed and unplaced nodes
Section titled “Placed and unplaced nodes”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 bycreated_atthenid. Theidtiebreak matters because an import stamps a whole chunk with the samecreated_at. Each row carries its properties and field values, so placing it needs no further fetch. Returnstotaltoo.placeNodeswrites positions throughnetwork.set_node_positionsin one statement. It refuses withMAX_NODES_EXCEEDEDif the map’s placed count would pass 200, andNODE_NOT_IN_MAPfor a node outside the map.unplaceNodesnulls the positions. The node and its connections are kept.moveNodesrepositions up to 200 placed nodes in one call. It refusesNODE_NOT_PLACEDfor 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.
The entry comboboxes
Section titled “The entry comboboxes”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.
Node attribute combobox
Section titled “Node attribute combobox”The Add row in the node’s details list (inspector/node-attributes-section.tsx).
buildNodeEntryOptions (inspector/node-entry-options.ts) returns, in order:
assign: existing measures that match, most used on this map first. Tags are left out; they have their own section.propertyfor each matching property key other nodes use, each followed bypromotePropertywhen the key’s values are all numeric.propertywith the typed text, thencreateField(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.
Connection entry combobox
Section titled “Connection entry combobox”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.
Duplicate names
Section titled “Duplicate names”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.
Property keys and promotion
Section titled “Property keys and promotion”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’spropertiesand returns each key withnodeCountandnumeric.numericis true when every non-blank value parses as a number. Sorted bynodeCount, 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, upsertsnode_measurefor 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 refusesPROPERTY_KEY_REQUIRED,PROPERTY_KEY_NOT_FOUNDandPROPERTY_NOT_NUMERIC. The editor then invalidates the property keys and fields and refetches the graph.
Tags and measures
Section titled “Tags and measures”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.
Group nodes by
Section titled “Group nodes by”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.
Connection rules
Section titled “Connection rules”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:
parsePartialDatereads2024,2024-06or2024-06-15(ignoring a trailing time). It rejects impossible days and years below 1000.toPartialStringwrites the wire form.periodOfgives the inclusive first and last day.datesProblemrefuses a period that ends before it starts. The database’sconnection_valid_period_orderconstraint checks the same.matchesTimeFilterdecides 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.