Skip to Content
Living documentation — last reviewed 2026-05-28
ArchitectureDatabase architecture

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

ThingWhere
Schema fileslibs/db/src/lib/schema/ (one file per domain)
Schema index re-exportslibs/db/src/lib/schema/index.ts
Drizzle configlibs/db/drizzle.config.ts
Migrationslibs/db/drizzle/ (generated SQL + meta/_journal.json)
Drizzle client factorycreateDbClient (exported from @fitkit/db)
Type for the clientDbClient (exported from @fitkit/db)
NestJS DI tokenDATABASE_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.

FileDomain
_pgvector.tsvector(N) Drizzle custom type for pgvector columns (1024-dim default for Voyage v3-multilingual).
enums.tsAll pgEnum declarations — membershipRole, bookingStatus, platformTier, formKind, assignmentKind, aiToolStatus, etc. Read this file when you need the canonical values.
users.tsusers (clerk-linked, soft-deletable).
organizations.tsorganizations (soft-deletable), organizationType, platformTier.
memberships.tsmemberships (the user↔org join), membershipRole, membershipStatus, membershipPaymentStatus.
invitations.tsinvitations + invitationStatus.
scheduling.tslocations, programs, programEnrollments, classTypes, classSessions, bookings. The scheduling core.
workouts.tsexercises, workouts, workoutMovements, workoutAssignments, workoutResults, personalRecords. The training core.
courses.tscourseConfigs (per-program when delivery_mode = 'course'), courseEntitlements, courseWorkouts.
community.tsfeedItems, reactions, comments.
payments.tsplans, subscriptions, paymentProviderConfigs, paymentTransactions, memberPaymentMethods, cancellation requests.
platform-billing.tsFitKit’s own B2B subscription to orgs (separate from member billing).
minisites.tsminisiteContent (draft + published JSONB).
tasks.tstasks (operator to-dos).
member-profiles.tsmemberProfiles (per-org extended profile + embedding).
imports.tsimportJobs, importProviderConfigs.
exports.tsexportJobs.
leads.tsleads (with leadSource, leadStatus).
messaging.tsmessages (per-org direct + workout threads).
announcements.tsannouncements (org broadcasts).
admin.tsauditLogs, manualCosts (admin cost dashboard).
body-metrics.tsbodyMetrics (weight, body fat, circumferences).
progress-photos.tsprogressPhotos.
device-tokens.tsExpo push device registrations.
notification-prefs.tsPer-user JSONB opt-out matrix.
goals.tsMember goals (body-metric or exercise-PR).
legal.tslegalDocuments, consent records.
comments.tsexerciseComments (workout-movement threads).
attachments.tsPolymorphic attachments (`exercise_comment
metric-sets.tsmetricDefinitions, set + template links.
ai-cache.tsCached embedding + enrichment results, keyed by (model, input_hash).
agent.tsSpotter — aiConversations, messages, tool calls, etc.
program-templates.tsFIT-74 reusable program structures.
forms.tsFIT-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:

EnvDefaultNotes
DATABASE_URLrequiredPrimary connection string. Routed through PgBouncer in some prod setups.
DB_POOL_MAX20Per-instance pool size. Drop to fit Neon free-tier (≤20 total connections).
DB_POOL_IDLE_TIMEOUT30_000 msClose idle connections after this.
DB_POOL_CONNECT_TIMEOUT5_000 msIncrease for serverless-DB cold starts (Neon).
DB_POOL_KEEPALIVEtrueTCP keepalive.
DB_POOL_KEEPALIVE_INITIAL_DELAY10_000 ms
DB_LOGGINGfalseSet 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.aiConversations and 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 envEncryptsNotes
PAYMENT_CREDENTIALS_ENCRYPTION_KEYpaymentProviderConfigs.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_KEYIsraeli 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)
  • progressPhotos
  • announcements
  • paymentProviderConfigs and paymentTransactions (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

  1. Edit the schema file under libs/db/src/lib/schema/.
  2. pnpm db:generate — Drizzle-kit diffs the schema, writes a numbered migration to libs/db/drizzle/ and updates libs/db/drizzle/meta/_journal.json.
  3. Review the generated SQL. Catch destructive renames, missing indexes, unintended type changes.
  4. 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 beforeSendTransaction in apps/api/src/instrument.ts tags transactions with slow_db: true when any db span exceeds 1s.

Drizzle Studio

pnpm db:studio

Web 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