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
| Tool | Version | Used for |
|---|---|---|
| Node.js | 20.x for the app and db-tests; 22.18 or later for map-service/ (it runs .ts files directly) | Frontend, scripts, edge-function tooling |
| Docker | current | The local Supabase stack |
| Supabase CLI | via npx supabase (a devDependency) | Local stack, migrations, function serving |
| Deno | 2.1.4, installed by npm ci (a devDependency) | npm run check:functions |
| Chrome or Chromium | any recent | Only 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.
| Service | Address |
|---|---|
| App (Vite) | http://localhost:5173 |
| Supabase API | http://127.0.0.1:54321 |
| Postgres | 127.0.0.1:54322 (user, password and database all postgres) |
| Studio | http://127.0.0.1:54323 |
| Mail (Mailpit) | http://127.0.0.1:54324 |
| map-service | http://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
| File | Holds | Committed |
|---|---|---|
.env.local (or .env) at the repo root | VITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEY (required); VITE_MAPBOX_PUBLIC_TOKEN, VITE_MAP_SERVICE_URL, VITE_MAPILLARY_TOKEN (optional) | No |
supabase/functions/.env | Edge-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:
| Variable | Purpose |
|---|---|
BROWSER_RENDER_ENDPOINT | Point PDF rendering at the local sidecar |
REPORT_FONT_BASE_URL | Font 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_TOKEN | Only 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:
- Compare history to disk in both directions.
Versions in the database but not on disk blockselect version, name from supabase_migrations.schema_migrations order by version;
migration upentirely; versions on disk but not in the database are pending. - Compare names for versions present in both. A different name means the number was reused by another migration, which the CLI will skip forever.
- 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 columnfails on a second run;create or replace functionandcreate ... if not existsare idempotent. - Scan pending migrations for
drop,truncateanddelete from. Anything touchingpublic_eventsorbuildingsneeds explicit approval. - Repair, always with
--local:Ifnpx 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 --localuprefuses withLegacyMigrationMissingRemoteError, pending migrations sort before ones already recorded;--include-allis the right flag once you have confirmed the file list matches step 3. - Verify: recorded migration count equals the
.sqlfile count with an empty diff both ways; row counts forpublic_events,buildingsandunitsare unchanged;npx supabase gen types typescript --localhas the same table and function keys assrc/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
| Suite | Command | Needs |
|---|---|---|
| App and edge-function helpers | npm run test (vitest; src/** and supabase/functions/** tests) | Nothing |
| Domain package | cd domain && npm test | Nothing |
| Edge-function types | npm run check:functions (Deno type-check of every function) | Nothing |
| Map service | cd map-service && npm test and npm run typecheck | Nothing (an in-memory DuckDB fixture world) |
| Database regression | cd db-tests && npm test | Local 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.