Skip to main content

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.tsTenantContext, NewResponse, SurveySummary, … and the branded id helpers), the permission catalogue and standard-role model (permissions.ts), and the typed error hierarchy (errors.tsAppError, 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/core depends on nothing internal.
  • @voc/ports and @voc/contracts depend only on @voc/core.
  • @voc/adapters depends on the ports, core, and provider SDKs — and is the only place the latter appear.
  • api depends on ports/core/contracts everywhere, and on adapters only in composition-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.