<!-- source: openspec/current/db/narrative.md -->

---
title: DB — Narrative
status: stable
owner: pavel
audience: [pavel, ai, engineer]
tldr: Architecture rationale for the db package — why it exists, browser/admin client separation philosophy, and open questions.
---
# DB — narrative

> Part of openspec/current/db/ — split from db.md on 2026-05-12

## Why this package exists

`@rapoport-studio/db` is the canonical Supabase client surface for the monorepo. It exposes exactly two runtime client shapes and one type shape — no query helpers, no business logic, no transaction wrappers. Consumer packages write queries directly against the raw Supabase clients; this keeps `db` deletable and minimal.

## Architecture

```
process.env  ──►  config.supabase  ──►  db.client (browser, anon key)  ──┐
                       │                  module-load singleton           │
                       │                                                  ├──►  consumers
                       └─────────────►  db.admin (server, service role)  ─┘
                                          factory, fresh client per call
```

Two client surfaces over the same Supabase project:

- **`db`** — browser-safe singleton (`src/client.ts:5`). Created at module load with the anon key. Safe to import from client components; relies on Supabase RLS for authorization.
- **`createAdminClient()`** — server-only factory (`src/admin.ts:5`). Each call creates a fresh client with the service role key, which bypasses RLS. Throws if `SUPABASE_SERVICE_ROLE_KEY` is unset.

## Browser/admin separation rationale

The split is deliberate. Browsers are single-tab single-runtime; one Supabase client per page is the documented pattern. Server runtimes (Next.js routes, edge functions, serverless workers) handle many requests; long-lived service-role clients in shared module scope risk leaking auth across requests, so admin gets a fresh client per call. Importing `createAdminClient` is also an easier-to-spot signal in code review than importing a pre-built admin singleton.

## RLS philosophy

Browser clients rely entirely on Supabase Row Level Security for authorization. The admin client bypasses RLS by design — only server-side code that has been explicitly audited for correct authz logic should use it. `createAdminClient` is the sentinel: its name in a client-side import is an immediate code-review flag.

Per-table RLS policies use three patterns:
1. **JWT claim check** — `app_metadata.platform_owner = 'true'` for platform-owner-only tables (forge tables, inbox SELECT).
2. **Owner match** — `auth.uid() = <owner_column>` for direct ownership (canvas_sessions, projects, user_settings).
3. **Invitee chain** — EXISTS join through `canvas_invitees` for per-canvas shared access, with optional `AND ci.role = 'collaborator'` guards for client exclusion.

## Open questions

- **Service-role exposure.** ~~`admin.ts` is "server-only" by convention but ESLint cannot enforce server-only at the package level. Options: (a) a `server-only` import marker (per Next.js convention), (b) a runtime guard that throws in browser builds, (c) a new ESLint rule that flags `createAdminClient` imports outside server contexts.~~ **Resolved** by change `wire-spec-enforcement-gates` (RAP-1167, applied 2026-05-22): **option (a) — the `server-only` import marker — is chosen.** App-edge modules that instantiate and re-export the service-role client (e.g. `apps/studio/src/lib/supabase-admin.ts`) carry `import "server-only";` as their first statement, so a client-bundle import is a build-time hard failure. Option (b) was rejected (a runtime guard fails only when the offending path executes — a cold code path leaks silently); option (c) was rejected (a new ESLint rule must hand-maintain a server-context allowlist and cannot see through re-export chains). The marker is applied at the app edge, not inside `@rapoport-studio/db`, which ships an inert environment-agnostic factory — see `conventions.md § Browser vs. admin separation`.
- **Query helpers vs. raw clients.** The package exposes raw Supabase clients today. Should it grow a thin query layer (e.g., `getProfile(id)`, `listClients(filter)`), or stay schema-as-types only and let `auth` / apps own queries? The latter keeps `db` deletable and minimal; the former centralizes RLS-compliant query patterns. No decision.
- **Multiple Supabase projects (staging vs production).** `config.supabase` resolves to a single project. If the studio adopts a staging/production split, does that surface as runtime config (multiple URL/key tuples in env), at the `db` package (factory taking a project ID), or at deployment (different `wrangler` envs binding to different projects)? Not addressed today.
- **`Json` re-export.** `index.ts:3` re-exports `Database` but not `Json`. The package exposes only the `.` subpath, so consumers cannot reach `Json` today (e.g., for typing a JSONB column value). When the first consumer needs it, add it to the barrel or declare a `./types` subpath.
- **Runtime exports without consumers.** `db` and `createAdminClient` are exported but no workspace code calls them yet. `auth` only consumes the `Database` type. This is a "scaffolding present, awaiting first consumer" state — comparable to the unused vendor accessors in `config.md`. Not a problem; flagged so future readers know the runtime path is not yet exercised end-to-end.
