Migrations
The schema lives in apps/api/migrations/*.sql – plain SQL, applied in file-name order and recorded
with a SHA-256 checksum in schema_migrations.
Applying
npm run db:migrate # = npm run migrate -w @3x/api (uses X3_DATABASE_URL_UNPOOLED)
npm run migrate -w @3x/api -- --status # list applied / pending / changed
npm run migrate -w @3x/api -- --check # exit 1 when something is pending or an applied file changedA session advisory lock serializes concurrent runners. Alternatives: X3_AUTO_MIGRATE=1 (the
first request migrates; other instances wait for the lock) or X3_MIGRATE_ON_BUILD=1 on Vercel.
Each file runs in a transaction unless its first line is -- x3:no-transaction (needed for
CREATE INDEX CONCURRENTLY). An applied file must never change – --check reports a checksum
mismatch, and the API refuses to auto-migrate over it.
Numbering
| Range | Owner |
|---|---|
0001 | initial schema (frozen) |
01xx | ingest |
02xx | analytics and conversions |
03xx | ads |
04xx | MCP and OAuth |
09xx | reserved for partitioning |
Your own deployment-specific migrations belong in a range you reserve (e.g. 08xx) so upstream
updates never collide.
Roles
The first migration creates x3_sql_reader, a role without login that the MCP tool sql_query
switches to (SET LOCAL ROLE). It can read analytics tables and views but no hashes, transient
request context, secrets or credentials. The migrating role therefore needs CREATEROLE once
(Neon's owner role has it).