Skip to main content

Local development

How to run Complied on your machine: prerequisites, the one-command dev stack, environment files, the local Supabase database (and the one thing you must never do to it), diagnosing migration drift, and running each test suite. For anyone contributing code.

Prerequisites​

ToolVersionUsed for
Node.js20.x for the app and db-tests; 22.18 or later for map-service/ (it runs .ts files directly)Frontend, scripts, edge-function tooling
DockercurrentThe local Supabase stack
Supabase CLIvia npx supabase (a devDependency)Local stack, migrations, function serving
Deno2.1.4, installed by npm ci (a devDependency)npm run check:functions
Chrome or Chromiumany recentOnly for the local PDF render sidecar

Install once: npm ci at the repo root, then npm install in each package you will run (domain/, db-tests/, map-service/, website/). Each has its own package.json and lockfile.

The dev stack​

npm run dev # Supabase check, migrations, Vite, edge functions, map-service
npm run dev -- --no-map --with-render # also start the local PDF render sidecar (needs --no-map, see below)
npm run dev -- --no-map # skip map-service
npm run dev -- --no-migrate # skip "supabase migration up --local"
npm run dev -- --web-only # Vite only (same as npm run dev:web)

scripts/dev-stack.mjs checks that local Supabase is up (it asks before starting it), runs npx supabase migration up --local (never a reset), then starts Vite and supabase functions serve. Unless --no-map is given it also starts map-service with its export watch on, so /map has a snapshot a few seconds after start.

ServiceAddress
App (Vite)http://localhost:5173
Supabase APIhttp://127.0.0.1:54321
Postgres127.0.0.1:54322 (user, password and database all postgres)
Studiohttp://127.0.0.1:54323
Mail (Mailpit)http://127.0.0.1:54324
map-servicehttp://127.0.0.1:8787
Render sidecar:8787 by default, the same port as map-service; set PORT to move it (see below)

Other scripts: npm run dev:https (TLS for *.compliednyc.com testing), npm run build, npm run lint, npm run test, npm run check:functions, npm run seed:dev.

Tenant subdomains. Each tenant has its own subdomain, and the app reads the tenant from the host. To test a tenant locally, map secureenv.local.com and abated.local.com to 127.0.0.1 in /etc/hosts and browse http://secureenv.local.com:5173. Vite's allowedHosts and the edge-function CORS allowlist already include these hosts. npm run seed:dev creates idempotent staff logins and licences for both tenants; it refuses a non-localhost target without SEED_CONFIRM=<project-ref>.

Environment files​

FileHoldsCommitted
.env.local (or .env) at the repo rootVITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEY (required); VITE_MAPBOX_PUBLIC_TOKEN, VITE_MAP_SERVICE_URL, VITE_MAPILLARY_TOKEN (optional)No
supabase/functions/.envEdge-function secrets for local serving (below)No

VITE_* values are publishable: the anon key only, never a service-role key. src/data/supabaseClient.ts throws at import time when the URL or anon key is missing, and CI has no env file, so a test whose import graph reaches src/data/ fails there while passing on a machine that has .env.local. The local file masks that failure. Before claiming the suite is green, move .env.local aside, run npm test, and put it back; keep unit tests on pure modules (domain/, src/lib/).

Edge-function variables used locally:

