πŸ”₯
Architecture Research Β· Part II

Dependency Injection & the Two Paths

A deeper read of Ember: what DI actually buys this codebase, and a line-by-line trace of its two halves β€” the request path (user β†’ HTTP) and the scheduled path (clock β†’ push).

1

There isn't "a" DI pattern β€” there are four

Ember decouples decisions from Postgres four different ways. Each is defensible; the variance between them is the thing to notice.

StyleWhereShape
Factory + selectors objectcreateMeRouter, createMeWordsRouterclosure captures an object of named query fns
Ports as first positional argimportWord(ports, …)plain fn, deps passed explicitly each call
Scoped / transactional DIrunGenerateQueue β†’ withUserTransaction(fn β†’ ops)deps yield a second deps object bound to a tx
Structural narrowingExpoChunker = Pick<Expo, …>no port object β€” minimal type the real SDK satisfies
β˜… The standout: transaction-scoped DI

withUserTransaction: (fn) => db.transaction((tx) => fn({ ...ops bound to tx })). The orchestration never imports db or says the word "transaction," yet every op runs inside a real one. It's the loan pattern: the dependency is a function that lends you a scoped resource and guarantees commit/rollback around your callback. Clean solution to the classic "how do I inject something transactional" problem.

⚠ The review note

Why does importWord take ports as an argument while routes use a factory closure? Same idea, two spellings. Not wrong β€” but unjustified variance is a tax even when each instance is fine. And me.ts's findUserById is a 6-line passthrough with zero branching to test β€” DI there is ceremony. The pattern's value is uneven: high where logic forks (generateQueue), ~zero where it's one query.

2

The benefit, proven by the test suite

The single biggest payoff in this codebase: you can test the logic without a database. Every API test swaps the real dependency for a fake β€”

// generateQueue.test.ts β€” the transaction becomes a no-op that just runs the callback
const deps: GenerateQueueDeps = { withUserTransaction: async (fn) => fn(ops), ... };
await runGenerateQueue(deps);

// words.test.ts β€” fake selectors injected straight into the router factory
app.route("/me", createMeWordsRouter({ ...baseSelectors, ...overrides }));

Confirmed by grepping the suite: zero API tests touch a real database β€” no pglite, no testcontainers, no DATABASE_URL. The entire backend test suite runs in-process against injected fakes. From that one capability everything else falls out:

BenefitWhat it buys you here
TestabilityAssert fallback cascades, the "skip if future job exists" guard, SRS branching β€” no DB setup
Speed & determinismNo network to Neon, no flaky connections, millisecond tests
Readable logicrunGenerateQueue reads as what the system decides; SQL noise lives in makeProductionDeps
Swappable implsReal Drizzle in prod, fakes in tests; same lever swaps the LLM or Expo SDK
β˜… The mechanism: inversion of control

Normally a function reaches out and imports db. With DI, control inverts β€” db is pushed in from outside. That single flip moves the database from a baked-in dependency to a substitutable parameter. Testability, speed, swappability are all downstream of that one inversion.

⚠ The flip side β€” DI's blind spot

Because the DB is always mocked in tests, the actual SQL is never exercised. The dynamic query in fetchNewWords, the tz math in insertNotificationJob, the concurrent-insert dance in resolveWordRow β€” all on the production side of the seam, untested. So the precise benefit is sharper than "DI helps testing": DI lets you choose where the test seam goes. Ember put exhaustive tests around the branching logic and left the SQL to be validated some other way.

3

REQUEST PATH  Login β†’ token β†’ daily word β†’ SRS write

Act 0 β€” Cold boot App.tsx Β· AuthContext.tsx

AuthProvider reads tokenStorage.get() (Expo SecureStore / OS keychain). Status goes BOOTSTRAPPING β†’ AUTHENTICATED | UNAUTHENTICATED. Root is a dispatch table, not a router β€” auth state is the route (no navigation library; ADR 2026-05-31).

const SCREENS_BY_STATUS = { BOOTSTRAPPING: SplashView, UNAUTHENTICATED: AuthShell, AUTHENTICATED: DailyWord };

Act 1 β€” Login: where the token is born auth.ts

api.auth.login.$post({ json }) β€” the typed Hono RPC call, valid only because the server chained .route("/auth", authRouter) into AppType. Server: validateLogin (Zod) β†’ loginFindUser β†’ bcrypt.compare β†’ mint:

