Skip to content

Architecture

The source decisions are in Linear: V1 Architecture Decisions and the Implementation Blueprint. This page describes what’s built.

flowchart LR
    browser["Browser
React app"] scripts["Scripts and agents
(API key)"] subgraph render["Render (Frankfurt)"] app["TanStack Start server
routes · server functions
/api/v1 · server routes"] docs["Docs site
(static)"] cron["Daily purge cron"] end subgraph supabase["Supabase (EU)"] auth["Auth"] appschema[("app schema
orgs, members, maps,
views, sessions")] network[("network schema
nodes, connections,
each map's catalogue")] storage["Storage
map-imports"] end resend["Resend
(email)"] browser -- "server functions (cookie session)" --> app browser -- "direct upload (signed URL)" --> storage scripts -- "/api/v1 (Bearer key)" --> app cron -- "POST /api/maintenance/purge" --> app app --> auth app --> appschema app --> network app --> storage app --> resend
Concern Choice
App framework TanStack Start (Router and Query) with React 19
Client graph state TanStack DB collections for nodes and connections
Database and auth Supabase (Postgres), EU region, two schemas: app and network
Edit-mode canvas @xyflow/react (React Flow)
Visualise-mode canvas react-force-graph-2d
Email Supabase Auth email, plus Resend for invitations
Hosting Render, Frankfurt, configured in render.yaml
Monorepo pnpm workspaces: apps/* and packages/*
Lint and format Biome, one config at the root
Docs Astro Starlight (apps/docs)

These apply to every change.

  • No cross-schema SQL joins between app and network. Need data from both? Run two queries and combine the results in application code. network refers to app only through plain uuid map_id columns with no foreign keys, so it can move to its own database later.
  • Guard every tenant-data server function twice: a route beforeLoad guard for the user experience, and authMiddleware/orgMiddleware plus an authz helper for security. Validate sessions with supabase.auth.getClaims(), never getSession(), on the server. See Auth and permissions.
  • Service-role writes must set map_id on every network row (and user_id where relevant), because the service role bypasses RLS. Nothing in network has an org_id. An organisation owns maps, members, invitations and API keys, and everything inside a map belongs to the map.
  • All data stays in the EU (Supabase and Render). Some users work in politically sensitive contexts. The region is fixed when a project is provisioned.
  • Pin @tanstack/* and the visualisation libraries exactly, with no ^ or ~. CI installs with --frozen-lockfile.
  • pnpm only, Biome only.
  • Use the new Supabase key format (sb_publishable_… and sb_secret_…).
  • Server functions (createServerFn) are the app’s own API: type-safe calls from the browser, grouped by area in apps/app/src/server/functions/. See Server functions and routes.
  • Server routes return raw HTTP responses. They serve map export and import, the maintenance purge, the health check, and the public REST API under /api/v1 (see Public API).
  • Three modes, one workspace. A map page (/maps/$mapId) has Edit, Visualise and Table modes, chosen by the mode search param. All three stay mounted, and switching never reloads data. They share one pair of TanStack DB collections, filled from one getGraph call. Edits apply to the collections at once, and save in the background. See Editing internals.
  • Two renderers, one data shape. Edit mode draws with React Flow, and Visualise with react-force-graph-2d. Both read the GraphData type in src/types/graph.ts.
  • Centrality and layout run in the browser. At the expected scale (about 180k nodes and 400k connections across all maps), Postgres with good indexes is enough. There’s no graph database.
  • Deploys are gated on releases. The production app deploys only when a GitHub release is published, after production migrations succeed. See Deployment.

Where each kind of workspace UI goes is covered in Workspace UI.