Architecture
┌────────────────────────────────────────────────────────────────────────┐│ Browser ──HTTPS──▶ api (Elysia + tRPC) ──BullMQ──▶ worker ││ │ │ ││ └──┬──────────────────────────┘ ││ │ over tRPC ││ ▼ ││ data-provider ││ (centralized 3rd-party calls: ││ CoinGecko, Finnhub, DeFiLlama, OpenAI, ││ Etherscan, Helius, Google Sheets, …) ││ ││ Postgres ◀─── api + worker + data-provider (Drizzle) ││ Redis ◀─── api (BullMQ producer) + worker (BullMQ consumer) ││ S3 ◀─── worker (screenshot uploads, file imports) │└────────────────────────────────────────────────────────────────────────┘The four services
Section titled “The four services”apps/backend/api
Section titled “apps/backend/api”tRPC + Elysia HTTP server. Owns per-user credentialed integrations (exchange API keys, brokerage tokens) so user creds never cross the tenant boundary. Also the BullMQ producer — it enqueues every async job; it doesn’t process long-running work inline.
apps/backend/worker
Section titled “apps/backend/worker”BullMQ consumer. Runs every scheduled job (pricing refresh, balance syncs,
historical backfills, transfer linking) and every user-initiated job
(screenshot parse, import, delete) in one binary. There is no separate cron
app — repeatable schedules live in
packages/infra/queue/src/queue-names.ts:REPEATABLE_SCHEDULES, and the worker
registers them with BullMQ at boot via upsertJobScheduler.
apps/backend/data-provider
Section titled “apps/backend/data-provider”tRPC service that centralizes outbound 3rd-party calls. The api and worker
call it over tRPC rather than reaching for upstream APIs directly. This is
the seam between the tiers: in Tier 1
it’s on localhost:8082, in Tier 2/3 it’s a hosted endpoint.
apps/frontend/app
Section titled “apps/frontend/app”React + Vite SPA. tRPC client end-to-end type-safe with the api.
- Postgres — everything durable (users, holdings, transactions, balances, audit log)
- Redis — BullMQ queues + per-provider rate-limiter buckets + realtime fan-out
- S3-compatible store — binary uploads (screenshots, file imports)
Async-job system
Section titled “Async-job system”Single Redis-backed queue (scani-jobs) plus a dead-letter queue
(scani-dlq). The api enqueues; the worker consumes everything.
Repeatable jobs:
pricing,wallet-balances,exchange-balances(hourly)apy-payouts(daily midnight UTC)historical-price-backfill(03:00),forex-backfill(03:30),portfolio-value-rollup(04:00),transfer-linking(05:00) — nightly chainbackfill-token-identity(weekly Sunday 02:00 UTC)reconcile-pending-credentials,reconcile-orphaned-user-jobs(every minute, sweep stuck rows)
User-initiated jobs: screenshot-parse, exchange-import,
wallet-import, file-import, holding-price-update, user-data-delete,
transaction-import.
Each scheduled processor wraps in a Postgres advisory lock
(apps/backend/worker/src/lib/cron-lock.ts) so two overlapping fires of the
same job-name silently no-op rather than racing. Operator tooling can call
HMAC-gated job endpoints on the api (retry / remove / DLQ replay) signed with
JOBS_HMAC_SECRET.
Tech stack
Section titled “Tech stack”- Runtime: Bun end-to-end — no Node
- Type-check:
tsgo(@typescript/native-preview) — 5–10× faster thantscon this monorepo - Lint + format: Biome (no ESLint, no Prettier)
- HTTP: Elysia + tRPC
- Database: PostgreSQL via Drizzle ORM
- Async jobs: BullMQ on Redis, with Postgres advisory locks for cron idempotency
- Auth: Better-Auth (sessions in Postgres)
- Storage: S3-compatible via
@aws-sdk/client-s3 - Email: Fastmail JMAP API or any SMTP server
- Frontend: React + Vite + Tailwind + shadcn/ui
- Dependency injection: typedi (class-field pattern — see Engineering conventions)
- Testing:
bun testwith per-test transactional rollback for repository tests