Event guestlist SaaS in a pnpm monorepo.
- API:
@runsheet/api— Fastify, GraphQL Yoga, Drizzle. Producer-only; no background polling loops. - Worker:
@runsheet/worker— consumes@runsheet/pubsubValkey-stream deliveries for email and billing outbox rows, and runs scheduled over-limit reconciliation. The domain-event relay code has been removed; the physicaldomain_eventstable remains until an owner-authored DROP migration. Running only the API is no longer sufficient for email or billing background work. - Web:
@runsheet/web— TanStack Start static marketing site for the public acquisition surface. - Dashboard:
@runsheet/dashboard— Vite, React, TanStack Router, urql, gql.tada. - External:
@runsheet/external— Vite, React, TanStack Router, TanStack Query. Non-credentialed public runtime for attendee personal links (briefing/b/:accessKey;/a/f/oreserved). Renders its own template renderer. - Mobile:
@runsheet/mobile— Expo Router, React Native, local-first encrypted SQLite, and persisted GraphQL sync for event-floor operations. - Packages:
@runsheet/config,@runsheet/database,@runsheet/pubsub,@runsheet/billing,@runsheet/email,@runsheet/builder,@runsheet/ui,@runsheet/i18n,@runsheet/i18n2,@runsheet/validation,@runsheet/observability,@runsheet/tsconfig.@runsheet/i18nis the English-only dashboard/API-adjacent/email translator;@runsheet/i18n2is the Paraglide localization package used byapps/web. Seedocs/adr/0001-i18n-package-direction.md.
- Node
>=24 - pnpm 11, via Corepack and the root
packageManager
pnpm install
cp .env.example .env
cp apps/dashboard/.env.example apps/dashboard/.env
cp apps/web/.env.example apps/web/.env
pnpm codegen
pnpm devpnpm dev starts the API, worker, web, dashboard, external, and mobile Metro
through Turbo. Run only the API process and email/billing background work will
not progress. Mobile requires an installed development build; Expo Go is not
supported.
URLs:
- API health: http://localhost:3000/health
- GraphQL endpoint: http://localhost:3000/graphql
- Web: http://localhost:3400
- Dashboard: http://localhost:3300
Both services read a single repo-root .env via @runsheet/config (API config
lives in apps/api/src/app/config/, worker config in apps/worker/src/config/).
Keys are shared by default; the worker reads WORKER_<KEY> in preference to the
shared <KEY> (e.g. WORKER_LOG_LEVEL=debug while the API stays at info). See
the repo-root .env.example for the full annotated surface.
| Variable | Required | Notes |
|---|---|---|
NODE_ENV |
no | development, test, or production. |
HOST |
no | Defaults to 0.0.0.0. |
PORT |
no | Defaults to 3000. |
DASHBOARD_ORIGIN |
yes | Comma-separated browser origins allowed to call the API with credentials. |
DATABASE_URL |
production / integration | Postgres connection string. Production startup requires it; DB-backed tests use TEST_DATABASE_URL. |
TOKEN_SECRET |
production | Secret for auth cookies/JWTs, token HMACs, OAuth state cookies, and unsubscribe tokens. A dev-only fallback is used outside production. |
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_OAUTH_REDIRECT_URI |
production | Google OAuth credentials and callback URL. |
S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY |
production | S3-compatible storage bucket and credentials. |
S3_ENDPOINT, S3_REGION, S3_FORCE_PATH_STYLE, S3_PUBLIC_BASE_URL |
S3_PUBLIC_BASE_URL in production |
S3-compatible storage endpoint options and the stable public-read base URL for public-safe assets. |
MAIL_FROM_ADDRESS, MAIL_FROM_NAME, MAIL_REPLY_TO, HMAC_INTERNAL_SECRET, MAIL_PUBLIC_API_ORIGIN, ENCRYPTION_SECRET |
production-dependent | Transactional email producer values used at request/enqueue time. The API encrypts outbox bodies; the worker decrypts before sending. ENCRYPTION_SECRET is set once in the root .env, so the API and worker can never drift. |
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_* |
production | Stripe request-time API and webhook signature verification. |
Worker variables are read in apps/worker/src/config/. The worker hosts the
@runsheet/pubsub Valkey stream consumer for email/billing outbox trigger messages and
the scheduled over-limit reconciliation loop. The old domain-event relay code is
removed; see docs/pubsub-migration.md for the retained table cleanup note. See
the repo-root .env.example and
docs/dev/async-jobs-and-events.md for the full surface and feature flags.
Legacy *_INPROCESS=false env vars remain as mixed-version disable overrides.
Dashboard variables are read only through apps/dashboard/src/lib/env.ts.
| Variable | Required | Notes |
|---|---|---|
VITE_API_URL |
production / staging builds | API base origin, for example http://localhost:3000 in development. Production and staging dashboard builds fail when this is missing or points at localhost. |
Web variables are public build-time values under apps/web/.env.example.
Route metadata and emitted Pages assets control indexing behavior.
| Command | Purpose |
|---|---|
pnpm dev |
Run all apps, including mobile Metro, through Turbo. |
pnpm dev:api |
Run just the API. |
pnpm --filter @runsheet/worker dev |
Run just the worker. |
pnpm dev:web |
Run the marketing site on port 3400. |
pnpm dev:dashboard |
Run the dashboard on port 3300. |
pnpm dev:external |
Run the external public app on port 3401. |
pnpm dev:mobile |
Start Metro for an installed mobile development build. |
pnpm storybook |
Run dashboard Storybook on port 6006, restarting it if it exits unexpectedly. |
pnpm build |
Build all workspaces. |
pnpm typecheck |
Run gql.tada check, then TypeScript checks. |
pnpm codegen |
Regenerate API GraphQL, dashboard/mobile gql.tada and persisted-operation artifacts, plus app route trees. |
pnpm codegen:check |
Regenerate committed artifacts and fail if they drift. |
pnpm test / pnpm test:unit |
Run unit and non-DB workspace tests. |
pnpm test:integration |
Run DB/app-backed API integration tests. |
pnpm test:all |
Run unit plus integration tests. |
pnpm db:generate |
Generate Drizzle migrations for the API. |
pnpm db:check |
Check Drizzle migration history consistency. |
pnpm db:migrate |
Apply Drizzle migrations for the API. |
pnpm db:seed |
Run the API seed script. |
pnpm db:studio |
Open Drizzle Studio for the API. |
pnpm lint / pnpm lint:fix / pnpm lint:fix:unsafe |
Run Biome lint tasks. |
pnpm format:fix |
Run Biome format and organize imports. |
pnpm check / pnpm check:write |
Run Biome check at the repo root. |
pnpm clean / pnpm clean:all |
Remove ignored build output, or also root node_modules. |
apps/
api/
run.ts # process entrypoint, initializes observability
seed.ts # local seed CLI
schema/ # GraphQL SDL split files
generated/ # committed GraphQL generated artifacts
src/
server.ts # process lifecycle
app/ # config, app context, Fastify composition
core/ # shared infrastructure
modules/ # domain behavior and resolver/route modules
worker/
run.ts # worker process entrypoint
src/
server.ts # process lifecycle: Valkey stream consumers + over-limit loop
config/ # worker env validation (per-domain slices via @runsheet/config)
shutdown.ts # signal handlers
email/, billing/, events/, handlers/, pubsub/ # runtime wiring per concern
web/
src/
routes/ # TanStack Start file routes
routeTree.gen.ts # committed TanStack Router generated artifact
components/, config/, lib/
dashboard/
graphql-env.d.ts # committed gql.tada introspection
src/
app/ # providers, router, bootstrap
routes/ # TanStack Router file routes
modules/ # dashboard domain modules
components/ # app-level reusable UI
graphql/ # handwritten gql.tada documents
lib/ # browser/client helpers
external/
src/
app/ # query-client, providers, router
routes/ # TanStack Router file routes (b.$accessKey, index)
pages/ # surface page logic (briefing-page, briefing-states)
lib/ # api client, queries, access-key, env, cn
components/ # external-state-page, powered-by-runsheet, template-renderer/
routeTree.gen.ts # committed TanStack Router generated artifact
mobile/
graphql-env.d.ts # committed gql.tada introspection
src/
app/ # Expo Router routes
db/ # encrypted SQLite schema + generated migrations
features/ # local-first feature repositories and UI
sync/ # pull, push, outbox, and conflict handling
packages/
database/ # Drizzle schema, migrations, repos, DB provider
pubsub/ # provider-agnostic messaging (topics, envelope, Valkey provider)
billing/ # Stripe client + billing outbox + over-limit
email/ # email templates + provider + outbox worker runtime
builder/ # Node/Docker bundle helpers
ui/
i18n/ # English-only product-app/API/email translations
i18n2/ # Paraglide-powered web localization
config/
validation/
observability/
tsconfig/
docs/
product/ # product intent
domains/ # domain model and behavior intent
dev/ # implementation docs
specs/ # implementation slices
tasks/ # implementation task plansThe API owns schema SDL in apps/api/schema/*.schema.graphql. Codegen merges
the SDL and writes:
apps/api/generated/resolvers.tsapps/api/generated/type-defs.tsapps/api/generated/schema.graphql
The dashboard uses gql.tada. Operation documents are handwritten under
apps/dashboard/src/graphql/{fragments,queries,mutations} and validated
against apps/dashboard/graphql-env.d.ts.
Fragments imported from another file must be passed as the second argument to
the graphql helper:
export const ViewerQuery = graphql(
`query Viewer { me { ...UserFields } }`,
[UserFieldsFragment],
);Dashboard components import module API hooks. Hooks import GraphQL documents.
Components do not import raw GraphQL documents, @/lib/graphql, or gql.tada.
After API schema changes, run:
pnpm codegen
pnpm typecheck| Alias | App | Resolves to |
|---|---|---|
@/* |
API | apps/api/src/* |
@test/* |
API tests | apps/api/test/* |
@generated/resolvers |
API | apps/api/generated/resolvers.ts |
@generated/type-defs |
API | apps/api/generated/type-defs.ts |
@/* |
Dashboard | apps/dashboard/src/* |
@/* |
Web | apps/web/src/* |
@/* |
External | apps/external/src/* |
@runsheet/<pkg> |
all workspaces | workspace packages under packages/* |
Do not deep-import package internals; import through each package's public entrypoint.
The API initializes observability explicitly in apps/api/run.ts through
initObservability(...). Application code imports logger from
@runsheet/observability; inside GraphQL request scope it resolves to the
request-bound logger.
Never log GraphQL variables, tokens, cookies, password hashes, refresh-token hashes, raw passwords, signed URLs, storage keys, or secrets.
Code is the source of truth for implemented behavior.
docs/dev/operating-manual.mdis the canonical engineering manual; rootAGENTS.mdandCLAUDE.mdare entry points for agent tooling.docs/product/*anddocs/domains/*describe product/domain intent.- Active implementation work should use the current spec/task set under
docs/specsanddocs/tasks. - Completed specs and tasks should be treated as historical baselines, not current implementation truth when they conflict with code.