VariablePurpose
BROWSER_RENDER_ENDPOINTPoint PDF rendering at the local sidecar
REPORT_FONT_BASE_URLFont base the renderer can reach from outside the function container (the container-internal SUPABASE_URL, http://kong:8000, is unreachable from it)
CRON_SECRET, NYC_OPEN_DATA_TOKENOnly when exercising sync-orchestrator
RESEND_API_KEY, RESEND_WEBHOOK_SECRET, EMAIL_FROM_*Only when exercising email delivery; locally, mail lands in Mailpit

SUPABASE_URL, SUPABASE_ANON_KEY and SUPABASE_SERVICE_ROLE_KEY are injected by the Supabase runtime. Never commit a .env* file or paste a key into a doc, issue or commit message.

Local Supabase and the data it holds​

npx supabase start # API :54321, DB :54322, Studio :54323
npx supabase migration new <name>
npx supabase migration up --local
npx supabase functions serve <name>

Always put --local on migration commands: the CLI may be linked to production (supabase/.temp/linked-project.json).

Never run supabase db reset, and never run db-tests/tests/run-tests.sh (it assumes a reset). The local database is not a disposable fixture database. It can hold the real, manually loaded citywide HPD and ECB ingestion data (about 49 GB of raw_data), which takes days to reload. A reset wipes public and reapplies migrations from empty. Ask first, every time. Treat other destructive operations (drops, truncates, bulk deletes) the same way. The buildings and public_events tables also carry delete guards; see ingestion.

If another project's Supabase stack is running on the default ports, stop the one you do not want by container name with docker stop, not supabase stop, which can grab the other stack's ports.

Migration drift​

Drift is when the local supabase_migrations.schema_migrations table no longer matches supabase/migrations/. It happens when two branches create migrations with the same timestamp and a merge renumbers one side: a database that applied the migration under its old number believes something ran that never did, and supabase migration up --local refuses to move. It is bookkeeping, not data damage, and the repair never touches public_events or buildings. If migration up --local refuses to run, it is drift, not a reason to reset.

The full procedure is the migration-drift-repair skill. In outline:

  1. Compare history to disk in both directions.
    select version, name from supabase_migrations.schema_migrations order by version;
    Versions in the database but not on disk block migration up entirely; versions on disk but not in the database are pending.
  2. Compare names for versions present in both. A different name means the number was reused by another migration, which the CLI will skip forever.
  3. For each pending migration, probe whether its effects already exist (table, column, index, function, bucket, grant) rather than inferring from the name. An unguarded add column fails on a second run; create or replace function and create ... if not exists are idempotent.
  4. Scan pending migrations for drop, truncate and delete from. Anything touching public_events or buildings needs explicit approval.
  5. Repair, always with --local:
    npx supabase migration repair --local --status reverted <versions in DB but not on disk>
    npx supabase migration repair --local --status applied <versions already present but unrecorded>
    npx supabase migration up --local
    If up refuses with LegacyMigrationMissingRemoteError, pending migrations sort before ones already recorded; --include-all is the right flag once you have confirmed the file list matches step 3.
  6. Verify: recorded migration count equals the .sql file count with an empty diff both ways; row counts for public_events, buildings and units are unchanged; npx supabase gen types typescript --local has the same table and function keys as src/integrations/supabase/types.ts.

To avoid causing drift: before opening a PR that adds a migration, rebase on main and confirm your timestamp sorts last (ls supabase/migrations/*.sql | tail -3). Rename before the file is applied anywhere; renaming afterwards is what creates drift.

Tests​

SuiteCommandNeeds
App and edge-function helpersnpm run test (vitest; src/** and supabase/functions/** tests)Nothing
Domain packagecd domain && npm testNothing
Edge-function typesnpm run check:functions (Deno type-check of every function)Nothing
Map servicecd map-service && npm test and npm run typecheckNothing (an in-memory DuckDB fixture world)
Database regressioncd db-tests && npm testLocal supabase start

db-tests runs serially against your local database, connects straight to Postgres (default postgresql://postgres:postgres@127.0.0.1:54322/postgres, override with TEST_DATABASE_URL) and simulates PostgREST's RLS context per user. It never tears down fixtures: every run inserts fixture rows into auth.users, so new-looking logins appear in Studio. That is expected noise, but do not run the suite repeatedly without a reason, and ask before running it against a database that holds real data. Strategy and conventions: conventions.

PDF render sidecar​

The XRF report and proposal need a headless browser, which an edge function cannot host (PDF reports). Locally, scripts/local-browser-render.mjs serves the same contract using the Chrome already on your machine, so no account is needed and the correct webfonts load.

npm run dev -- --no-map --with-render # as part of the stack (see the port note below)
PORT=9000 npm run render:local # standalone, headless Chrome on :9000

The sidecar and map-service both default to port 8787. When map-service is running (the default under npm run dev), start the sidecar on another port with PORT=9000 and use that port in BROWSER_RENDER_ENDPOINT (http://host.docker.internal:9000/pdf); or start the stack with --no-map when you are only working on reports.

Never run the bare node scripts/local-browser-render.mjs. It binds 127.0.0.1, which the edge runtime cannot reach from inside Docker, and reports then fail with "the report generator service is not responding". The npm script and --with-render bind 0.0.0.0.

One-time font upload (idempotent):

SUPABASE_URL=http://127.0.0.1:54321 SUPABASE_SERVICE_ROLE_KEY=<local service key> \
node scripts/upload-report-fonts.mjs

Then set BROWSER_RENDER_ENDPOINT and REPORT_FONT_BASE_URL in supabase/functions/.env. If a local report still fails, follow the xrf-report-debug skill.

Ingestion and map data​

Citywide data loads through the CLI in db-tests/ (npm run ingest -- status, backfill, delta, redrive, refresh); see ingestion and the hpd-ingestion-backfill skill. The CLI writes to whatever TEST_DATABASE_URL points at, so check the target first. Fetching is injected (FeedFetchDeps.fetchJson), so feed tests use saved real rows and never call Socrata. After a local load, the map re-exports about a minute later because every sync ends with mark_map_stale() (map).

Docs site​

The docs site in website/ copies canonical markdown from docs/ and the root REQUIREMENTS.md at build time into a gitignored website/docs/. Edit the sources, never the copy.

cd website
npm ci
npm start # sync + Docusaurus dev server
npm run build # sync + production build; also the link checker
npm run serve # preview the build

Requires Node 20 or later. Search is offline. Mermaid diagrams render through @docusaurus/theme-mermaid. website/ has its own package.json, and root ESLint, tsconfig and vitest ignore it; keep it that way when changing root tooling. Publishing: deployment.