API surface
The data API is built from TanStack Start server functions
(createServerFn) — type-safe RPC endpoints, not REST routes. Convention: GET
for queries, POST for mutations and any query whose input approaches the 1 MB
GET payload limit.
The double-guard pattern
Section titled “The double-guard pattern”Auth is enforced at two independent layers — both required:
- Route
beforeLoadguard (UX only). Redirects unauthenticated browsers to/login. The_authedlayout route does this. It does not protect server functions. - Server-function middleware (security boundary). Every tenant-data function
declares
authMiddleware(validates the JWT withgetClaims()— nevergetSession()server-side) and, where org-scoped,orgMiddleware.
orgMiddleware is deliberately thin (auth pass-through only) — it can’t assert
membership itself because the target org_id/map_id lives in the handler’s
validated data, not in anything available to middleware. Each handler calls
an assertion helper from apps/app/src/server/lib/authz.ts explicitly, before
touching tenant data:
assertOrgMember(orgId)— wrapsuser_is_org_member.assertOrgRole(orgId, roles[])— wrapsuser_has_org_roleORuser_is_system_admin(system admins bypass org-role checks everywhere).assertMapAccess(mapId)— wrapsuser_can_access_map.assertSystemAdmin()— wrapsuser_is_system_admin.
Each helper opens its own RLS-scoped client so auth.uid() inside the
underlying SQL helper resolves to the caller, not to a client passed in from
elsewhere. Row-Level Security remains the real tenant-isolation boundary for
RLS-scoped-client queries; the explicit assertion turns an opaque RLS failure
into a clean, specific error and is the only boundary for service-role
handlers (which bypass RLS entirely).
For a map_id handler that also needs to check an org-level role (e.g.
renaming a map requires org_admin, not just map access), resolve the map’s
organisation_id server-side and assert on that — never trust a
separately-passed org_id alongside a map_id.
Server functions by locality
Section titled “Server functions by locality”- Profiles & settings (
profile.ts) —getProfile,updateProfile,updateSettings. RLS-scoped, upsert-by-self (profile_select_self/profile_modify_selfpolicies key onuser_id = auth.uid()). - Manual editing (
manual-editing.ts) — Locality 01, all implemented:getMap,getMaps,createMap,renameMap,deleteMap, the node/connection CRUD setcreateNode,updateNode,deleteNode,createConnection,updateConnection,deleteConnection, and the node field assignment setsetNodeField,deleteNodeField. See Node & connection editing below.deleteMapcallsnetwork.delete_map(network-only RPC) before deleting theapp.maprow, then removes the map’smap-importsStorage objects —network.map_idhas no FK intoapp.map, so without this the map’s entire graph would be orphaned (see Database schema). - Fields (
fields.ts) —getFields({ map_id, subject_type? }): fields visible to a map’s org (its own fields plus system-defined ones), optionally filtered to'node'or'connection'. Used by the connection inspector to populate its field picker, and by the map editor’s Fields panel to list and manage them.createField/updateFieldtake an optionaldirectionality('undirected' | 'directed' | 'mutual', defaults to'undirected') andinverse_name;assertValidDirectionalityrejects either being set on a non-'connection'field, andinverse_namebeing set withoutdirectionality: 'directed'(mirroring the DB check constraints — see Database schema).createField,updateField,deleteFieldare member-level (assertMapAccess, mirroring Locality 01’s editing permissions) and only ever touch the caller’s own org fields — system- defined fields (org_idnull) and other orgs’ fields are read-only from here, enforced byassertFieldEditable(org_idmust match the caller’s resolved org).getFieldUsage({ map_id, metric_id })countsnode_metric/connectionrows referencing a field across every map in the org (a field isn’t map-scoped) — the client calls this before a value-type change or deletion to decide whether to show a confirmation dialog; this is a UX gate only; there is no server-side block. Deleting a field doesn’t cascade at the DB level (no FK onmetric_id, per CLAUDE.md) — the handler explicitly deletes referencingnode_metricrows and clears (metric_id/value→null) referencing connections, i.e. it un-defines rather than deletes what the field described. - Data upload (
data-upload.ts) — Locality 02, implemented (COLLAB-5508) exceptremoveImportRow(stillNOT_IMPLEMENTED— deferred, see below):getSignedUploadUrl,createImportSession,getImportPreview,updateImportMapping,ingestImportSession,getImportStatus. See Data upload & async ingestion below. - Visualisation (
visualisation.ts) —getGraphimplemented (Locality 03 pulled forward for Locality 01);createSavedView/getSavedViewsimplemented (Locality 03 — view metadata only:map_id,created_by,name,viewport_state,visualisation_configas jsonb onapp.saved_view, never graph data).exportVisualisationremainsNOT_IMPLEMENTED(out of scope for COLLAB-5466 — no file export). - Storage & revision (
revision.ts) —startSession,captureChangeimplemented (the Locality 01 change-capture slice);undoChange,redoChange,collapseSession,discardSession,getRevisionHistory,loadRevision,compareRevisionremainNOT_IMPLEMENTED(Locality 04). - Workspace & permissions (
workspace.ts) — implemented:createOrg(service-role — see below),deleteOrg(soft-delete —deleted_atonly; the org’s members/maps/graph are hard-purged later by the maintenance sweep once past a retention window, see below),renameOrg,getOrgs,getOrg.createInvitation,removeMember,changeMemberRole,getMembers,getInvitations.getMembers/isEmailAlreadyMemberbatch-resolve member emails viaapp.get_user_emails(one SQL RPC) rather than oneauth.admin.getUserByIdcall per member — the Admin API has no bulk get-users-by-id endpoint, so the previous per-member loop scaled linearly with org size.listMyInvitations,acceptInvitation(service-role — see Workspace invites).setMapVisibility.shareMapremainsNOT_IMPLEMENTED— cross-org sharing model is an explicit follow-up (see Constraints & edge cases).
- Maintenance (
server/lib/maintenance.ts) —purgeOrphanedData, run byPOST /api/maintenance/purge(a Server Route, not acreateServerFn— guarded by a shared-secret header, not a user session, since it’s invoked by a daily cron job with no signed-in user). Sweepsnetworkrows for maps that no longer exist inapp.map, hard-purges organisations soft-deleted past the retention window (graph data + Storage + the org row itself), and failsimport_sessionrows stuck iningestingpast a timeout. See Database schema.
These live in apps/app/src/server/functions/, grouped by locality.
Node & connection editing (Locality 01)
Section titled “Node & connection editing (Locality 01)”All mutations below authorize with assertMapAccess(map_id) — editing is
member-level, unlike map create/rename/delete which require
assertOrgRole(['org_admin']). Writes use the RLS-scoped client (the caller
is a member; network RLS via app.user_is_org_member(org_id) enforces
tenancy), never the service role. org_id is always resolved server-side from
app.map via resolveMapOrgId and stamped on every row.
createNode({ map_id, label, pos_x, pos_y, properties?, session_id?, node_id? })— calls thenetwork.create_node_in_mapSQL function (atomic node + map_node insert). Returns{ node_id, map_node_id }.node_idis optional and client-supplied (the RPC doescoalesce(p_node_id, gen_random_uuid())) — the node/edge collections (see below) mint acrypto.randomUUID()client-side so the node can be inserted into the collection and rendered instantly, with this call running as the background persistence rather than something the UI waits on for a real id.updateNode({ node_id, map_id, label?, pos_x?, pos_y?, properties?, session_id? })— position fields and label/properties are independent: supplying onlypos_x/pos_ycaptures anode.movedchange; supplyinglabel/propertiescaptures anode.property_changedchange; supplying both captures both.deleteNode({ node_id, map_id, session_id? })— callsnetwork.delete_node_from_map(atomic: removes this map’s connections touching the node, removes the node’s placement in this map, and deletes the node itself only if it has no remaining placement in any other map). Returns{ ok, node_deleted }.createConnection({ map_id, source_node_id, target_node_id, metric_id?, value?, properties?, session_id?, id? })— defaultsmetric_idtonull(undefined/TBD — drawing a connection never forces a type up front). Multiple connections may exist between the same node pair; there is no uniqueness constraint on(map_id, source_node_id, target_node_id). There’s nodirectionparam — direction is derived from whichever field ends up assigned (seegetGraphbelow), not chosen per connection.idis optional and client-supplied, same rationale ascreateNode’snode_idabove.updateConnection({ connection_id, map_id, metric_id?, value?, properties?, swap_endpoints?, session_id? })—value’s JS type is validated against the referenced field’svalue_typewhenever a newvalueis supplied (seeassertValidFieldValue, shared withsetNodeFieldbelow).swap_endpoints: trueswapssource_node_id/target_node_id— since a directed field always reads source → target, this is the only way to correct a connection drawn the wrong way round without deleting and redrawing it.deleteConnection({ connection_id, map_id, session_id? })setNodeField({ map_id, node_id, metric_id, value, session_id? })— upserts onnode_metric’s(node_id, metric_id)unique constraint, so a node has at most one value per field (unlike connections, where the same field can appear on several connections).valueis required (the column isNOT NULL) and validated against the field’svalue_typethe same way as connections, withexpectedSubjectType: 'node'.deleteNodeField({ map_id, node_id, metric_id, session_id? })
Placed / unplaced nodes (COLLAB-5512)
Section titled “Placed / unplaced nodes (COLLAB-5512)”A network.map_node row is per-map membership, not necessarily
placement: pos_x/pos_y are nullable, and null means “belongs to this map
but not yet placed on the canvas”. This lets imported data (see
map export/import below) land in a map without
immediately dumping thousands of nodes onto the React Flow canvas.
getUnplacedNodes({ map_id, limit, offset })— paginated list of this map’s unplaced nodes ({ id, label }[]), ordered bymap_node.created_at, plustotal. The Nodes panel’s Unplaced list uses this;limitis the client’sNODE_LIST_PAGE_SIZE(==MAX_NODES_IN_EDIT_MODE, 200).placeNodes({ map_id, placements: [{ node_id, pos_x, pos_y }], session_id? })— upserts positions onto each node’s existingmap_noderow (they already exist as unplaced membership rows), moving it from unplaced to placed. Rejects withMAX_NODES_EXCEEDEDif the map’s placed-node count would exceedMAX_NODES_IN_EDIT_MODE(200) — client-side, the Nodes panel’s “Place all” button is disabled first, but the server re-checks. Emitsnode.placed.unplaceNodes({ map_id, node_ids, session_id? })— the reverse: nulls the position back out. The node and its connections are untouched; it just disappears fromgetGraph’s output (and the canvas) and reappears in the Unplaced list. Emitsnode.unplaced. This is what “Remove all” and the canvas/list right-click “Remove from map” context menu call.
placeNodes/unplaceNodes write straight to the nodes collection’s synced
state (nodesCollection.utils.writeUpdate, setting/clearing x/y) rather
than going through the collection’s generic onUpdate → updateNode path,
since placement is a distinct bulk RPC with its own MAX_NODES_IN_EDIT_MODE
check — see Client state below.
The RPC itself still runs in the background; on failure the collection is
refetched from getGraph to reconcile.
Client state: node/connection collections
Section titled “Client state: node/connection collections”The map editor and visualiser don’t read getGraph through a plain TanStack
Query cache — they read through two TanStack DB
collections (apps/app/src/components/map-editor/lib/collections.ts), one for
nodes and one for connections, both editing- and visualisation-mode components
subscribe to via useLiveQuery. The collections are the single source of
truth for a map’s graph on the client:
- Structural edits are instant.
nodesCollection.insert/update/deleteandedgesCollection.insert/update/deleteapply synchronously and optimistically; the corresponding server function (createNode,updateNode,deleteNode,createConnection,updateConnection,deleteConnection) runs in the background via the collection’sonInsert/onUpdate/onDeletehandler. A thrown error rolls the optimistic change back automatically — there’s no hand-rolledonMutate/onErrorrollback pair to get right per mutation. - The server is a follower, not a gate. Nothing in the editor invalidates
or refetches
getGraphafter a structural edit succeeds — the collection already reflects the change. This replaced an earlier design where every edit’s success invalidated thegraphquery, forcing React Flow’s local state to be destructively rebuilt from a fresh fetch; that round trip was the primary source of visible flicker/lag on every add/remove/connect. - A few writes bypass
onInsert/onUpdate/onDeleteon purpose. Debounced label/property/field-value edits and bulk placement (placeNodes/unplaceNodes) callcollection.utils.writeUpdate(a direct, local-only write) paired with an explicit, separately-debounced or fire-and-forget server call — these persist through a different RPC shape than the collection’s genericonUpdatehandles, or need debouncing independent of the optimistic apply. - Deleting a node doesn’t re-delete its connections client-side.
deleteNode’s RPC (delete_node_from_map) already cascades the map’s connections touching that node. The client removes those edges from the edges collection viaedgesCollection.utils.writeDelete(a direct local write, no network call) rather thanedgesCollection.delete(...), which would otherwise re-triggerdeleteConnectionfor rows the server already removed. - Both collections fetch through the same
getGraphcall. TheirqueryFns both resolve viaqueryClient.ensureQueryData(graphQueryOptions(mapId)), which TanStack Query dedupes by the['graph', mapId]key — so having a separate nodes collection and edges collection still costs one round trip, preservinggetGraph’s single-fetch, no-cross-schema-joins shape.
Coded errors
Section titled “Coded errors”Thrown as Error with a CODE: message prefix (see server/lib/errors.ts,
matching the NOT_IMPLEMENTED: convention):
| Code | Thrown when |
|---|---|
LABEL_TOO_LONG | Node label is empty or exceeds 255 characters. |
NODE_SELF_CONNECTION | source_node_id === target_node_id (also enforced by the connection_no_self DB constraint as defense in depth). |
CONNECTION_INVALID_NODES | Either node lacks a map_node row in this map. |
FIELD_NOT_FOUND | metric_id doesn’t reference an existing app.metric row. |
FIELD_WRONG_SUBJECT | The referenced field’s subject_type doesn’t match the caller ('connection' for connection mutations, 'node' for setNodeField). |
FIELD_VALUE_TYPE_MISMATCH | value’s JS type doesn’t match the field’s value_type. |
FIELD_NAME_REQUIRED | createField/updateField’s name is empty/whitespace. |
FIELD_DIRECTIONALITY_NOT_APPLICABLE | directionality/inverse_name set on a field whose subject_type isn’t 'connection'. |
FIELD_INVERSE_NAME_NOT_APPLICABLE | inverse_name set without directionality: 'directed'. |
FIELD_NOT_EDITABLE | updateField/deleteField targeted a system-defined field or one belonging to another org. |
DELETE_FAILED | network.delete_node_from_map RPC errored. |
MAX_NODES_EXCEEDED | placeNodes would push the map’s placed-node count past MAX_NODES_IN_EDIT_MODE (200). |
Change capture (minimal slice)
Section titled “Change capture (minimal slice)”Every mutation above accepts an optional session_id. When present, it calls
captureChange (server/lib/change-capture.ts) after the write succeeds,
inserting an app.atomic_change row with a forward and reverse payload and
bumping edit_session.last_activity_at. When session_id is omitted the
mutation still succeeds — no-op-safe, useful for tests and non-session
callers. revision.ts’s startSession reuses any existing active session
for the caller on that map (one active session per user per map) rather than
creating a duplicate.
What Locality 04 still owes: undo/redo (walking the atomic_change stack),
idle/countdown session expiry, and session→revision collapse. The six
mutations above already emit every change event the spec requires
(session.change_captured); Locality 04 only needs to add the consumers of
that stream (undoChange, redoChange, collapseSession, discardSession,
getRevisionHistory, loadRevision, compareRevision — currently
NOT_IMPLEMENTED), not re-touch the mutations themselves.
getGraph
Section titled “getGraph”Assembles GraphData from separate network queries (no cross-schema
join, per the hard rule): map_node (for positions) → node (label/properties)
connection+node_metricfor the collected node ids, plus a furtherapp.metricquery (also cross-schema, hence separate) to resolve thevalue_typeanddirectionalityof every distinctmetric_idreferenced by a connection. EachGraphEdge.directionis derived from its field’sdirectionalityhere ('undirected'whenmetric_idis null) — there is no per-connectiondirectioncolumn to read. Undefined connections (metric_id === null) are included in the raw payload — the edit layer needs them;apps/app/src/graph/modes.ts’stoVizModeGraphfilters them out for algorithmic views. Requires map membership (RLS) — anonymous read of a public map’s network data is a pre-existing tracked follow-up (see the RLS migration’s note), out of scope here.
Placed nodes only by default (COLLAB-5512): the map_node query filters
to pos_x is not null, and connections are filtered in app code to those
whose both endpoints are in the resulting node set — an edge to a node
outside it would otherwise dangle (React Flow warns on an edge referencing an
absent node). The connection itself is never deleted; it just doesn’t render
until both ends are placed. Use getUnplacedNodes (above) to list what a
placed-only getGraph call omits.
Passing include_unplaced: true (used by the visualisation view,
COLLAB-5466 — see visualisationGraphQueryOptions) skips the pos_x is not null filter entirely, returning every node in the map regardless of
placement; unplaced nodes come back with x/y left undefined, and the
force simulation lays them out itself (no canvas position to respect, unlike
React Flow). This is a separate query/cache key
(['graph', mapId, 'include-unplaced']) from the editor’s ['graph', mapId]
— the two calls return different node sets, so they can’t share a cache
entry.
The render-time weight function (COLLAB-5515)
Section titled “The render-time weight function (COLLAB-5515)”apps/app/src/graph/weight.ts is the single place a scalar is derived from a
node pair’s defined connections (isConnectionDefined —
apps/app/src/graph/definedness.ts; a reason-only connection counts as
defined too) — it is code, never user input. Every mode is the same
underlying two-dial calculation: contribution(connection) = importance(its type) × magnitude(this connection), then combine(...) collapses a pair’s
(possibly several) contributions into one number. magnitude is the
connection’s own value normalised per Field (computeFieldRanges +
strong-reading normalisation — positive and negative values each
independently stretch to their own graph-wide span) when its Field is
numeric, or 1 when boolean or typeless. WeightMode has four presets,
each just a different setting of importance/combine:
- Presence (default, zero-config) —
importance= 1 for every type;combine= does any defined connection exist for the pair at all? A pure existence check, not a magnitude threshold. - Count —
importance= 1 for every type;combine= sum of contributions, normalised against the graph-wide max resulting sum (computeGraphWeights, notcomputePairWeightin isolation, since this needs visibility across every pair). - Filter — user picks one type;
importance= 1 for it, 0 for every other type (including typeless) — a pair with none of the selected type resolves to exactly 0 and is dropped from view by the zero-weight-drop rule, a real filter rather than a fade. - Score — user assigns each type a score (0..1 for MVP);
combine= sum of (score × magnitude), averaged across the pair’s contributions to stay roughly bounded without a graph-wide renormalisation pass (deferred open question). A typeless connection has no type to score and contributes0.
MVP tradeoff: a negative normalised magnitude clamps to 0 rather than
driving repulsion — the force layout only ever attracts today; extending
Score to a -1..1 range for inherently-negative types (e.g. excludes) and
wiring genuine repulsion are both deferred, tracked follow-ups (see
COLLAB-5515’s open questions).
toPathCost(weight) inverts a weight into a shortest-path cost for
betweenness-centrality-style algorithms, which treat edge weight as distance
(the inverse of closeness/strength) — feeding a weight straight into a
shortest-path routine without this inversion would make strongly-connected
pairs look like long paths. apps/app/src/graph/modes.ts’s toVizModeGraph
applies the definedness filter and computeGraphWeights together, giving
every connection in a pair the same pair-level weight.
Weighting is cross-cutting, not nested inside one view-type’s controls:
it’s set once, in the shared WeightingPanel
(components/map-visualiser/panels/weighting-panel.tsx, mounted as its own
sidebar tab in viz-sidebar.tsx), and governs both the topology (Landscape)
force layout and betweenness-centrality (Bridges) path costs alike.
VisualisationConfig.weight_config (types/visualisation.ts) persists the
active WeightMode as part of a saved view; it’s optional so a saved view
persisted before this field existed still loads, defaulting to Presence
(weightConfigFor, lib/viz-config.ts).
Visualisation-mode architecture (COLLAB-5466)
Section titled “Visualisation-mode architecture (COLLAB-5466)”mode ('edit' | 'visualise') is a URL search param on
/maps/$mapId, not a path param or component-local state — switching it
never re-runs the route loader. Both ['graph', mapId] (edit) and
['graph', mapId, 'include-unplaced'] (visualise) are prefetched by the
loader up front, so the toggle never triggers a getGraph refetch either way
(verify in React Query devtools). The two graph queries are deliberately
separate cache entries, not one shared one — the visualisation view passes
include_unplaced: true (see getGraph, above) to include nodes that haven’t
been placed on the edit canvas yet, so it returns a different (superset) node
set than the editor’s placed-only query. The shared shell
(components/map-workspace/map-workspace.tsx) owns the header (Back + map
name + ModeSwitcher) and body-swaps <MapEditor> ↔ <MapVisualiser> on
mode — both still read from types/graph.ts’s shared GraphData shape,
just via different queries.
components/map-visualiser/ is react-force-graph-2d behind one seam:
viz-canvas.tsx is the only file that imports it (lazy + a mounted
gate — the library touches window/canvas at import time, so nothing else
may import it on a path that could run server-side). lib/force-adapter.ts
is the pure VizGraphData -> { nodes, links } adapter — it drops zero-weight
pairs, seeds x/y from the editor’s stored layout (never fx/fy), and
only for metric_clustering injects one synthetic, never-persisted
“meta-node” per distinct cluster-field value.
All three lenses (topology / betweenness centrality / field clustering)
reconfigure the same d3 simulation (lib/use-force-config.ts) rather than
tearing it down — betweenness centrality reuses the topology forces unchanged
(it only drives node paint size, never layout, so positions stay comparable
across lenses); field clustering adds a hand-written cluster force that
nudges each node toward its cluster’s meta-node every tick.
Betweenness centrality itself (apps/app/src/graph/betweenness.ts) is a
weighted Brandes implementation behind a BetweennessStrategy function type
— consumers depend on the type, not the concrete weightedBetweenness, so a
graphology-backed implementation can be swapped in later without touching
call sites. It’s computed inline on the main thread (deferred one tick via
setTimeout so the “Computing centrality…” indicator can paint first) rather
than in a Web Worker as originally scoped — revisit if profiling shows
main-thread jank above LARGE_GRAPH_NODE_THRESHOLD (lib/viz-config.ts)
nodes.
Saved views (createSavedView/getSavedViews) persist view metadata only
— visualisation_config + viewport_state as jsonb on app.saved_view —
never graph data or positions.
Data upload & async ingestion (Locality 02)
Section titled “Data upload & async ingestion (Locality 02)”data-upload.ts implements the upload → scan → map → ingest pipeline behind
the map editor’s Import / Export panel (COLLAB-5508). CSV and GraphML
share one pipeline and both go through a mapping step (COLLAB-5516): CSV maps
columns to a label/field/property/ignore role, GraphML maps every non-reserved
attribute key to an existing field, a new field, a property, ignore, or a node
label source. A categorical connection attribute (e.g. “type” = “follows”/
“jaccard”) is exploded into one independent row per distinct value at
scan time rather than one row for the whole attribute, so each value gets its
own choice (analyzeGraphmlAttributes/attrValueKeyOf).
getSignedUploadUrl({ map_id, filename, mime_type })— issues a short-lived Supabase Storage signed upload token (storage_path,token).org_idis deliberately not accepted from the client — always resolved server-side frommap_id(resolveMapOrgId). The browser then uploads directly to Storage’s privatemap-importsbucket; the app server is never in the upload path. Uses the service-role client — only the app server can mint a signed upload token, so there’s no RLS-scoped path for this at all.createImportSession({ map_id, filename, storage_path, format })— called once the browser’s upload completes. Inserts anapp.import_sessionrow (status: 'scanning'), then immediately downloads the file back from Storage (service-role — the object was uploaded via a signed token, not a user session, so the RLS-scoped client can’t read it) and scans it:analyzeCsv(CSV) oranalyzeGraphmlAttributes(GraphML) — both take the org’s candidate fields (loadCandidateFields) so they can suggest linking to an existing field, not just creating a new one. Scan output (including the suggested mapping) is persisted to the row’sscanjsonb column,column_mappingis pre-filled with that suggestion, andstatusmoves to'previewing'(or the session is left'invalid'with anerrormessage, and the call throws, on a parse failure). Returns anImportPreviewcombining both formats’ fields.getImportPreview({ session_id })/getImportStatus({ session_id })— RLS-scoped reads of the session row.import_session_rw’screated_by = auth.uid() OR user_can_access_map(map_id)policy is the real boundary here — no extraassertMapAccesscall needed for a read.getImportStatusis what the client polls during ingestion.updateImportMapping({ session_id, column_mapping })— both formats (COLLAB-5516). For CSV, validates exactly one column is mapped to'label'. For GraphML there’s no equivalent hard requirement (every attribute already has a default resolution), so any confirmed mapping is accepted. Either way, persists the mapping and movesstatusto'mapping'.ingestImportSession({ session_id })— asserts the session is'previewing'/'mapping'; for CSV, requires a label column mapped; for GraphML, requiresstatus === 'mapping'(i.e. the attribute mapping was actually confirmed, not just left at its scan-time suggestion). Flipsstatusto'ingesting'; then detaches a floating (un-awaited) async task and returns immediately. The detached task runs after the request has already completed, so it has no cookie/request context — it uses the service-role client, re-derivesorg_iditself, downloads the file from Storage again, and callsingestCsv/ingestGraph(passing the confirmedcolumn_mappingeither way), writing the result (or an error) back onto the session row (status: 'ingested' | 'failed'). Authorization (assertMapAccess) happens synchronously in the handler before anything is detached — the async task trusts that check already passed rather than repeating it. v1 tradeoff: if the app server process restarts mid-ingest, the session is left stuck in'ingesting'forever (no reaper yet).removeImportRow— stillNOT_IMPLEMENTED. The analysis-first scan already drops bad columns while keeping every row (see below), which covers the issue’s core requirement; user-initiated single-row removal before ingest is a deferred follow-up.
CSV analysis & ingest (map-io/csv.ts)
Section titled “CSV analysis & ingest (map-io/csv.ts)”analyzeCsv classifies every non-label column before the user ever sees a
mapping step (the “analysis-first” requirement): a column whose non-blank
values are all numeric or all boolean-ish is suggested as a node field;
freeform text is a property; a wholly empty column is suggested
ignore. Ignoring a column never drops rows — only that column’s data is
disregarded, so a corrupt or unusable column never costs you the rest of a
row. Duplicate labels and likely-personal-data column names (email/phone/etc.)
are surfaced as non-blocking warnings.
A column mapped to field can link to an existing org/system field of the
same name and a compatible value type — suggested automatically
(COLLAB-5516) but always user-confirmable — or create a brand-new
app.metric row, same as GraphML’s attribute mapping. A cell that disagrees
with its column’s declared type (e.g. one non-numeric value in an otherwise-
numeric column) is dropped for that row only; the node itself is still
created (rowsSkipped only counts rows with no label at all).
CSV import always lands nodes unplaced (COLLAB-5512), via the same
network.node/network.map_node chunked-insert shape as ingestGraph
(client-minted node ids, no position), and never creates connections — CSV is
nodes-only.
Storage
Section titled “Storage”A single private Storage bucket, map-imports
(20260718072042_map_imports_storage_bucket.sql). No storage.objects RLS
policies: uploads authorize via the short-lived signed token (not a Supabase
Auth session), and every server-side read goes through the service-role
client, which bypasses RLS anyway.
Client-type matrix (minimise service-role)
Section titled “Client-type matrix (minimise service-role)”Most reads and writes use the RLS-scoped client (getSupabaseServerClient)
so Postgres policies do the enforcing. Two functions use the service-role
client because RLS structurally can’t help:
| Function | Why service-role |
|---|---|
createOrg | A new org has no members yet — the org insert and the creator’s org_admin membership insert must land as one trusted unit (mirrors initializeApp’s bootstrap). Still asserts assertSystemAdmin() first and stamps the creator explicitly. |
acceptInvitation, listMyInvitations | There is no SELECT policy on invitation for a non-member invitee — RLS blocks reading their own pending invitation and blocks the membership insert. Matches by the caller’s verified JWT email, checked against email_confirmed_at via the admin API, not just the JWT’s email claim. |
Every other write (invitations, members, maps, org rename/delete) uses the
RLS-scoped client with an explicit assertOrgRole/assertSystemAdmin call for
a clean error, backed by the matching write policy in the database.
acceptInvitation idempotency
Section titled “acceptInvitation idempotency”Accepting is safe to call twice: a pre-check for existing membership
short-circuits to success, and a unique-violation (23505) on the
organisation_member insert — a concurrent accept — is treated as success
rather than surfaced as an error. All invitation-status UPDATEs are guarded
with .eq('status', 'pending') so a second accept can’t un-expire or
re-accept a row that moved on.
The last-org_admin guard
Section titled “The last-org_admin guard”removeMember and changeMemberRole (when demoting out of org_admin) both
call a local assertNotLastOrgAdmin helper in workspace.ts that refuses the
change if it would leave an organisation with zero admins. This is
application logic, not an RLS policy — the database’s write policies happily
allow a lone admin to demote themselves.
Testing the security boundary
Section titled “Testing the security boundary”apps/app/src/server/lib/authz.integration.test.ts runs against a live
Supabase branch (pnpm test, or ./scripts/supa pnpm test on a feature
branch) — this repo has no mocking layer, per the branching design. It covers
user_is_org_member / user_has_org_role / user_can_access_map and the RLS
policies directly: non-member denied, member allowed, admin-only enforced,
and public-map visibility.
apps/app/src/server/functions/manual-editing.integration.test.ts covers
Locality 01 the same way: the two network SQL functions (node/map_node
creation, the delete cascade and orphan rule — including a node placed in a
second map surviving deletion from the first, and a node with multiple
connections on the same pair having all of them removed), self-connection
rejection, org_id stamping and the undefined-by-default (metric_id: null)
connection, multi-edge connections between the same pair, RLS blocking an
outsider from both RPCs and reads, the getGraph query shape, and
atomic_change insertion under an edit_session with an incrementing
stack_index.
apps/app/src/server/lib/map-io/map-io.integration.test.ts covers the map-io
module the same way (client-injected, bypassing the route/HTTP layer): full
Field[] resolution across node- and connection-referenced fields, fresh-uuid
minting and org_id stamping on import, a confirmed GraphmlAttributeMapping
driving every resolution kind (existing-field link, new-field creation,
property, ignore, node-attribute-as-label), a categorical connection
attribute exploded into independent per-value keys where one value becomes
its own directional boolean field while a sibling value stays a plain
property (COLLAB-5516), a multi-attribute GraphML edge preserving every attribute rather than dropping
all but the first, analyzeGraphmlAttributes’ suggestion round-tripping
unchanged through ingestGraph, ingestCsv linking a column to an existing
field (and rejecting a fieldId outside the org),
loadGraphForExport paging past PostgREST’s default 1000-row cap instead of
silently truncating, internal-JSON’s strict rejection of an unknown fieldId,
and a full seed → export → reimport → re-export round trip.
csv.test.ts/graphml.test.ts/internal-json.test.ts are pure unit tests (no
DB) for the classification/suggestion and serialization/parsing logic itself.
POST /api/maps/$mapId/import?format=graphml (the actual HTTP route, cookie
auth, not the injected client above) was manually verified end-to-end against
a live dev instance using a real ~1600-edge GraphML export from
packages/bluesky_to_graph (COLLAB-5511) — this is what surfaced both the
edge-attribute-drop and the 1000-row-truncation bugs described above, before
either had regression coverage.
It does not cover the last-org_admin guard, acceptInvitation’s
idempotency/expiry/email-match branches, or org_id/user_id stamping on
service-role writes — those live inside createServerFn handlers, which
can’t be invoked outside the TanStack Start server runtime (they need an
AsyncLocalStorage request context for the cookie-bound client). All were
manually verified end-to-end against a live instance while building the
shared-foundations milestone. Automating them is a follow-up — either a
Playwright-style e2e suite against a running dev server, or refactoring
handlers to accept an injected Supabase client.
Server routes
Section titled “Server routes”Most raw HTTP endpoints are auth callbacks: GET /auth/confirm (email
confirmation — verifyOtp) and GET /auth/callback (OAuth code exchange),
implemented as route loaders calling a server function that sets cookies and
redirects.
The map export/import endpoints below are the first real use of TanStack
Start’s Server Routes feature in this repo — a server: { handlers: { GET, POST } } property on createFileRoute (a route file with no component),
returning a raw Response for full control over Content-Type/
Content-Disposition. This is a different mechanism from createServerFn:
authMiddleware/orgMiddleware are Server-Function-type middleware
(createMiddleware({ type: 'function' })) and cannot attach to Server Routes
(request middleware, createMiddleware(), can’t depend on function
middleware). Each handler instead calls supabase.auth.getClaims() (401 JSON
on failure) and assertMapAccess(mapId) (403 JSON on failure) inline, matching
the existing “explicit handler assertion” convention above — just without a
middleware wrapper.
Map export/import (map-io)
Section titled “Map export/import (map-io)”Pure translation-layer tooling in apps/app/src/server/lib/map-io/ — GraphML,
a nodes-only CSV, and an internal JSON dialect, round-tripping NODE/
CONNECTION/FIELD/NODE_FIELD data with no schema changes. These Server
Routes are for programmatic/API use; the map editor’s Import/Export panel
drives CSV and GraphML through the async data-upload.ts pipeline instead
(see Data upload & async ingestion
above) — the two never overlap on the same request.
GET /api/maps/$mapId/export?format=graphml|json|csv—loadGraphForExportassembles the map’s nodes/edges plus every referencedFielddefinition, thenbuildGraphml/buildInternalJson/buildCsvserializes it. Undefined connections (metric_id === null) are excluded from GraphML and CSV (CSV is nodes-only regardless) but included in JSON (its whole purpose is lossless round-tripping). GraphML field keys are namespacedfield:{id}and property keysprop:{key}so a same-tool reimport can bind directly by id.POST /api/maps/$mapId/import?format=graphml|json— the raw request body is the file content (no multipart, no Storage signed-URL round trip; capped at 10 MB viaContent-Length, checked before reading the body).ingestGraphmints fresh node ids client-side (crypto.randomUUID()) and batch-insertsnetwork.noderows plus unplacednetwork.map_nodemembership rows (nopos_x/pos_y) — imported nodes land in the Nodes panel’s Unplaced list, not on the canvas (COLLAB-5512; see placed/unplaced nodes above). Import is a pure create into an existing map, never an upsert or a “create a new map” branch. Batches are chunked at 500 rows per insert statement (INSERT_CHUNK_SIZE) so a several-thousand-node import doesn’t send one unbounded statement.ingestGraphno longer resolves GraphML attributes implicitly (COLLAB-5516) — it trusts an explicitGraphmlAttributeMappingpassed by the caller, one resolution per key: existing field, new field, property, ignore, or node-attribute-as-label. A categorical connection attribute like “type” (“follows”/“jaccard”) is exploded into one key per distinct value at scan time (attrValueKeyOf, notattrKeyOf) rather than one key for the whole attribute, so “follows” and “jaccard” resolve completely independently — one can become its own directional boolean field while the other stays a property (seeanalyzeGraphmlAttributes/buildFieldResolver). This route has no human in the loop to confirm a mapping the way the map editor’s import wizard does, so it computes the same suggestion (analyzeGraphmlAttributes) but downgrades every non-”existing” suggestion to “property” before passing it toingestGraph— preserving this endpoint’s original guarantee that it never silently creates a field (or reinterprets the node label) on the caller’s behalf. Attribute keys that don’t match an existing field by(attr.name, subject_type)are stored as a freeform property rather than being lost; internal-JSON imports instead reject outright if a referencedfieldIddoesn’t exist in the target org (a mismatch there means importing into the wrong org). Uses the RLS-scoped client, not service-role — same reasoning as the client-type matrix above, sinceassertMapAccesshas already confirmed org membership by the time any write happens.- A connection only has one
metric_id/valueslot, but a third-party GraphML edge can carry several non-reserved attributes (e.g. a similarity graph with both a type label and multiple numeric scores).ingestGraphtakes the first attribute resolved to a field for that slot; every other attribute on that edge — resolved to a field or not — is preserved as a freeform property, the same fallback nodes already had. Attributes stored as a property (whether by explicit choice or this fallback) are also listed inunmatchedFieldKeys. - Every
network.connection/node/map_node/node_metricquery inloadGraphForExportandgetGraphpages explicitly withfetchAllRows(server/lib/pagination.ts) past PostgREST’smax_rows(supabase/config.toml, currently 1000) — an unranged query silently truncates at that limit with no error, so any map whose connection count crosses it would otherwise export/visualise incomplete data with no indication anything was dropped.
- A connection only has one
The shared graph data layer
Section titled “The shared graph data layer”Both renderers consume GraphData ({ nodes, edges }) from
apps/app/src/types/graph.ts — the stable coupling point between editing and
visualisation modes.