Skip to Content

Data Access: Server Functions, Adapters, Projections

The browser can never call apps/api directly, because the WorkOS session lives in an httpOnly cookie the client can’t read, and the JWT must never reach client code. So every read and write goes through a two-file pattern in apps/portal/src/server/ β€” a createServerFn transport that route code imports, and a .server.ts implementation that only ever executes on the Worker. The display layer, meanwhile, is locked to rendering serialized projections by a CI gate. This page explains all three.

The adapter pair

Each domain gets a pair β€” crm-client(.server).ts, kpi-client(.server).ts, fund-accounting-client(.server).ts, portco-, collection-, import-, extraction-review-, portal-search-, and so on.

The transport half (kpi-client.ts) is a thin createServerFn whose handler dynamically imports the server half, and injects itself into a typed contract client so wire shapes come from the shared contract package (no drift):

// apps/portal/src/server/kpi-client.ts const requestKpiApi = createServerFn({ method: "POST" }) .validator((input: KpiRequestInput) => input) .handler(async ({ data }) => { const { requestKpiApiServer } = await import("./kpi-client.server") return (await requestKpiApiServer(data)) as KpiJson }) export const kpiClient: KpiClient = makeKpiClient({ async request(input) { return requestKpiApi({ data: input }) }, })

The server half (kpi-client.server.ts) does the sensitive work:

  1. resolveForwardableSessionToken(getRequest(), …) from @opsdna/portal-session unseals the cookie and refreshes the access token if needed β€” any refreshed set-cookie headers are appended to the response, so the session rolls forward transparently.
  2. A RequiresAuth failure becomes an auth-session-recovery redirect (ADR-0024 layer 2 β€” mid-session expiry lands back at login with return_to).
  3. The fetch to ${API_BASE_URL}${path} carries authorization: Bearer <jwt> plus activeTenantHeader() (see Auth & Tenancy) and, in previews, previewApiAuthHeader().
  4. Workspace-loss statuses trigger maybeThrowNoWorkspaceRecoveryRedirect instead of a raw error.

The dynamic import inside the handler matters: it keeps getRequest, cookie crypto, and secrets out of the browser bundle β€” the same reason portal-auth-gate-server.ts is only ever reached via its server function.

What’s on the other end

apps/api serves read-models and projections, not raw tables. For LP-facing surfaces, ADR-0015 sets the posture: tenant RLS stays hard, and the LP portal reads only recipient-authorized shared rows or packets (data_share_grant, shared_resource_snapshot) β€” never provider tables via cross-tenant joins. Projection types the display layer consumes come from @opsdna/portal-projections / @opsdna/portal-read-models (see /api and /entity-model).

The display-only rule

Route components (apps/portal/src/routes/**) and shared view modules (apps/portal/src/portal/**) render from serialized projections and static fixtures β€” nothing else. scripts/check-portal-display-only-imports.ts (run as portal:display-only-check inside bun run verify and CI) fails the build if display code imports, at runtime:

  • @opsdna/persistence or @opsdna/ledger (type-only imports are allowed β€” types erase at compile time),
  • @opsdna/canonical-json (serialization belongs to projection loaders),
  • any migrations path, or server-only modules.

Violating display-only is not a style nit. A runtime import of persistence/ledger from a route module smuggles server-side substrates (and potentially migration SQL) into the browser bundle. The gate exists because this failure mode is silent until bundle-audit or a prod incident catches it. If a route needs data, the answer is always: add it to a projection and fetch it through a server-fn adapter β€” never reach past the seam.

Where things live

      • companies.index.tsx (display-only; loader β†’ server fn)
      • kpi-client.ts (createServerFn transport)
      • kpi-client.server.ts (JWT + headers + fetch)
      • active-tenant-header.ts
      • auth-session-recovery.ts
  • Auth & Tenancy β€” the gates the JWT and tenant header answer to.
  • Adding a Page β€” the how-to that stitches route + server fn + adapter together.
  • /api β€” the endpoints these adapters call.
Last updated on