A daily vocabulary app built on spaced repetition, push notifications, and AI-generated word content โ architecture deep dive.
A monorepo with a deliberate package-manager split mirroring a runtime split:
apps/api runs on Bun (bun install),
apps/mobile on Expo/React Native (pnpm install), and
packages/shared is glue โ compile-time only, ships no runtime code.
The API is one Docker image deployed as two Fly.io process groups: web (the Hono server) and worker (src/jobs/worker.ts). Same code, same DB, different entry point. The server never does background work; the worker never serves HTTP.
The web/worker split is a "share code, split deployment" pattern. Background threads inside the HTTP server were explicitly rejected (ADR 2026-05-30-worker-concurrency-single-process). Splitting processes lets you restart/scale the notification loop without dropping in-flight requests โ and a crash in one doesn't take down the other.
The architectural keystone. The mobile client imports the server's route type directly โ no OpenAPI-to-client codegen, no hand-synced DTOs. Change a response shape on the server and the mobile app gets a compile error.
// apps/api/src/index.ts
const typedApp = app.route("/auth", authRouter).route("/me", meRouter).route("/me", meWordsRouter);
export type AppType = typeof typedApp;
// apps/mobile/src/lib/api.ts
import type { AppType } from "@vocab/api";
return hc<AppType>(API_BASE_URL, { ... });
The .route() calls are chained into typedApp, but export default app exports the unchained app. Deliberate: the runtime only needs app, but the type must be captured from the chained value because Hono accumulates each route into the inferred type. They use @hono/zod-openapi, so one createRoute definition feeds three consumers: runtime Zod validation, RPC types for mobile, and the OpenAPI spec at /docs.
Every route and job is a factory that takes its dependencies as an argument. Handlers contain only orchestration; the actual DB queries are injected at the bottom of the file.
// route logic is pure orchestration...
export function createMeWordsRouter(selectors: MeWordsSelectors) { /* ... */ }
// ...real Drizzle queries wired in once, at the bottom
export const meWordsRouter = createMeWordsRouter({
findTodaysNotifiedWord, findNextUnseenImport, findRandomWord, markWordSeen, importWord,
});
DI without a framework โ just "pass an object of functions." The payoff is testing: runGenerateQueue() runs against fake deps to assert on ordering, the fallback cascade, and the "skip if a future job exists" guard โ with no database. A deliberate seam between what the code decides and how it talks to Postgres.
The Daily Word is chosen by a flat fallback so the screen is never empty:
const word =
(await selectors.findTodaysNotifiedWord(userId)) ?? // 1. the word we pushed today
(await selectors.findNextUnseenImport(userId)) ?? // 2. your imported backlog
(await selectors.findRandomWord()); // 3. anything, graceful
The SRS step ladder โ each correct daily review climbs one rung, recomputing the next review date:
markWordSeen is two statements: an INSERT ... ON CONFLICT DO NOTHING that starts the clock on First Seen, and an UPDATE guarded by (last_reviewed_at AT TIME ZONE tz)::date < (NOW() AT TIME ZONE tz)::date. Tap "review" five times in one afternoon โ exactly one advance per user-local calendar day, enforced at the database, not in racy app code. Timezone source is users.timezone (ADR 2026-06-02).
Midnight UTC โ generateQueue
Decides what to send tomorrow and writes notification_jobs rows with scheduledAt precomputed in the user's tz. Word selection is a 5-tier cascade:
SRS-due โ preferred (category+difficulty) โ difficulty-only โ any new โ re-surface seen
Every minute โ processQueue
Picks rows where scheduledAt <= NOW() and sent_at IS NULL, fires them via expo-server-sdk, and marks each sent or failed โ both terminal, never retried.
(1) scheduledAt is computed once at midnight, so the per-minute loop does pure time comparison โ no tz math at fire time. (2) hasExistingFutureJob() makes the midnight job safe to re-run, and preventOverlappingTicks() stops a slow tick from stacking. (3) The index notif_jobs_scheduled_sent_idx on (scheduled_at, sent_at) is exactly the hot-path query.
| Table | Purpose |
|---|---|
users | Accounts, preferences, timezone, push token, quota |
words | Global dictionary + AI content ยท unique index on lower(word) |
user_imports | Your added words, with example/definition overrides |
user_word_progress | SRS state per (user, word) ยท indexed on (user_id, next_review_at) |
word_translations | Cached per-language translations |
llm_usage | Token + cost tracking per Claude call |
notification_jobs | Scheduled push rows ยท pending โ sent / failed |
A consistent philosophy: push correctness as close to the data as possible. Idempotency โ SQL date guard + ON CONFLICT. Length limits โ CHECK constraints sourced from @vocab/shared constants. Uniqueness โ unique indexes (one carries a comment warning that duplicate imports would multiply JOIN rows and fire N duplicate pushes). The DB refuses to hold invalid state โ which is why the route handlers stay so thin.
A hard rule: the HTTP server never calls Anthropic. Definitions, examples, synonyms, and pronunciation are pre-generated by generate-definitions.ts via the Batches API and written back into words.
At request time, serving a word is a pure DB read โ no external latency, no per-request AI spend, no rate limits in the user's face.