Database architecture
PostgreSQL 16 with the pgvector extension. Drizzle ORM in @fitkit/db (libs/db). Single physical database, single schema. Multi-tenancy via organization_id on every domain table.
Quick reference
| Thing | Where |
|---|---|
| Schema files | libs/db/src/lib/schema/ (one file per domain) |
| Schema index re-exports | libs/db/src/lib/schema/index.ts |
| Drizzle config | libs/db/drizzle.config.ts |
| Migrations | libs/db/drizzle/ (generated SQL + meta/_journal.json) |
| Drizzle client factory | createDbClient (exported from @fitkit/db) |
| Type for the client | DbClient (exported from @fitkit/db) |
| NestJS DI token | DATABASE_CLIENT |
| Studio (GUI) | pnpm db:studio |
Schema organization
Each domain has its own file in libs/db/src/lib/schema/. All are re-exported from index.ts. Cross-file foreign keys are explicit imports.
| File | Domain |
|---|---|
_pgvector.ts | vector(N) Drizzle custom type for pgvector columns (1024-dim default for Voyage v3-multilingual). |
enums.ts | All pgEnum declarations — membershipRole, bookingStatus, platformTier, formKind, assignmentKind, aiToolStatus, etc. Read this file when you need the canonical values. |
users.ts | users (clerk-linked, soft-deletable). |
organizations.ts | organizations (soft-deletable), organizationType, platformTier. |
memberships.ts | memberships (the user↔org join), membershipRole, membershipStatus, membershipPaymentStatus. |
invitations.ts | invitations + invitationStatus. |
scheduling.ts | locations, programs, programEnrollments, classTypes, classSessions, bookings. The scheduling core. |
workouts.ts | exercises, workouts, workoutMovements, workoutAssignments, workoutResults, personalRecords. The training core. |
courses.ts | courseConfigs (per-program when delivery_mode = 'course'), courseEntitlements, courseWorkouts. |
community.ts | feedItems, reactions, comments. |
payments.ts | plans, subscriptions, paymentProviderConfigs, paymentTransactions, memberPaymentMethods, cancellation requests. |
platform-billing.ts | FitKit’s own B2B subscription to orgs (separate from member billing). |
minisites.ts | minisiteContent (draft + published JSONB). |
tasks.ts | tasks (operator to-dos). |
member-profiles.ts | memberProfiles (per-org extended profile + embedding). |
imports.ts | importJobs, importProviderConfigs. |
exports.ts | exportJobs. |
leads.ts | leads (with leadSource, leadStatus). |
messaging.ts | messages (per-org direct + workout threads). |
announcements.ts | announcements (org broadcasts). |
admin.ts | auditLogs, manualCosts (admin cost dashboard). |
body-metrics.ts | bodyMetrics (weight, body fat, circumferences). |
progress-photos.ts | progressPhotos. |
device-tokens.ts | Expo push device registrations. |
notification-prefs.ts | Per-user JSONB opt-out matrix. |
goals.ts | Member goals (body-metric or exercise-PR). |
legal.ts | legalDocuments, consent records. |
comments.ts | exerciseComments (workout-movement threads). |
attachments.ts | Polymorphic attachments (`exercise_comment |
metric-sets.ts | metricDefinitions, set + template links. |
ai-cache.ts | Cached embedding + enrichment results, keyed by (model, input_hash). |
agent.ts | Spotter — aiConversations, messages, tool calls, etc. |
program-templates.ts | FIT-74 reusable program structures. |
forms.ts | FIT-176 forms engine — formTemplates, formInstances, signing tokens. |
Multi-tenant isolation
Every domain table carries organization_id uuid NOT NULL REFERENCES organizations(id) with an index. Some tables intentionally don’t — e.g. users (global identity), aiCache (cross-org dedupe), legalDocuments (platform-wide versioned docs).
Services always include organization_id in WHERE clauses. There’s no row-level security at the Postgres layer — isolation is enforced in application code. Tests assert this.
Connection pool
apps/api/src/database/database.module.ts builds a pg Pool with knobs read from env:
| Env | Default | Notes |
|---|---|---|
DATABASE_URL | required | Primary connection string. Routed through PgBouncer in some prod setups. |
DB_POOL_MAX | 20 | Per-instance pool size. Drop to fit Neon free-tier (≤20 total connections). |
DB_POOL_IDLE_TIMEOUT | 30_000 ms | Close idle connections after this. |
DB_POOL_CONNECT_TIMEOUT | 5_000 ms | Increase for serverless-DB cold starts (Neon). |
DB_POOL_KEEPALIVE | true | TCP keepalive. |
DB_POOL_KEEPALIVE_INITIAL_DELAY | 10_000 ms | |
DB_LOGGING | false | Set true in dev to dump all SQL. Never in prod. |
DIRECT_DATABASE_URL
Migrations bypass PgBouncer because long-running ALTER TABLE statements + transaction-pooling don’t mix. libs/db/drizzle.config.ts reads DIRECT_DATABASE_URL when present and falls back to DATABASE_URL. Set DIRECT_DATABASE_URL to the unpooled connection on Neon/Supabase before running pnpm db:migrate.
pgvector
Custom Drizzle column type at libs/db/src/lib/schema/_pgvector.ts. Defaults to 1024 dimensions (Voyage v3-multilingual).
Tables using vector columns:
exercises— exercise embeddings for semantic search.workouts— workout-level embeddings for similar-workout discovery.programs— program embeddings.member_profiles— member-profile embeddings used by Spotter RAG.agent.aiConversationsand related agent tables — embeddings for retrieval.
The pgvector extension is preinstalled in the pgvector/pgvector:pg16 Docker image used in docker-compose.yml and docker-compose.test.yml. Migration 0037 runs CREATE EXTENSION vector. Stock postgres:16 will fail this migration.
Encryption at rest (application-level)
Two AES-256-GCM keys, both 32-byte hex:
| Key env | Encrypts | Notes |
|---|---|---|
PAYMENT_CREDENTIALS_ENCRYPTION_KEY | paymentProviderConfigs.encryptedCredentials, memberPaymentMethods.encryptedToken, related fields. | Rotating it after writes makes existing values unreadable. Plan a re-key migration if you must rotate. |
NATIONAL_ID_ENCRYPTION_KEY | Israeli Teudat Zehut on users (where collected). | Same rotation caveat. Service: apps/api/src/users/national-id-encryption.service.ts. |
Generate keys with node -e "console.log(require('crypto').randomBytes(32).toString('hex'))". Never commit. Dev defaults to a 64-zero string in Makefile and .env.example; prod must override.
Soft delete
Convention: deleted_at timestamptz on tables that need it, with reads filtered via WHERE deleted_at IS NULL. Used today on:
users(user removed themselves)organizations(org closed)progressPhotosannouncementspaymentProviderConfigsandpaymentTransactions(history-preserving)program_templates
Hard delete is reserved for transient records (e.g. unverified invitations).
Migration policy
Source of truth for this section: CLAUDE.md “Database Policy”. Keep both in sync.
Workflow
- Edit the schema file under
libs/db/src/lib/schema/. pnpm db:generate— Drizzle-kit diffs the schema, writes a numbered migration tolibs/db/drizzle/and updateslibs/db/drizzle/meta/_journal.json.- Review the generated SQL. Catch destructive renames, missing indexes, unintended type changes.
pnpm db:migrate— applies pending migrations. Requires explicit user approval every time. Dev and prod both hold real data.
Never use pnpm db:push (auto-apply without migration files). It bypasses the journal and creates drift.
The monotonic when rule
Drizzle-kit’s migrator filters journal entries by when > max(__drizzle_migrations.created_at). Any entry whose when is smaller than the last applied migration’s is silently skipped — the CLI reports “migrations applied successfully” with nothing actually run.
This caused a production outage on 2026-04-18 when a branch merged with a journal entry timestamped before main’s own newer migration.
Rule: when merging a branch that added migrations before main got its own, regenerate the stale ones so their when bumps. Verify by reading _journal.json after merge — the when field on each entry must be strictly monotonic.
Direct-write conventions
- Use
db.transaction(async (tx) => { ... })whenever a logical change spans multiple tables. - Prefer Drizzle’s relational query builder (
db.query.users.findFirst({ with: { ... } })) over hand-built joins. - For bulk operations use
db.execute(sql\…`)` with parameterized templates — never string-interpolate user input. - Log slow queries — the Sentry
beforeSendTransactioninapps/api/src/instrument.tstags transactions withslow_db: truewhen anydbspan exceeds 1s.
Drizzle Studio
pnpm db:studioWeb GUI for browsing + editing data. Defaults to https://local.drizzle.studio. Reads DATABASE_URL from the api app’s .env. Useful for ad-hoc data inspection — avoid using it to modify prod data (use a SQL session and an audit-logged path instead).
Reference: env vars touching the DB
DATABASE_URL required, prod points at managed Postgres (Neon/Supabase/Railway DB)
DIRECT_DATABASE_URL optional, used by migrations to bypass PgBouncer
DB_POOL_MAX default 20
DB_POOL_IDLE_TIMEOUT default 30000 (ms)
DB_POOL_CONNECT_TIMEOUT default 5000 (ms)
DB_POOL_KEEPALIVE default true
DB_POOL_KEEPALIVE_INITIAL_DELAY default 10000 (ms)
DB_LOGGING default false; true dumps SQL
PAYMENT_CREDENTIALS_ENCRYPTION_KEY 32-byte hex, AES-256-GCM
NATIONAL_ID_ENCRYPTION_KEY 32-byte hex, AES-256-GCM