Deployment
Constellation is deployed on Vercel as a multi-zone topology:
| Zone | Host | Vercel project |
|---|---|---|
| Directory (root) | constellation.planetb2b.com | constellation-directory |
| Project Tracker | /projects/* (rewritten) | constellation-platform |
| Catalog | /catalog/* (rewritten) | constellation-catalog |
| Docs | docs.planetb2b.com | constellation-docs |
Directory is the root zone and rewrites requests for /projects/* and /catalog/* to the other Vercel projects.
NEXT_PUBLIC_BASE_PATH
Sub-zone apps read this env var at build time:
| Environment | Value | Behaviour |
|---|---|---|
| Production | /projects or /catalog | Multi-zone: served under prefix, rewrites from Directory |
| Preview | unset | Standalone: served at / for preview deploys |
| Local dev | unset | Standalone: turbo dev on app-local port |
fetch() and basePath
Next.js auto-prepends basePath to <Link> and router.push, but not to fetch(). Use the apiUrl() helper from each app's src/lib/api-url.ts:
import { apiUrl } from '@/lib/api-url';
const res = await fetch(apiUrl('/api/people')); // '/projects/api/people' in prod
Migration gate on release PRs
Every PR targeting main (release branches and hotfixes) runs the Verify Release Migrations CI job. It fails the merge if either staging (DEV_DATABASE_URL) or production (PROD_DATABASE_URL) is missing a migration that's on the branch. The job is read-only — it never applies migrations. Apply outstanding migrations to both databases via the Supabase dashboard SQL editor or the Supabase MCP (apply_migration / execute_sql) before opening the release PR.
CONCURRENTLY cannot go through a transactionScan the migrations you are about to apply first — recursively, since an entry can be a directory:
grep -rl CONCURRENTLY apps/<module>/prisma/migrations/<entry>
PostgreSQL rejects CREATE INDEX CONCURRENTLY / DROP INDEX CONCURRENTLY outright inside a transaction block, so a tool that wraps what it is given does not merely apply them more slowly — the migration fails. Those files are written deliberately without BEGIN;/COMMIT; to avoid holding an ACCESS EXCLUSIVE lock on a busy table for the length of a full index build, and they say so in their header.
Apply them with a session that does not wrap, and always with ON_ERROR_STOP=1 — psql's default is to report an error and carry on, which lets a migration fail its DDL and still run its final schema_migrations insert, recording an incomplete migration as applied:
psql "$PROD_DATABASE_URL" -v ON_ERROR_STOP=1 -f <file>
A migration entry can hold more than one file — 033_tasks_backlog_grooming is migration.sql plus post_migration.sql, and the grep points only at the one containing CONCURRENTLY. Apply every .sql in the entry in runner order (migration.sql first) and record the tracking row only once they have all succeeded; stamping the entry after applying one file marks the whole migration as done while the rest never ran.
The raw psql path also bypasses the module runner's tracking-row write, and not every migration self-inserts. Check the file for a schema_migrations insert and add one yourself if it has none, or the release gate stays red against a database that is already up to date — chained to the apply with && so it cannot stamp a migration that failed, into the owning module's schema (see scripts/check-migrations.ts), and keyed on the migration entry name (a directory-based migration is tracked by its directory, not by the inner .sql file).
If you use the dashboard or MCP instead, run the concurrent statement on its own and then confirm the index is valid: a failed concurrent build leaves an INVALID index behind, which a later re-run guarded by IF NOT EXISTS will skip straight past.
SELECT indexrelid::regclass, indisvalid FROM pg_index WHERE NOT indisvalid;
Full procedure: .claude/skills/release-and-migrations/SKILL.md § Migrations Before the PR.
See scripts/check-migrations.ts (invoked with --strict from the gate) for the implementation.
Environment variables per Vercel project
| Vercel project | Var | Value | Environments |
|---|---|---|---|
constellation-platform | NEXT_PUBLIC_BASE_PATH | /projects | Required on production. Standalone preview/dev unset (serves at /) |
constellation-catalog | NEXT_PUBLIC_BASE_PATH | /catalog | Required on production. Standalone preview/dev unset |
constellation-directory | PROJECTS_ZONE_URL | https://constellation-platform.vercel.app | Required on production. Optional elsewhere (falls back to localhost defaults) |
constellation-directory | CATALOG_ZONE_URL | https://constellation-catalog.vercel.app | Required on production. Optional elsewhere (falls back to localhost defaults) |
The resolveBasePath() helper in scripts/resolve-base-path.ts fails fast if NEXT_PUBLIC_BASE_PATH is missing on Vercel production — without it the sub-zone app deploys at / and breaks multi-zone routing.
RLS-bypass startup guard (assertNonBypassRole)
Each app's src/instrumentation.ts calls assertNonBypassRole() (from @constellation-platform/db) on server startup. PostgreSQL silently disables every Row-Level Security policy — even with FORCE ROW LEVEL SECURITY set on every table — when the connected role has rolsuper = true or rolbypassrls = true. The guard is the fail-fast tripwire for that: if DATABASE_URL is ever repointed at a SUPERUSER/BYPASSRLS role, the app refuses to boot instead of silently serving cross-tenant data. Production connects as the non-bypass constellation_app runtime role, so the guard passes; it only trips on a regression.
| Var | Value | Effect |
|---|---|---|
CONSTELLATION_ALLOW_BYPASS_RLS | 1 | Fail-closed opt-out — attested CI only. Skips the guard ONLY on an attested ephemeral GitHub Actions runner: it takes effect only when CI=true and GITHUB_ACTIONS=true and DATABASE_URL is a loopback host (localhost/127.0.0.1/::1) and no Vercel signal (VERCEL/VERCEL_ENV) is present. Every deployment tier fails that attestation — SaaS (Vercel), Dedicated Cloud, and On-Prem (Docker/K8s) set neither CI var and use a remote database — so a stray value here is ignored on any deployment and the guard always enforces. (Vercel is additionally an absolute veto, even under full attestation.) Set it only where a privileged/bypass role is used deliberately (the CI end-to-end tests serving the apps as a superuser). Any other value, or unset, always enforces. |
Local dev needs nothing: .env.local.example already points the apps at constellation_app. Migration scripts legitimately connect as a privileged role and never call the guard.
Vercel project wiring
The four Vercel projects share a build root (the monorepo) but each has its own Root Directory set to apps/<name> so Vercel runs each build from inside that workspace. installCommand and buildCommand are left blank on three of them (Vercel auto-detects); the docs project overrides them to use Turborepo, see apps/docs/vercel.json. All projects use the framework default output directory (.next for the three Next apps, build for Docusaurus) — apps/docs/vercel.json declares outputDirectory: "build" explicitly, but it matches the Docusaurus default.