Skip to content

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.

Auth is enforced at two independent layers — both required:

  1. Route beforeLoad guard (UX only). Redirects unauthenticated browsers to /login. The _authed layout route does this. It does not protect server functions.
  2. Server-function middleware (security boundary). Every tenant-data function declares authMiddleware (validates the JWT with getClaims() — never getSession() 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) — wraps user_is_org_member.
  • assertOrgRole(orgId, roles[]) — wraps user_has_org_role OR user_is_system_admin (system admins bypass org-role checks everywhere).
  • assertMapAccess(mapId) — wraps user_can_access_map.
  • assertSystemAdmin() — wraps user_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.

  • Profiles & settings (profile.ts) — getProfile, updateProfile, updateSettings. RLS-scoped, upsert-by-self (profile_select_self / profile_modify_self policies key on user_id = auth.uid()).
  • Manual editing (manual-editing.ts) — Locality 01, all implemented: getMap, getMaps, createMap, renameMap, deleteMap, the node/connection CRUD set createNode, updateNode, deleteNode, createConnection, updateConnection, deleteConnection, and the node field assignment set setNodeField, deleteNodeField. See Node & connection editing below. deleteMap calls network.delete_map (network-only RPC) before deleting the app.map row, then removes the map’s map-imports Storage objects — network.map_id has no FK into app.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/updateField take an optional directionality ('undirected' | 'directed' | 'mutual', defaults to 'undirected') and inverse_name; assertValidDirectionality rejects either being set on a non-'connection' field, and inverse_name being set without directionality: 'directed' (mirroring the DB check constraints — see Database schema). createField, updateField, deleteField are member-level (assertMapAccess, mirroring Locality 01’s editing permissions) and only ever touch the caller’s own org fields — system- defined fields (org_id null) and other orgs’ fields are read-only from here, enforced by assertFieldEditable (org_id must match the caller’s resolved org). getFieldUsage({ map_id, metric_id }) counts node_metric/connection rows 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 on metric_id, per CLAUDE.md) — the handler explicitly deletes referencing node_metric rows and clears (metric_id/valuenull) referencing connections, i.e. it un-defines rather than deletes what the field described.
  • Data upload (data-upload.ts) — Locality 02, implemented (COLLAB-5508) except removeImportRow (still NOT_IMPLEMENTED — deferred, see below): getSignedUploadUrl, createImportSession, getImportPreview, updateImportMapping, ingestImportSession, getImportStatus. See Data upload & async ingestion below.
  • Visualisation (visualisation.ts) — getGraph implemented (Locality 03 pulled forward for Locality 01); createSavedView/getSavedViews implemented (Locality 03 — view metadata only: map_id, created_by, name, viewport_state, visualisation_config as jsonb on app.saved_view, never graph data). exportVisualisation remains NOT_IMPLEMENTED (out of scope for COLLAB-5466 — no file export).
  • Storage & revision (revision.ts) — startSession, captureChange implemented (the Locality 01 change-capture slice); undoChange, redoChange, collapseSession, discardSession, getRevisionHistory, loadRevision, compareRevision remain NOT_IMPLEMENTED (Locality 04).
  • Workspace & permissions (workspace.ts) — implemented:
    • createOrg (service-role — see below), deleteOrg (soft-delete — deleted_at only; 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/isEmailAlreadyMember batch-resolve member emails via app.get_user_emails (one SQL RPC) rather than one auth.admin.getUserById call 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.
    • shareMap remains NOT_IMPLEMENTED — cross-org sharing model is an explicit follow-up (see Constraints & edge cases).
  • Maintenance (server/lib/maintenance.ts) — purgeOrphanedData, run by POST /api/maintenance/purge (a Server Route, not a createServerFn — guarded by a shared-secret header, not a user session, since it’s invoked by a daily cron job with no signed-in user). Sweeps network rows for maps that no longer exist in app.map, hard-purges organisations soft-deleted past the retention window (graph data + Storage + the org row itself), and fails import_session rows stuck in ingesting past a timeout. See Database schema.

These live in apps/app/src/server/functions/, grouped by locality.

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 the network.create_node_in_map SQL function (atomic node + map_node insert). Returns { node_id, map_node_id }. node_id is optional and client-supplied (the RPC does coalesce(p_node_id, gen_random_uuid())) — the node/edge collections (see below) mint a crypto.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 only pos_x/pos_y captures a node.moved change; supplying label/properties captures a node.property_changed change; supplying both captures both.
  • deleteNode({ node_id, map_id, session_id? }) — calls network.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? }) — defaults metric_id to null (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 no direction param — direction is derived from whichever field ends up assigned (see getGraph below), not chosen per connection. id is optional and client-supplied, same rationale as createNode’s node_id above.
  • updateConnection({ connection_id, map_id, metric_id?, value?, properties?, swap_endpoints?, session_id? })value’s JS type is validated against the referenced field’s value_type whenever a new value is supplied (see assertValidFieldValue, shared with setNodeField below). swap_endpoints: true swaps source_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 on node_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). value is required (the column is NOT NULL) and validated against the field’s value_type the same way as connections, with expectedSubjectType: 'node'.
  • deleteNodeField({ map_id, node_id, metric_id, session_id? })

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 by map_node.created_at, plus total. The Nodes panel’s Unplaced list uses this; limit is the client’s NODE_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 existing map_node row (they already exist as unplaced membership rows), moving it from unplaced to placed. Rejects with MAX_NODES_EXCEEDED if the map’s placed-node count would exceed MAX_NODES_IN_EDIT_MODE (200) — client-side, the Nodes panel’s “Place all” button is disabled first, but the server re-checks. Emits node.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 from getGraph’s output (and the canvas) and reappears in the Unplaced list. Emits node.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 onUpdateupdateNode 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.

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/delete and edgesCollection.insert/update/delete apply synchronously and optimistically; the corresponding server function (createNode, updateNode, deleteNode, createConnection, updateConnection, deleteConnection) runs in the background via the collection’s onInsert/onUpdate/onDelete handler. A thrown error rolls the optimistic change back automatically — there’s no hand-rolled onMutate/onError rollback pair to get right per mutation.
  • The server is a follower, not a gate. Nothing in the editor invalidates or refetches getGraph after a structural edit succeeds — the collection already reflects the change. This replaced an earlier design where every edit’s success invalidated the graph query, 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/onDelete on purpose. Debounced label/property/field-value edits and bulk placement (placeNodes/unplaceNodes) call collection.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 generic onUpdate handles, 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 via edgesCollection.utils.writeDelete (a direct local write, no network call) rather than edgesCollection.delete(...), which would otherwise re-trigger deleteConnection for rows the server already removed.
  • Both collections fetch through the same getGraph call. Their queryFns both resolve via queryClient.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, preserving getGraph’s single-fetch, no-cross-schema-joins shape.

