A note on scope: I built this over a weekend, in a couple of focused sessions, as a working demonstration of how I'd approach this problem space. It isn't production (no real device, demo-scale data), but the patterns are the ones I'd ship to production: typed boundaries, idempotent ingestion, reviewed migrations, and reversible architecture decisions (written up here).
Wearclair is a continuous hormone-intelligence platform for a wrist wearable. It turns temperature, heart rate, HRV, respiratory, EDA, arterial stiffness, perfusion, SpO₂, bioimpedance, and motion samples into cycle-phase, readiness, hormone, and health insights.
The repository contains a Fastify/NestJS API, a background worker, shared domain and infrastructure libraries, an Expo mobile app, and a separate Next.js dashboard. High-volume biomarker data lives in TimescaleDB; relational product data and derived insights live in a Prisma-managed PostgreSQL database.
| Home | Vitals | Insights |
|---|---|---|
![]() |
![]() |
![]() |
| Calendar | Timeline | Edit period |
|---|---|---|
![]() |
![]() |
![]() |
| Add note | Performance | BLE sync simulator |
|---|---|---|
![]() |
![]() |
![]() |
The Expo app shows the continuous hormone dashboard, the live biomarker grid, AI-generated health insights, cycle calendar and timeline, period and note editing, performance readiness, and the BLE sync simulator used in place of real hardware.
The API owns authenticated HTTP interactions and publishes typed events. Inngest invokes retryable worker stages that aggregate bounded device-local windows and upsert derived product data. Clients query the API rather than either database directly.
The system diagram is embedded above and stored as a self-contained SVG. The complete architecture reference follows in this README so setup, runtime boundaries, data movement, reliability, and extension guidance stay in one place. The reasoning behind the load-bearing choices (TimescaleDB over plain Postgres, Inngest over a Redis-backed queue, and the api/worker split) is written up in assets/docs/architecture-decisions.md.
Clair band or local simulator
→ POST /devices/:deviceId/sync
→ SyncBatch RECEIVED ledger entry
→ batch-attributed raw_biomarker write + RAW_WRITTEN state
→ device/batch.synced Inngest event
→ PUBLISHED state + worker local-day classification
→ DailyInsight + HealthInsight upserts with raw-window lineage
→ PROCESSED state + per-user realtime completion signal
→ biomarker, insight, and cycle query APIs
→ mobile and dashboard clients
Important properties of this flow:
- Raw inserts carry their
batch_id; event delivery and derived writes are designed for safe replay. - Inngest separates the worker into independently retryable steps.
- The worker groups raw samples by the device-stamped
local_day, so late samples repair the correct local day through stable upsert keys. - AI-generated health cards use a 15-day change signature and fall back to deterministic rules when the model is unavailable.
- Cross-database lineage is stored by value; the two databases have no foreign keys between them.
| Component | Responsibility | Entry point |
|---|---|---|
| Expo mobile | Authentication, buffered band simulation/sync, and consumer views | mobile/src/app/ |
| Next.js dashboard | Browser client and authenticated workspace | dashboard/src/app/ |
| API | Fastify/NestJS REST API, OpenAPI, CQRS, Mastra routes, and event publication | apps/api/src/main.ts |
| Worker | Separate NestJS application serving retryable Inngest functions | apps/worker/src/main.ts |
| Inngest | Typed event transport, durable execution, deduplication, retries, and step memoization | inngest.yaml |
| App PostgreSQL | Auth, devices, sync batches, cycle logs, and derived insights | prisma/app/schema.prisma |
| TimescaleDB | Raw biomarker samples and 1-hour/1-day aggregates | timeseries/migrations/ |
| Mastra storage | Agent runtime state and memory, isolated from product data | libs/providers/mastra/ |
| Shared libraries | Infrastructure, providers, simulation, and pure insight logic | libs/ |
The API and worker are separate NestJS applications. They share libraries but never import one another. The worker does not use API request-scoped ALS; user identity required by background work travels in typed event data. Inngest is the event and durable-execution layer between them; there is no BullMQ queue in this path.
- Fastify assigns or preserves
x-request-idand applies body limits, CORS, Helmet, cookies, compression, and multipart handling. - Better Auth owns raw routes under
/api/auth/*. - Every Nest controller route is protected by the global
SessionGuardunless marked@Public(). - The guard resolves the session, attaches it to the request, and writes
userIdinto API-only ALS. - Zod parses boundary DTOs; thin controllers dispatch typed CQRS commands or queries.
- Handlers access relational data through
AppPrismaService, time-series data throughBiomarkerStore, and integrations through provider services. - Zod validates/encodes responses and
AllExceptionsFilternormalizes errors.
Swagger/OpenAPI is served at /api and generates the mobile and dashboard clients. HTTP, CQRS, service, event, and Inngest boundary types derive from Zod schemas with stable metadata IDs.
devices: registration, ownership-aware listing, real batch ingestion, and deterministic sync simulation.biomarkers: latest samples and bucketed series from TimescaleDB.insights: daily and health-insight reads from app PostgreSQL.cycle: logs, predictions, calendar, and timeline composition.event-publisher: typed Inngest publication and API-side function registration.health: runtime dependency checks.
Mastra is mounted globally through the Fastify adapter at /mastra/*. Its runtime state is stored separately from Wearclair product data.
Wearclair uses separate relational and time-series databases because their access patterns and lifecycles differ.
The wearclair database is accessed through AppPrismaService and owns:
- Better Auth users, sessions, accounts, and verification records;
- registered devices and
SyncBatchstate; DailyInsightcycle, readiness, and hormone derivations;- AI- or rule-generated
HealthInsightcards; and - user-authored
CycleLogevents.
APP_DATABASE_REPLICA_URL optionally enables transparent read-replica routing.
The wearclair_tsdb database is accessed through BiomarkerStore and owns:
- the narrow
raw_biomarkerhypertable; - the nullable
batch_idused to attribute newly ingested raw samples; biomarker_1handbiomarker_1dcontinuous aggregates; and- retention and compression behavior defined by reviewed SQL under
timeseries/migrations/.
There are no cross-database foreign keys or distributed transactions. Derived rows record source_from, source_to, and source_sample_count, preserving raw-window lineage by value while allowing each database to evolve independently.
Locally, the TimescaleDB container on port 6543 hosts three logical databases: wearclair, wearclair_tsdb, and wearclair_mastra.
Real device sync and POST /devices/:deviceId/simulate-sync converge on IngestBatchCommand:
- ALS supplies
userId, and the handler verifies device ownership (primary-routed read). - Prisma records a
SyncBatchinRECEIVEDstate first. A client-suppliedclientBatchIdmakes retries reuse the same row, while a stored content hash rejects reuse of that key with different samples. - Samples are inserted into
raw_biomarkerwith that batch'sbatch_id, using chunkedUNNESTwrites andON CONFLICT DO NOTHING. The batch advances toRAW_WRITTEN, andDevice.lastSyncedAtmoves forward when appropriate. - The API publishes typed
device/batch.syncedwith IDdevice-batch-<batchId>for delivery deduplication, then advances the batch toPUBLISHED. - The API's
recover-stale-batchescron reconciles stranded work every 10 minutes. It can promote aRECEIVEDbatch when its exactly attributed raw rows prove the write committed, and republishes a dueRAW_WRITTENbatch with the same deterministic event ID.
The worker serves functions at :3311/api/inngest. compute-daily-insights runs three named,
independently retryable steps:
load-daily-statsgroups required raw metrics by the device-stampedlocal_day.classify-and-upsertderives cycle phase, readiness, and hormone estimates and upsertsDailyInsightby(userId, date).mark-batch-processedadvances the source batch toPROCESSED.
After those steps, the function publishes a per-user Inngest Realtime completion signal for clients.
Health insight cards are generated by a separate, debounced refresh-health-insights function
(triggered off the same event). A signature over the latest day plus its preceding 14-day baseline
avoids unnecessary regeneration. The function transactionally replaces stale card keys while
upserting current cards by (userId, date, key), so card generation never blocks or retries the
numeric derivation.
Completed Inngest steps are memoized. A transient failure resumes from the failed boundary. Late raw samples re-derive the loaded window, and stable upsert keys repair historical days without duplication.
libs/feature/cycle-insights contains pure domain logic. It converts daily temperature, resting-heart-rate, and HRV summaries into cycle phase, cycle day, readiness, and estimated estradiol, progesterone, LH, and FSH values.
Health insights are AI-first when ANTHROPIC_API_KEY is configured. Model output is constrained with Zod before persistence. Missing credentials, provider failures, or invalid output invoke deterministic rules, keeping local development and the demo functional without an external call.
/biomarkers/latestand/biomarkers/seriesread TimescaleDB./insights,/insights/today, and/insights/healthread derived relational data./cycle/predictions,/cycle/calendar,/cycle/timeline, and/cycle/logscompose insights with user logs.
The Expo app uses generated OpenAPI functions with TanStack Query. A successful sync immediately
invalidates device and raw-backed biomarker queries. Worker-derived insight and cycle queries refresh
after the authenticated user's Inngest Realtime PROCESSED signal, with a 10-second delayed fallback
when realtime is unavailable. Account changes gate rendering while TinyBase and the TanStack Query
cache are cleared, preventing one account's local data from appearing under another session. The
Next.js dashboard uses its own generated client and credentialed Better Auth cookies.
- Raw writes are idempotent through a time-series dedupe index and
ON CONFLICT DO NOTHING. - Stable event IDs deduplicate repeated batch publication.
SyncBatchplus rawbatch_idattribution forms a cross-database state machine: the ledger records intent before the time-series write, and reconciliation repairs interruptions between the stores.- Named Inngest steps isolate retries and memoize completed work.
- Daily and health insights use stable unique keys and Prisma upserts.
- AI failures fall back to deterministic rules instead of failing the feed.
- Batch state progresses through
RECEIVED,RAW_WRITTEN,PUBLISHED, andPROCESSED;FAILEDrecords an ingest failure rather than silently losing it. - Raw-window lineage is stored by value because cross-database transactions and foreign keys are intentionally absent.
- HTTP errors are normalized and retain request IDs in structured Pino logs.
CacheModulecan use Valkey throughAPP_REDIS_URL; it remains available but no current product flow depends on it, so cache is omitted from the architecture diagram.StorageModulewraps S3-compatible storage. Local Ministack exposes it on port4567and initializeswearclair-uploads.ResendModulewraps outbound email delivery.- The API Mastra adapter serves HTTP routes; the worker uses the shared Mastra runtime without importing API code.
- Observability uses Pino: pretty logs in development and JSON in production. OpenTelemetry and Sentry are not installed.
wearclair/
├── apps/
│ ├── api/ # authenticated REST API, CQRS features, OpenAPI, Inngest publisher
│ └── worker/ # Inngest consumers and background insight derivation
├── libs/
│ ├── system/ # auth, ALS, cache, database, queues, time-series, logging
│ ├── providers/ # S3, Resend, and Mastra integrations
│ └── feature/ # pure simulator and cycle-insight domain logic
├── prisma/app/ # relational app schema; generated client is imported as @orm/app
├── timeseries/ # reviewed TimescaleDB SQL, migration runner, and demo seed
├── mobile/ # separate Expo app with generated OpenAPI client
├── dashboard/ # separate Next.js app with generated OpenAPI client
└── assets/architecture/ # self-contained architecture diagrams and README preview
Requirements: Node.js 24, Bun 1.3.14 or newer, and Docker.
bun install
bun run infra:up
bun run tsdb:migrate
bun run prisma:migrate:app
bun run seed:demoStart the backend processes in separate terminals:
bun run start:dev:api
bun run start:dev:worker
bun run inngest:devStart the mobile app:
cd mobile
bun install
bun startSeeded demo credentials:
demo@wearclair.dev
wearclair-demo
Use the app's sync action to send locally generated band samples through the real ingest path. Watch compute-daily-insights in the Inngest UI, then see Today, Perform, Cycle, and Insights refetch the server-derived results.
Install dependencies for the backend and each separate client application:
bun install
bun install --cwd dashboard
bun install --cwd mobileCreate local environment files:
cp apps/api/.env.example apps/api/.env
cp apps/worker/.env.example apps/worker/.env
cp dashboard/.env.example dashboard/.envCreate prisma/.env for Prisma CLI commands:
APP_DATABASE_URL=postgresql://postgres:postgres@localhost:6543/wearclairGenerate the Prisma client:
bun run prisma:generateDo not hand-write Prisma migration files. The user applies app-schema changes deliberately with bun run prisma:migrate:app. Time-series DDL is reviewed SQL under timeseries/migrations/ and is applied with bun run tsdb:migrate.
Physical mobile devices need EXPO_PUBLIC_API_URL in mobile/.env set to the development machine's LAN address, such as http://192.168.1.20:3310. The API listens on 0.0.0.0.
bun run build # typecheck/build api and worker
bun run lint # eslint --fix for apps and libs
bun run lint:check # non-mutating backend lint (dedicated Mastra excluded)
bun run format # prettier for apps and libs
bun test apps libs timeseries # backend/shared/time-series test suite
bun run test:core # submission tests (dedicated Mastra excluded)
bun run check:core # all submission checks; dashboard excluded
bun run prisma:generate # regenerate @orm/app
bun run tsdb:migrate # apply reviewed time-series SQL
bun run tsdb:generate # refresh reference SQL generated by @timescaledb/core
bun run seed:demo # idempotent demo data seed
bun run infra:logs # follow local infrastructure logs
bun run infra:down # stop local backing servicesbun run --cwd mobile start
bun run --cwd mobile lint
bun run --cwd mobile typecheck
bun run --cwd mobile gen:api # API must be runningbun run --cwd dashboard dev
bun run --cwd dashboard gen:api # API must be running







