Architecture & Technical Decisions
How WorkSmart is put together — the runtime topology, the data model, the request and data flows, and the engineering decisions (with their trade-offs) that shaped the build.
Contents
01System overview
A small, sharp monorepo with a deliberately decoupled inference service.
WorkSmart is a Turborepo + pnpm monorepo. Two deployable apps sit alongside three shared packages, and a single source of truth for every contract lives in @worksmart/types so the browser, the server, and the database all agree on shapes.
Apps
- web (
@worksmart/web) — React 18 + TanStack Router/Query + Vite, styled with StyleX. Ships as a static bundle. - api (
@worksmart/api) — Fastify 5 run straight from TypeScript viatsx. Owns all CRUD, auth, presigned uploads, and AI proxying.
Packages
- types — Zod schemas: the check-in grammar, AI wire contracts, pagination cursors. Imported by both apps.
- ui — StyleX component library (Button, Input, Select, Card, Badge) + design tokens.
- config — shared TypeScript & Biome configuration.
The GenAI inference service (tm-worksmart-ai-service, FastAPI) lives in a separate repository and is reachable only through the api. The browser never holds the service token, and the inference layer can be deployed and evolved on its own cadence.
02Runtime architecture
Everything is a container on one EC2 host; prod mirrors local dev one-to-one.
The web container (nginx) serves the static bundle and reverse-proxies /api/* to the api on the same origin — so there is no browser CORS in play. Document bytes never pass through the api: the browser uploads and downloads them directly to object storage using short-lived presigned URLs.
nginx serving the SPA and proxying /api on one origin means zero CORS configuration and one TLS certificate. The Vite dev server replicates the exact same proxy (stripping the /api prefix), so what you test locally is what runs in production.
03Data model
Six tables. Time, work, and documents, joined by people.
The schema is defined once in Drizzle (apps/api/src/db/schema.ts) and migrations are the committed SQL in apps/api/drizzle/. drizzle-zod keeps the database shape and the Zod validation contracts in lock-step.
Two design notes worth calling out. First, checkins.taskId is nullable: time can be logged loosely or attributed to a task so it rolls up into project totals — the link is optional, never required. Second, documents.extractedData is a jsonb column that holds the full AI analysis result verbatim, so the inference contract can evolve without a migration.
04API surface
A flat, resource-oriented Fastify API. Identity on every request; ownership enforced server-side.
| Area | Endpoints |
|---|---|
| Identity | GET /me · GET /users · GET /health |
| Check-ins | GET /checkins (keyset) · POST /checkins · PATCH /checkins/:id · DELETE /checkins/:id · GET /checkins/stats |
| Projects & tasks | GET·POST·PATCH·DELETE /projects · GET /projects/:id/summary · POST /projects/:id/status-narrative · GET·POST·PATCH·DELETE /tasks |
| Documents | POST /documents/presign · POST·GET /documents · GET /documents/:id/download · PATCH·DELETE /documents/:id · POST /documents/:id/analyze · GET·POST·DELETE link endpoints |
| GenAI | POST /categorize · GET /anomalies · POST /search |
05Key flows
Document upload — bytes bypass the API
Uploads are a two-step handshake. The api mints a presigned PUT URL; the browser streams the file straight to object storage; then the browser confirms, and the api persists only the metadata row. Downloads work the same way in reverse with a presigned GET.
Pagination — keyset, not offset
The check-in feed uses cursor (keyset) pagination over a composite (created_at, id) index. It stays stable while rows are inserted concurrently and never degrades the way OFFSET does deep into a list — which matters for a feed that grows every working day. The web renders it with a virtualized list, so a 1,200-row history scrolls at 60fps.
06Build & deploy
Push to main → quality gate → images to ECR → SSM deploy to the host.
Migrations run as a one-shot api-migrate service that the api depends_on with service_completed_successfully — schema is always applied before the server accepts traffic. Rollback is re-deploying a previous image tag.
07Key decisions & trade-offs
Each choice optimizes for a small team shipping a vertical slice at a time — and says no to the alternatives on purpose.
Vertical-slice first
Phase 0 proved the whole stack end-to-end before any feature was layered on.
Trade-off: less up-front breadth, but every later phase landed on a known-good spine.
Drizzle + drizzle-zod
The DB schema and the Zod contracts are single-sourced; validation and types never drift.
Trade-off: a lighter ORM than the heavyweights, chosen for fast cold starts and small images.
Decoupled AI by storage key
The inference service reads documents from object storage by key — bytes aren’t shipped in requests, and it deploys independently.
Trade-off: one more moving part, bought with a clean, evolvable inference boundary.
Presigned uploads
File bytes never touch the api or a server session, so the upload path is stateless and scales trivially.
Trade-off: storage CORS to configure, in exchange for no large-body handling in the app.
Run TypeScript via tsx
The api runs from source — no compile step, no cross-package build resolution to debug.
Trade-off: ship dev dependencies in the image; gained a dead-simple, fast container.
Containers on one EC2 host
Production is the same docker compose shape as local dev — one reproducible host, no serverless packaging.
Trade-off: manual horizontal scale later, for radical simplicity now.
Keyset pagination
Index-backed cursors stay stable under concurrent inserts and don’t rot at depth.
Trade-off: no random page jumps — the right call for an append-heavy feed.
Auth with a dev bypass
Clerk in production; an offline X-User-Id mode for local dev and grading. Access roles are enforced either way.
Trade-off: a clearly-fenced bypass path, never enabled in production builds.
Every decision favours one obvious way to do things over configurable flexibility — a single source of truth for contracts, a single host shape across environments, a single “AI suggests / you confirm” interaction model. That is what keeps a small codebase fast to change.