Database architecture
PostgreSQL 16 with the pgvector extension. Drizzle ORM in @taikan/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 @taikan/db) |
| Type for the client | DbClient (exported from @taikan/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 | Taikan’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.
Read this before touching any of them. Production PGBouncer runs
PGBOUNCER_POOL_MODE=session, so every pooled client connection pins a Postgres
backend for its whole lifetime. An idle connection in the app’s pool is not free —
it is a server connection no other process can have. The binding limit is therefore
PGBOUNCER_DEFAULT_POOL_SIZE (60, shared by every process on this user+database),
not PGBOUNCER_MAX_CLIENT_CONN (120) and not Postgres max_connections (500, which
has plenty of room). The sizing rule is:
processes x DB_POOL_MAX x 2 <= PGBOUNCER_DEFAULT_POOL_SIZE
^-- a rolling deploy runs old and new containers togetherToday that is 3 x 8 x 2 = 48 against 60 — it fits, with 12 spare for the
preDeployCommand migration job and ad-hoc clients. It did not fit until
2026-09-03, when the pool size was raised from 20 after eager warming exhausted it and
failed a deploy (see below). Re-check this line whenever a replica is added or
DB_POOL_MAX is raised — 4 replicas at 8 would need 80.
| Env | Default | Notes |
|---|---|---|
DATABASE_URL | required | Routed through PgBouncer in prod (pgbouncer.railway.internal:6432). |
DB_POOL_MAX | 20 (production sets 8) | Per-process pool size. 3 processes (2 api replicas + scheduler) x 8 = 24 pinned backends at steady state, out of 60. MinisitesService.getPlatformData alone takes ~10 checkouts with 7 concurrent, so one minisite render can nearly fill a process’s pool. |
DB_POOL_IDLE_TIMEOUT | 600_000 ms | How long an idle connection keeps its pinned backend. 10 min, not 30s: at 30s every traffic lull and the 5-min billing reconciler paid a fresh connect (117ms p50 / 132ms p95), which was 40-46% of the total server time of GET /users/me, /organizations/:orgId/programs and /members (measured 2026-09-03). Safe only while concurrency stays low enough that pools sit well below max. |
DB_POOL_WARM | 0 (off) | Connections opened in parallel at boot, clamped to DB_POOL_MAX. Off by default because under session pooling it pins DB_POOL_MAX backends per process immediately: setting it to max made three processes demand 24 of 20 at boot and killed the 2026-09-03 08:45 deploy with query_wait_timeout (08P01) 124 seconds in (PGBouncer’s 120s wait queue, not a database outage). 19 of the 20 backends were then observed sitting idle. Satisfy the sizing rule above before setting it. What it buys when affordable: 8 connections in 161-181ms on production, largely in parallel. |
DB_POOL_CONNECT_TIMEOUT | 15_000 ms | Must outlast the pooler’s cold server-pool ramp under a connection burst (see 2026-07-24 cron-tick timeouts). |
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. |
Why not transaction pooling
Transaction mode would make idle app connections free and delete this whole class of
problem. It is not a config flip: the session-scoped advisory locks in
apps/api/src/common/advisory-lock.ts and
subscriptions/plan-change.service.ts take pg_try_advisory_lock and release it in a
later statement. Under transaction pooling those two statements can land on different
backends, so the lock would never be released — it would leak silently until PGBouncer
recycled the connection. Moving them to pg_advisory_xact_lock (as
platform-billing.service.ts already does) is the prerequisite.
Raising PGBOUNCER_DEFAULT_POOL_SIZE is the cheap alternative, and is what was done on
2026-09-03 (20 → 60): Postgres max_connections is 500, so the pooler’s cap has no
database constraint behind it. That buys headroom, not correctness — under session
pooling the pool size still has to track processes x DB_POOL_MAX x 2, so it is a
number to revisit on every replica or DB_POOL_MAX change rather than a fix.
DIRECT_DATABASE_URL
Migrations bypass PgBouncer so a long ALTER TABLE neither occupies one of the 20
pooled server connections for its whole duration nor competes with the app for them —
which matters because preDeployCommand = "pnpm db:migrate" runs while the outgoing
containers still hold theirs. (An earlier version of this line blamed transaction
pooling; the pooler is in session mode, so that was never the reason.)
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 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 (Railway)
DIRECT_DATABASE_URL optional, used by migrations to bypass PgBouncer
DB_POOL_MAX default 20
DB_POOL_IDLE_TIMEOUT default 600000 (ms)
DB_POOL_CONNECT_TIMEOUT default 15000 (ms)
DB_POOL_WARM default 0 (off; session pooling — see sizing rule)
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