Files
parking_solution/wiki/concepts/local-dev-workflow.md
T
julian 22544ecf63
Build desktop / desktop (push) Successful in 4m37s
CI / check (push) Successful in 42s
Build & push images / images (push) Successful in 2m51s
docs(wiki): log-storm hardening + reset drift guard (2026-07-07 incident)
button-light-indicator: failure backoff + rate-limited logging rationale;
app-logs: storm coalescing invariant + --diagnostics wipe; local-dev-workflow
and appliance-provisioning §7d: new reset flag table + drift guard; log entry
tying all three layers to the ENETUNREACH incident.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-10 08:29:59 +02:00

5.7 KiB

type, tags, sources, updated
type tags sources updated
reference
parking
dev-environment
workflow
2026-06-30

Local Dev Workflow

Dev-environment reference, not product architecture. How to run the stack locally and the gotchas that have bitten us. For device testing under WSL also read wsl-dev-networking.

First-time setup

pnpm install
cp apps/server/.env.example apps/server/.env     # then fill in JWT_SECRET
#   JWT_SECRET=$(openssl rand -hex 32)            # server refuses to start without a strong one
pnpm --filter @parking/db exec drizzle-kit migrate   # create the SQLite schema
pnpm seed:admin                                   # create the first admin (see [[local-jwt-auth]])

apps/server/.env and the *.sqlite files are gitignored (local-only). Leave NODE_ENV unset in dev so the auth cookies aren't Secure-only (Vite dev is plain http).

Running

pnpm dev      # turbo runs both: Vite (web, :5173) + Fastify (server, :3000)

Open http://localhost:5173. The Vite dev proxy forwards /api + /health to the backend, so the SPA and API are same-origin and the local-jwt-auth works without CORS. Production uses an nginx reverse proxy (deploy/nginx.conf) for the same same-origin setup.

Gotchas (all fixed, recorded so they don't recur)

  • Server dev must not be node --experimental-strip-types src/index.ts. Type-stripping does not rewrite .js import specifiers to .ts, so it crashed with ERR_MODULE_NOT_FOUND and silently never started — the symptom was the SPA hanging for minutes (the Vite proxy waiting on a dead backend), then finally erroring. The dev script uses tsx watch instead.
  • Vite proxy → 127.0.0.1, not localhost. localhost resolves to IPv6 ::1 first while the backend binds IPv4; Node's proxy can stall on the v6 attempt. Same class of "slow then works" hang, worse under WSL2 mirrored mode (wsl-dev-networking).
  • .env must actually be loaded. The server reads process.env only; the dev/start scripts load the file via Node's --env-file-if-exists=.env. An empty JWT_SECRET= makes the server fail-fast at boot.
  • Seed into the DB the server reads. seed:admin and the server must use the same DATABASE_URL; running via pnpm seed:admin (which loads apps/server/.env) keeps them aligned.

Useful one-offs

  • First admin: pnpm seed:admin (prompts; blank username → admin). Non-interactive: ADMIN_USER=.. ADMIN_PASS=.. pnpm seed:admin. Reset a password: add FORCE=1.
  • Hardware test scripts (UHPPOTE): apps/server/scripts/uhppote-listen.mjs (live events), uhppote-relay.mjs (guarded door-open). See uhppote-controller.

Database reset — training / demo only (2026-06-30)

A site is sometimes run live to train operators/admins on the real app; afterwards the demo data must go without leaving an obvious self-serve button (an operator must not be able to wipe history). So the reset is a CLI script, not UI: packages/db/scripts/reset-db.mjs, run via pnpm db:reset.

RESET_ALLOWED=1 pnpm db:reset --financial      # default DB = apps/server/parking.sqlite
RESET_ALLOWED=1 DATABASE_URL=/path node packages/db/scripts/reset-db.mjs --all

Category flags (combinable; ≥1 required) — grounded in which tables hold what:

Flag Wipes Keeps
--financial ledger_events (entry/exit/payment/void/shift/cash/anomaly), device_events, snapshots, subscription instances + credentials/plates, blocklist users, devices, config, tariffs, subscription plans
--config site_config, devices, setup_state (→ re-runs first-run setup), tariffs + versions + drafts, subscription plans everything else
--users users, roles, role_permissions, auth sessions everything else
--diagnostics app_logs (the unsigned app-logs store behind /setup/logs) everything else
--all every table (blank slate) —

Drift guard (2026-07-08): before doing anything, the script compares the category union against sqlite_master and refuses if any table is uncategorized — app_logs and tariff_drafts had silently survived every reset (including --all) because the hand-maintained table list lagged the schema. A new table now forces a deliberate one-line categorization decision.

⚠ --financial/--all TRUNCATE the append-only, signed append-only-event-chain. That is the anti-fraud record; a partial delete would break the hash chain, so a financial reset wipes the whole ledger back to empty (re-seeding starts a NEW chain under the same EVENT_SIGNING_KEY — the key is not touched). This is the opposite of how the ledger is meant to behave, hence the gates below. It is a training/demo tool; never point it at a live booth.

Two safety gates (threat-model):

  1. RESET_ALLOWED=1 env must be set — a real booth never sets it, so the command is inert in production even if typed.
  2. Typed confirmation of the DB filename (interactive). --yes skips it for CI/scripted training setup only.

Runs as a single transaction (all-or-nothing) + VACUUM to shrink the re-used demo DB. After --users/--all (users cleared), re-seed an admin: pnpm seed:admin. The EVENT_SIGNING_KEY and BACKUP_KEY are intentionally left alone (see backup-recovery on key custody).

On the BOOTH there is no pnpm — only Docker containers. pnpm db:reset is the dev form; on an appliance, run the same script via docker exec into the server container (node node_modules/@parking/db/scripts/reset-db.mjs …, DATABASE_URL=/data/parking.sqlite). Full booth procedure: appliance-provisioning §7d.