Monorepo packages
The repo is an npm workspaces monorepo (workspaces: ["packages/*", "api", "db", "frontend"] in the root package.json). Packages are consumed by their @voc/* names
and resolve to TypeScript source directly (no build step between workspaces).
The workspaces
packages/core — @voc/core
Pure domain types and logic, with zero provider dependencies. It holds the domain
DTOs (packages/core/src/domain.ts — TenantContext, NewResponse, SurveySummary,
… and the branded id helpers), the permission catalogue and standard-role model
(permissions.ts), and the typed error hierarchy (errors.ts — AppError,
NotFoundError, ConflictError, ValidationError). Everything else depends on it;
instanceof AppError works across packages because there is exactly one @voc/core.
packages/contracts — @voc/contracts
Zod request/response schemas shared by api and frontend, so the wire shape has one
source of truth (e.g. healthResponse). The Worker parses inbound bodies and validates
outbound payloads against these.
packages/ports — @voc/ports
The port interfaces — Database, EmailProvider, SmsProvider, VoiceProvider,
AiProvider, TranscriptionProvider, IdentityProvider, ObjectStore, QueuePort,
ValidationProvider — plus the Ports container type. It imports only from @voc/core (domain
types). No provider SDK appears here. The Database port and its TenantRepositories
bundle live in packages/ports/src/database.ts.
packages/adapters — @voc/adapters/*
The concrete implementations, and the only place a provider SDK is imported: the Neon
adapter (src/neon.ts + src/repositories/ (per-domain modules, assembled by src/repositories.ts)), Resend, Twilio (SMS + Voice), Gemini
(AI + transcription), Clerk (src/clerk-auth.ts, via @clerk/backend), R2, and the
Cloudflare Queues and Postcoder validation adapters, plus
the loud stubs for unconfigured optional providers. Each exports a make… factory the
composition root calls.
api — the Worker
The Cloudflare Worker (Hono). src/index.ts is the entrypoint (fetch/scheduled/queue);
src/app.ts mounts middleware and routes; src/composition-root.ts wires env →
adapters → the Ports container; src/env.ts declares the bindings + secrets. Routes
live in src/routes/, middleware in src/middleware/, and tests in test/ (run against
the in-memory fake Database in test/helpers/fake-database.ts).
db — @voc/db
The data layer's non-domain half: src/schema/ (Drizzle table definitions), src/client.ts
(pinTenant / withTenant — the RLS chokepoint), the SQL files in migrations/, and the
operational scripts in scripts/ (migrate.mjs, rehearse-migrations.mjs, verify-rls.mjs, seed-demo.mjs,
generate-responses.mjs). See Migrations.
frontend
The Vite + React + TypeScript + Tailwind admin SPA and the public survey runtime. It
imports @voc/contracts for typed payloads and holds no secrets.
Dependency direction
Dependencies point inward toward the domain; provider SDKs sit at the outer edge:
frontend ─┐
├─▶ @voc/contracts ─▶ @voc/core
api ──────┤
├─▶ @voc/ports ─────▶ @voc/core
└─▶ @voc/adapters ──▶ @voc/ports + @voc/core + provider SDKs (Neon, Resend, …)
db ───────────────────────────▶ @voc/core
@voc/coredepends on nothing internal.@voc/portsand@voc/contractsdepend only on@voc/core.@voc/adaptersdepends on the ports, core, and provider SDKs — and is the only place the latter appear.apidepends on ports/core/contracts everywhere, and on adapters only incomposition-root.ts.
An ESLint boundary enforces this — domain/route code that reaches a tenant-owned table
outside the withTenant/forTenant path, or imports a provider SDK outside an adapter,
fails CI. See Ports & adapters for the seam itself.