new SignJWT({ sub: userId }).setProtectedHeader({ alg: "HS256" }).setExpirationTime(now + 86400).sign(secret);

Client: authSuccessSchema.parse β†’ tokenStorage.set β†’ status = AUTHENTICATED β†’ Root swaps to DailyWord.

Act 2 β€” Token rides every request useDailyWord.ts Β· api.ts

Two-step dependency: needs your timezone before it can record a review, so it chains hooks β€” useFetchMe() then the daily-word query, gated by { enabled: !!me }. The api.ts headers callback pulls the token from storage and attaches Authorization: Bearer … per request. No screen ever handles the token. Server: requireAuth β†’ jwtVerify β†’ c.set("userId", sub).

Act 3 β€” Word resolves, then silently records a Review words.ts Β· srs.ts

GET /me/daily-word β†’ three-tier cascade (notified β†’ unseen import β†’ random). Then, as a side effect of viewing, the hook fires POST /me/words/:id/reviews β†’ markWordSeen: the two-statement idempotent write (INSERT ON CONFLICT starts the clock; tz-guarded UPDATE advances ≀1 rung per local day). Wrapped in try/catch {} β€” non-blocking. A 401 calls logout(), flipping Root back to login in the same render.

Act 4 β€” Render DailyWord.tsx

Merges per CONTEXT.md: userDefinition ?? definition; user examples filtered out of LLM examples so nothing shows twice; user examples get the orange " prefix.

LoginScreen ─login()β†’ api.auth.login.$post ─JSON─→ [validateLogin] β†’ bcrypt β†’ SignJWT
     ↑                                                                      β”‚
 token β†’ SecureStore ←──────────────────── { token } β†β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
     β”‚  status=AUTHENTICATED β†’ Root β†’ DailyWord
useDailyWord: GET /me (timezone) ──┐
     β”‚                             β”œβ”€ api.ts attaches Bearer token on every call
     └─ GET /me/daily-word β”€β”€β”€β”€β”€β”€β”€β”€β”˜        β”‚
            β”‚                               β–Ό [requireAuth] jwtVerify β†’ c.set(userId)
     three-tier fallback (words.ts) ─────────
            β”‚                               β–Ό
     POST /me/words/:id/reviews ──→ markWordSeen() β†’ INSERT…ON CONFLICT + tz-guarded UPDATE
            β–Ό   render: userDefinition ?? definition,  examples deduped
⚠ A contract the types DON'T protect

auth.ts mints with 86400s & HS256; middleware/auth.ts verifies with maxTokenAge: "24h" & ["HS256"]. Two files, no shared constant β€” the RPC types guarantee shapes, not values. Mismatch either and every token silently 401s.

4

SCHEDULED PATH  Clock β†’ schedule-ahead β†’ push, with no one watching

Act 0 β€” A different process entirely worker.ts

Same Docker image, different entry point (Fly worker process group). Boots into two timers and nothing else β€” no Hono, no port. The web process waits to be called; the worker calls itself on a clock.

setInterval(preventOverlappingTicks("generateQueue", generateQueue), MIDNIGHT_MS);
setInterval(preventOverlappingTicks("processQueue",  processQueue),  MINUTE_MS);
β˜… The loop must serialize itself

preventOverlappingTicks is an in-process mutex from a closure boolean. HTTP is naturally concurrent so the request path never needs this β€” but a setInterval will fire tick N+1 while tick N is still awaiting the DB. The guard makes a slow tick skip the next rather than overlap (β†’ double-send).

Act 1 β€” Midnight: deciding what to send generateQueue

5-tier cascade (SRS-due β†’ preferred β†’ difficulty β†’ any new β†’ re-surface seen) writes notification_jobs with scheduled_at precomputed in the user's tz. Decisions now, delivery later. hasExistingFutureJob makes it safe to re-run.

Act 2 β€” Every minute: firing processQueue.ts

1. Pull due: WHERE scheduled_at <= NOW() AND sent_at IS NULL AND failed_at IS NULL. The JOIN prefers the user's own content β€” COALESCE(ui.user_definition, w.short_definition), user example[1] else dictionary example[1] (same precedence as the screen, enforced in SQL).
2. Partition: no push token β†’ markJobFailed(MISSING_PUSH_TOKEN) immediately.
3. Build β†’ chunk β†’ send via Expo.
4. Apply: ticket ok β†’ markJobSent; ticket error β†’ markJobFailed(reason).

