Skip to content

Database conventions

Nexus has three logically separate PostgreSQL databases. Each is on its own Neon project in production and its own Docker container locally.

Database ORM App Neon project
Scheduler Drizzle ORM scheduler-api, scheduler-worker scheduler-postgres
Vendure TypeORM vendure-server, vendure-worker vendure-postgres
Directus Directus managed directus directus-postgres

They do not share a connection pool or cross-reference each other via foreign keys. Cross-system consistency is maintained by the convergence workflows.

The entire schema is defined in one file:

apps/scheduler-api/src/db/schema.ts (~108 KB consolidated)

All tables, enums, and relations live here. Do not split the schema across multiple files — Drizzle’s relational queries require the schema to be in scope together.

Migrations live in apps/scheduler-api/drizzle/. They are journal-driven: every migration file must have a corresponding entry in drizzle/meta/journal.json.

CI checks journal sync on every PR:

Terminal window
# Run locally to verify
pnpm --filter scheduler-api db:check

To generate a new migration after schema changes:

Terminal window
pnpm --filter scheduler-api db:generate # generates SQL migration
pnpm --filter scheduler-api db:migrate # applies to local DB

PostgreSQL does not allow using a newly-added enum value in the same transaction:

-- ❌ One migration file — PG rejects at commit
ALTER TYPE booking_status ADD VALUE 'waitlisted';
UPDATE booking SET status = 'waitlisted' WHERE …;
-- ✅ Two migration files
-- File 1: ALTER TYPE booking_status ADD VALUE 'waitlisted';
-- File 2 (separate file, applied in next run):
-- UPDATE booking SET status = 'waitlisted' WHERE …;

Drizzle wraps each migration file in a transaction. The ADD VALUE succeeds, but using the new value in the same transaction fails. Always split.

Table Description
business Tenant businesses (core entity)
booking Appointment bookings (pending → confirmed → completed/cancelled/no_show)
staff Staff members of a business
customer End customers
service Bookable services (name, duration, price)
availability_rule Staff/service availability windows
session Grouped booking sessions
notification Notification records (all channels)
chat_thread Support ticket threads
billing_invoice_mirror Local Stripe invoice cache
business_billing_audit Billing event log
platform_config Runtime feature toggles
domain Custom domain records + ACME state

Vendure manages its own schema through TypeORM migrations:

Terminal window
pnpm --filter vendure-server migrate # Generate + apply

The Vendure DB is separate from the scheduler DB. Do not mix them.

Directus auto-manages its schema. Changes are tracked via snapshots:

Terminal window
# After making schema changes in the Directus admin UI:
npx directus schema snapshot ./snapshots/latest.yaml
# On deploy / bootstrap:
npx directus schema apply ./snapshots/latest.yaml

Commit the snapshot file whenever the Directus schema changes. This is the only way to reproduce schema changes across environments.

The CI lint-typecheck-test-build job checks Drizzle journal sync before any tests run. If you add a migration file without updating the journal, CI fails.

Terminal window
# Start all three DBs
docker compose -f docker/docker-compose.yml up -d
# Reset and re-migrate scheduler DB
pnpm --filter scheduler-api db:drop && pnpm --filter scheduler-api db:migrate
# Connect to scheduler DB
psql postgresql://postgres:postgres@localhost:5434/scheduler