๐Ÿ”ฅ

Ember

A daily vocabulary app built on spaced repetition, push notifications, and AI-generated word content โ€” architecture deep dive.

Bun + Hono API Expo React Native Neon Postgres Drizzle ORM Anthropic Batches API Fly.io ยท web + worker
1

One codebase, two runtimes, two processes

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.

๐Ÿ“ฑ MobileExpo ยท RN
โ†’
webHono server
๐Ÿ—„๏ธ NeonPostgres
workersetInterval loops
โ†’
Expo PushAPNs / FCM
โ˜… Insight

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.

2

End-to-end type safety with no codegen

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, { ... });
โ˜… Insight

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.

3

Ports & selectors โ€” hand-rolled DI

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,
});
โ˜… Insight

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.

4

The domain core โ€” SRS engine & the Daily Word cascade

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:

1day
3days
7days
30days
90days
180days
365days
โ˜… Insight โ€” idempotency in SQL

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).

5

Notifications โ€” schedule ahead, fire on time

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.

โ˜… Insight

(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.

6

Data model โ€” correctness pushed into the database

TablePurpose
usersAccounts, preferences, timezone, push token, quota
wordsGlobal dictionary + AI content ยท unique index on lower(word)
user_importsYour added words, with example/definition overrides
user_word_progressSRS state per (user, word) ยท indexed on (user_id, next_review_at)
word_translationsCached per-language translations
llm_usageToken + cost tracking per Claude call
notification_jobsScheduled push rows ยท pending โ†’ sent / failed
โ˜… Insight

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.

7

AI is offline, never on the request path

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.

๐Ÿ“„ CSVsNGSL ยท AWL ยท tech
โ†’
seedbulk insert
โ†’
๐Ÿ—„๏ธ words
โ†’
๐Ÿค– ClaudeBatches API
โ†’
write backcontent cols

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.

โˆ‘

The one-paragraph mental model

A Bun/Hono server serves a thin, JWT-gated REST surface whose types flow directly into the Expo mobile client with no codegen. Route handlers are pure orchestration; real DB work is injected as selectors/deps for testability. The domain logic โ€” SRS ladder, daily-word fallback, notification scheduling โ€” lives partly in small TypeScript functions and partly inside Postgres as constraints, idempotency guards, and timezone-aware date math. A separate worker process schedules tomorrow's pushes at midnight and fires due ones every minute. Claude runs entirely offline via the Batches API, so the request path never touches an LLM.