β˜… Three failure regimes, three fates

β€’ No token β†’ failed immediately (terminal)  β€’ Expo rejects ticket β†’ failed w/ mapped reason (terminal)  β€’ Can't reach Expo β†’ rows stay pending β†’ next tick retries. The retry mechanism is the every-minute loop + the sent_at/failed_at filter. No counter, no backoff, no dead-letter β€” the queue columns are the state machine.

Act 3 β€” The Expo contract, type-checked failures.ts

Opposite of the JWT contract: the Expo error map is compiler-enforced. as const satisfies Record<ExpoErrorCode, string> β€” if a future SDK adds an error code, it's a compile error until you map it.

worker boots β†’ two setIntervals (each wrapped in preventOverlappingTicks)
   β”Œβ”€β”€β”€β”€ midnight UTC ─────────────────────────────┐
   β”‚ generateQueue: pick words (5-tier) β†’ INSERT    β”‚
   β”‚ notification_jobs (scheduled_at, user tz)      β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
   β”Œβ”€β”€β”€β”€ every 60s ────────────────────────────────┐
   β”‚ processQueue: SELECT due + content              β”‚
   β”‚   no token? β†’ markJobFailed        (terminal)   β”‚
   β”‚   else β†’ build β†’ chunk β†’ Expo send              β”‚
   β”‚      ticket ok    β†’ markJobSent    (terminal)   β”‚
   β”‚      ticket error β†’ markJobFailed  (terminal)   β”‚
   β”‚      send threw   β†’ leave pending β†’ retry 60s   β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
        β†’ Expo Push β†’ APNs/FCM β†’ πŸ“±  data:{type:"daily_word", wordId, jobId}
5

The two paths, side by side

Request pathScheduled path
TriggerUser action over HTTPWall clock (setInterval)
Trust / authJWT verified per requestNone β€” "they're our own rows"
Outside contractHono RPC types (shapes)DB schema + Expo SDK types (satisfies)
Idempotencytz-date guard in markWordSeenterminal sent_at/failed_at + WHERE filter
Concurrencyisolated per request (free)must self-serialize (preventOverlappingTicks)
Retrynone β€” caller retriesimplicit: pending rows reappear next tick
DI-tested logicrouter / runGenerateQueue orchestrationrunProcessQueue partition + ticket apply
DI blind spotthe SRS SQL in srs.tsthe content JOIN in fetchPendingJobs

Both halves share the same skeleton β€” orchestration injected, I/O on the production side of the seam, the dangerous SQL untested β€” but everything around it inverts: one is pulled by a user and guarded by a token; the other is pushed by a clock and guarded by nothing but its own queue columns.

6

The honest gaps

1 Β· The JWT value-contract

Alg + expiry are hand-matched across two files with no shared constant. Types protect shapes, not these values.

2 Β· Untested I/O β€” DI's structural ceiling

The most consequential code (SRS tz guard, the content JOIN) lives on the production side of every seam and is executed by no test. DI demands a second test layer (real Neon / pglite) to cover it β€” not present in the suite as read.

3 Β· Tickets β‰  receipts

markJobSent fires when Expo accepts the ticket, not on delivery. Expo's model is two-phase; DeviceNotRegistered (the "this push token is dead" signal) arrives in the receipt, which the worker never polls. So dead tokens accumulate and silently fail forever. CONTEXT.md is explicit that "sent = accepted," so it's a known boundary β€” and receipt-polling is the natural next worker loop.

βˆ‘

The whole system, one paragraph

A Bun/Hono server serves a thin, JWT-gated surface whose types flow directly into the Expo client with no codegen. Handlers are pure orchestration; real DB work is injected as deps/selectors, which is what lets the entire backend test suite run with zero database. 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 tz-aware date math. A separate worker process schedules tomorrow's pushes at midnight and fires due ones every minute, using the queue table's own columns as its state machine. Claude runs entirely offline via the Batches API, so the request path never touches an LLM. The recurring tension throughout: orchestration is exhaustively tested; the I/O it orchestrates is trusted, not verified.