Thrown as Error with a CODE: message prefix (see server/lib/errors.ts, matching the NOT_IMPLEMENTED: convention):

CodeThrown when
LABEL_TOO_LONGNode label is empty or exceeds 255 characters.
NODE_SELF_CONNECTIONsource_node_id === target_node_id (also enforced by the connection_no_self DB constraint as defense in depth).
CONNECTION_INVALID_NODESEither node lacks a map_node row in this map.
FIELD_NOT_FOUNDmetric_id doesn’t reference an existing app.metric row.
FIELD_WRONG_SUBJECTThe referenced field’s subject_type doesn’t match the caller ('connection' for connection mutations, 'node' for setNodeField).
FIELD_VALUE_TYPE_MISMATCHvalue’s JS type doesn’t match the field’s value_type.
FIELD_NAME_REQUIREDcreateField/updateField’s name is empty/whitespace.
FIELD_DIRECTIONALITY_NOT_APPLICABLEdirectionality/inverse_name set on a field whose subject_type isn’t 'connection'.
FIELD_INVERSE_NAME_NOT_APPLICABLEinverse_name set without directionality: 'directed'.
FIELD_NOT_EDITABLEupdateField/deleteField targeted a system-defined field or one belonging to another org.
DELETE_FAILEDnetwork.delete_node_from_map RPC errored.
MAX_NODES_EXCEEDEDplaceNodes would push the map’s placed-node count past MAX_NODES_IN_EDIT_MODE (200).

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.

