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).
Ember decouples decisions from Postgres four different ways. Each is defensible; the variance between them is the thing to notice.
| Style | Where | Shape |
|---|---|---|
| Factory + selectors object | createMeRouter, createMeWordsRouter | closure captures an object of named query fns |
| Ports as first positional arg | importWord(ports, β¦) | plain fn, deps passed explicitly each call |
| Scoped / transactional DI | runGenerateQueue β withUserTransaction(fn β ops) | deps yield a second deps object bound to a tx |
| Structural narrowing | ExpoChunker = Pick<Expo, β¦> | no port object β minimal type the real SDK satisfies |
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.
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.
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:
| Benefit | What it buys you here |
|---|---|
| Testability | Assert fallback cascades, the "skip if future job exists" guard, SRS branching β no DB setup |
| Speed & determinism | No network to Neon, no flaky connections, millisecond tests |
| Readable logic | runGenerateQueue reads as what the system decides; SQL noise lives in makeProductionDeps |
| Swappable impls | Real Drizzle in prod, fakes in tests; same lever swaps the LLM or Expo SDK |
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.
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.
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 };
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.
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).
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.
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
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.
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);
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).
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.
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).
β’ 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.
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}
| Request path | Scheduled path | |
|---|---|---|
| Trigger | User action over HTTP | Wall clock (setInterval) |
| Trust / auth | JWT verified per request | None β "they're our own rows" |
| Outside contract | Hono RPC types (shapes) | DB schema + Expo SDK types (satisfies) |
| Idempotency | tz-date guard in markWordSeen | terminal sent_at/failed_at + WHERE filter |
| Concurrency | isolated per request (free) | must self-serialize (preventOverlappingTicks) |
| Retry | none β caller retries | implicit: pending rows reappear next tick |
| DI-tested logic | router / runGenerateQueue orchestration | runProcessQueue partition + ticket apply |
| DI blind spot | the SRS SQL in srs.ts | the 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.
Alg + expiry are hand-matched across two files with no shared constant. Types protect shapes, not these values.
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.
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.