Assembles GraphData from separate network queries (no cross-schema join, per the hard rule): map_node (for positions) → node (label/properties)

  • connection + node_metric for the collected node ids, plus a further app.metric query (also cross-schema, hence separate) to resolve the value_type and directionality of every distinct metric_id referenced by a connection. Each GraphEdge.direction is derived from its field’s directionality here ('undirected' when metric_id is null) — there is no per-connection direction column to read. Undefined connections (metric_id === null) are included in the raw payload — the edit layer needs them; apps/app/src/graph/modes.ts’s toVizModeGraph filters 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 (isConnectionDefinedapps/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.
  • Countimportance = 1 for every type; combine = sum of contributions, normalised against the graph-wide max resulting sum (computeGraphWeights, not computePairWeight in 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 contributes 0.

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 onlyvisualisation_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_id is deliberately not accepted from the client — always resolved server-side from map_id (resolveMapOrgId). The browser then uploads directly to Storage’s private map-imports bucket; 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 an app.import_session row (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) or analyzeGraphmlAttributes (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’s scan jsonb column, column_mapping is pre-filled with that suggestion, and status moves to 'previewing' (or the session is left 'invalid' with an error message, and the call throws, on a parse failure). Returns an ImportPreview combining both formats’ fields.
  • getImportPreview({ session_id }) / getImportStatus({ session_id }) — RLS-scoped reads of the session row. import_session_rw’s created_by = auth.uid() OR user_can_access_map(map_id) policy is the real boundary here — no extra assertMapAccess call needed for a read. getImportStatus is 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 moves status to 'mapping'.
  • ingestImportSession({ session_id }) — asserts the session is 'previewing'/'mapping'; for CSV, requires a label column mapped; for GraphML, requires status === 'mapping' (i.e. the attribute mapping was actually confirmed, not just left at its scan-time suggestion). Flips status to '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-derives org_id itself, downloads the file from Storage again, and calls ingestCsv/ingestGraph (passing the confirmed column_mapping either 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 — still NOT_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.

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.

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:

FunctionWhy service-role
createOrgA 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, listMyInvitationsThere 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.

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.

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.

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.

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.

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|csvloadGraphForExport assembles the map’s nodes/edges plus every referenced Field definition, then buildGraphml/buildInternalJson/buildCsv serializes 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 namespaced field:{id} and property keys prop:{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 via Content-Length, checked before reading the body). ingestGraph mints fresh node ids client-side (crypto.randomUUID()) and batch-inserts network.node rows plus unplaced network.map_node membership rows (no pos_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. ingestGraph no longer resolves GraphML attributes implicitly (COLLAB-5516) — it trusts an explicit GraphmlAttributeMapping passed 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, not attrKeyOf) 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 (see analyzeGraphmlAttributes/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 to ingestGraph — 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 referenced fieldId doesn’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, since assertMapAccess has already confirmed org membership by the time any write happens.
    • A connection only has one metric_id/value slot, 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). ingestGraph takes 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 in unmatchedFieldKeys.
    • Every network.connection/node/map_node/node_metric query in loadGraphForExport and getGraph pages explicitly with fetchAllRows (server/lib/pagination.ts) past PostgREST’s max_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.

Both renderers consume GraphData ({ nodes, edges }) from apps/app/src/types/graph.ts — the stable coupling point between editing and visualisation modes.