Files
recipe/.planning/research/ARCHITECTURE.md
2026-04-29 21:07:49 +02:00

260 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Architecture Research
**Domain:** Offline-first meal-planning app (KMP + Ktor, household-shared)
**Researched:** 2026-04-23
**Confidence:** HIGH (locked stack; standard patterns within it)
## System Overview
```
┌──────────────────────────────────────────────────────────────────┐
│ composeApp/ (Android · iOS · Desktop · Wasm) │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ UI: Compose screens + NavHost (Jetpack Nav CMP) │ │
│ │ ViewModel (StateFlow) ──► Repository (reactive Flow) │ │
│ │ │ │ │ │
│ │ ▼ ▼ │ │
│ │ SyncEngine (singleton) ◄──► SQLDelight (local) + Outbox │ │
│ │ │ │ │
│ │ │ AuthSession (AppAuth / ASWebAuth) │ │
│ │ ▼ │ │
│ │ Ktor Client (JWT bearer) ─────────────────┐ │ │
│ └────────────────────────────────────────────┼───────────────┘ │
└──────────────────────────────────────────────┼───────────────────┘
│ HTTPS
┌────────────────────────────────┼───────────┐
│ Authentik (OIDC IdP, homelab) │ JWKS │
└────────────────────────────────┴───────────┘
┌──────────────────────────────────────────────▼───────────────────┐
│ server/ (Ktor 3.x, same homelab) │
│ Auth (ktor-server-auth-jwt) ──► Routes /api/v1/* │
│ │ │ │
│ ▼ ▼ │
│ PrincipalResolver ──► Services ──► Exposed DSL ──► Postgres │
│ (Flyway) │
└──────────────────────────────────────────────────────────────────┘
shared/commonMain: domain models + API DTOs (client + server both depend)
```
## Component Responsibilities
| Component | Responsibility | Typical Implementation |
|-----------|----------------|------------------------|
| Screen (`@Composable`) | Render state, forward intents. No I/O. | `PlannerScreen(state, onAddMeal)`; consumes `collectAsStateWithLifecycle()` |
| ViewModel | Expose `StateFlow`; coordinate repo calls; zero Compose imports | Extends `ViewModel`, scoped via `koinViewModel()`, method-per-action |
| Repository | Single source of truth for one aggregate; hide local/remote split | Exposes `Flow<Domain>` from SQLDelight; write path goes through local DB + outbox |
| SyncEngine | Own outbox drain, pull loop, backoff, auth failure handling | App-scoped Koin singleton; one `CoroutineScope(SupervisorJob)`; started after auth |
| DataSource (local) | Thin SQLDelight wrapper, mapping rows ↔ domain | Per-table `Queries` injected; suspend + `asFlow().mapToList()` |
| DataSource (remote) | Typed Ktor calls for `/sync/push`, `/sync/pull`, catalog endpoints | `HttpClient` with `Auth { bearer { ... } }` + `ContentNegotiation(Json)` |
| AuthSession | Own tokens, refresh, sign-in/out; expose `StateFlow<AuthState>` | Platform-specific actual class (AppAuth / ASWebAuth) behind `expect` |
| Koin Module | Wire graph per layer (`appModule`, `dataModule`, `syncModule`, `authModule`) | Declared in `commonMain`; `startKoin` in `App()` + `MainViewController` |
| Ktor route | HTTP surface; validate DTO; call service; never touch DB directly | `Route.planRoutes()` under `authenticate("auth-jwt") { route("/api/v1") { ... } }` |
| Exposed table | Schema definition + column types; DSL queries via `transaction {}` | `object PlanEntries : Table("plan_entries")` — no DAO |
| Outbox | Durable queue of unsynced local writes keyed by aggregate+id | `sync_outbox` table in SQLDelight; `(op, table, pk, payload_json, attempts)` |
## Recommended Project Structure
```
composeApp/src/commonMain/kotlin/app/recipe/
├── app/ # App() composable, root nav, Koin bootstrap
├── navigation/ # @Serializable route classes + NavGraphBuilder extensions
├── ui/
│ ├── theme/ # Color, typography, Haze style tokens
│ ├── components/ # Reusable (GlassCard, MealSlotChip, ...)
│ └── screens/
│ ├── recipes/ # RecipeListScreen, RecipeDetailScreen, *ViewModel
│ ├── planner/ # PlannerScreen, DayColumn, *ViewModel
│ ├── pantry/
│ └── shopping/
├── data/
│ ├── local/ # SQLDelight driver factory (expect/actual), Queries wrappers
│ ├── remote/ # HttpClient factory, DTOs mirroring shared/, auth interceptor
│ ├── sync/ # SyncEngine, Outbox, pull scheduler, conflict policy
│ └── repository/ # PlanRepository, PantryRepository, CatalogRepository, ...
├── domain/ # Value types, enums (MealSlot), pure computations (shortfall, aggregation)
├── auth/ # AuthSession interface, token store, OIDC config
└── di/ # appModule, dataModule, syncModule, authModule
server/src/main/kotlin/app/recipe/server/
├── Application.kt # embeddedServer, install plugins, call moduleMain()
├── plugins/ # Auth, ContentNegotiation, CallLogging, StatusPages, CORS
├── auth/ # JWKS config, PrincipalResolver (sub → user → household)
├── routes/
│ ├── sync/ # push.kt, pull.kt
│ ├── catalog/ # recipes, ingredients, products (read-mostly)
│ ├── households/ # memberships, invites
│ └── health/
├── services/ # PlanService, SyncService — orchestrate transactions
├── db/
│ ├── tables/ # Exposed Table objects (no DAO)
│ ├── Mappers.kt # ResultRow → shared DTO
│ └── Database.kt # HikariCP + Flyway.migrate()
└── util/ # Clock (injectable), IdGen, Json
server/src/main/resources/db/migration/ # V1__init.sql, V2__plan_entries.sql, ...
shared/src/commonMain/kotlin/app/recipe/shared/ # Domain + DTOs (@Serializable) — no I/O deps
```
Rationale: groups by UI concern then data layer, matching the locked decision in PROJECT.md. `data/sync/` is a first-class folder because sync is the spine of the app. `domain/` holds pure logic so it can be unit-tested without Android/iOS runtime. Server mirrors the client's layered split (routes → services → db) so reasoning transfers.
## Architectural Patterns
### Pattern 1: Repository → reactive Flow → StateFlow in ViewModel
Repositories expose `Flow<Domain>` built from SQLDelight's `asFlow().mapToList()`. The ViewModel lifts that into a cold-hot `StateFlow` using `stateIn` with `WhileSubscribed(5_000)`. Writes go through the repo, which writes to SQLDelight; the reactive query re-emits automatically. **Never** pre-fetch state with a suspend call in `init {}` — that races with collection.
```kotlin
class PlannerViewModel(private val repo: PlanRepository) : ViewModel() {
val state: StateFlow<PlannerState> = repo.observeWeek(currentWeek)
.map(PlannerState::fromEntries)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), PlannerState.Loading)
fun onAddMeal(day: LocalDate, slot: MealSlot, recipeId: Uuid) =
viewModelScope.launch { repo.add(day, slot, recipeId) }
}
```
### Pattern 2: Sync engine as a Koin singleton owning outbox + poll cycles
One long-lived `SyncEngine` bound in `syncModule` with a `SupervisorJob`-backed scope. It exposes `pushNow()`, `pullNow()`, `status: StateFlow<SyncStatus>`. Two loops: a push loop that drains `sync_outbox` with exponential backoff on 5xx/network errors, and a pull loop that calls `GET /sync/pull?since={lastCursor}` every 2030s while foregrounded. Repositories never talk to HTTP directly for household data — they enqueue outbox rows and trust the engine.
```kotlin
class SyncEngine(private val api: SyncApi, private val local: LocalDb, private val clock: Clock) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun start() { scope.launch { pushLoop() }; scope.launch { pullLoop() } }
suspend fun nudge() = pushSignal.emit(Unit)
}
```
Trade-off: single point of failure if the engine deadlocks, so all its work must be cancellable and idempotent (server-side push is keyed by `client_op_id`).
### Pattern 3: Household-scope enforcement at three layers
Defence in depth: (a) **Client query filter** — every SQLDelight query for household-scoped tables includes `WHERE household_id = :hh`, sourced from `AuthSession.activeHouseholdId`; (b) **Server principal resolver** — a `PrincipalResolver` turns the JWT `sub` claim into `(userId, householdId)` via a cached lookup against `memberships`; routes receive an `AuthPrincipal` already carrying `householdId`; (c) **DB row ownership** — every household-scoped table has `household_id uuid NOT NULL` with an index, and every `UPDATE`/`DELETE` includes `AND household_id = ?`.
```kotlin
fun Route.planRoutes(svc: PlanService) = authenticate("auth-jwt") {
post("/api/v1/sync/push") {
val p = call.principal<AuthPrincipal>()!! // householdId baked in
val batch = call.receive<PushBatch>()
call.respond(svc.applyBatch(p.householdId, batch))
}
}
```
Never trust a `householdId` field inside a client payload — overwrite with the principal's.
### Pattern 4: Catalog (read-mostly) vs Household (read-write, synced) split
Two cache + sync policies in one app. **Catalog** (recipes, ingredients, products) is pre-seeded server-side, pulled via versioned ETag (`GET /api/v1/catalog?etag=...`), cached in SQLDelight with a simple "replace all or diff by updated_at" refresh on app start + manual refresh. No outbox. **Household** (plan entries, pantry, shopping items) is LWW-synced with server-assigned `updated_at`, uses the outbox, and is reactively observed. Keep these in separate repositories and separate Koin modules so their refresh semantics don't leak into each other.
## Data Flow — Hero Write Path (Add Meal to Plan)
```
User taps "add meal"
PlannerScreen invokes onAddMeal(day, slot, recipeId)
PlannerViewModel.onAddMeal → viewModelScope.launch { repo.add(...) }
PlanRepository.add():
├─ SQLDelight transaction:
│ INSERT plan_entry (id=localUuid, household_id, day, slot, recipe_id,
│ updated_at=NULL /* server will stamp */, pending=1)
│ INSERT sync_outbox (op='upsert', table='plan_entry', pk=id,
│ payload_json, client_op_id, attempts=0)
└─ Flow<PlanEntries> re-emits → PlannerViewModel.state recomputes → UI updates
│ (optimistic; pending=1 may render a subtle marker)
SyncEngine.nudge() — push loop wakes
Ktor Client POST /api/v1/sync/push (Authorization: Bearer <jwt>)
Ktor Server: install(Authentication) { jwt("auth-jwt") { verifier(jwkProvider) } }
│ JWT validated against Authentik JWKS (cached, rotating)
PrincipalResolver: sub → userId → householdId (cached)
sync/push.kt → SyncService.applyBatch(householdId, batch)
Exposed transaction {
PlanEntries.upsert { it[id]=...; it[householdId]=...; it[updatedAt]=Clock.now() }
// server clock is authoritative
}
Response { applied: [{ id, client_op_id, updated_at: <server ts> }] }
Client: local tx {
UPDATE plan_entry SET updated_at = <server ts>, pending = 0 WHERE id = ?
DELETE FROM sync_outbox WHERE client_op_id = ?
}
Flow re-emits → pending marker vanishes
~~~ (later) partner's device ~~~
Pull loop: GET /api/v1/sync/pull?since=<lastCursor>
Server returns rows with updated_at > since, scoped to householdId
Client upserts rows in a single SQLDelight tx; advances cursor
Partner's PlannerViewModel StateFlow emits new state → their UI updates
```
## Anti-Patterns
### Anti-Pattern 1: Suspend fetch in `init {}` feeding a `MutableStateFlow`
```kotlin
// WRONG
init { viewModelScope.launch { _state.value = repo.getOnce() } }
```
Races with UI collection; loses SQLDelight's reactive updates; forces manual refresh after every write. **Instead:** build the `StateFlow` declaratively from `repo.observeX().stateIn(...)`.
### Anti-Pattern 2: Using Exposed DAO (active record) for new tables
Exposed's DAO API (`IntEntity`, `EntityClass`) looks convenient but leaks lazy-loading through transactions and fights JSONB/composite types. PROJECT.md already forbids it. **Instead:** use the DSL (`Table` objects + `transaction { Table.select { ... } }` + explicit `ResultRow → DTO` mappers). Predictable SQL, no session/transaction surprises.
### Anti-Pattern 3: Sharing SQLDelight transactions across coroutine contexts on iOS
SQLDelight's iOS driver (native-sqlite) uses thread-confined connections. Launching nested `withContext(Dispatchers.IO)` inside a `transaction { }` can throw `IllegalStateException` or silently serialize incorrectly. **Instead:** keep the entire transaction inside one coroutine, use SQLDelight's `transactionWithResult { }`, and do network/CPU work *outside* the tx. On iOS, the driver's own dispatcher handles threading.
### Anti-Pattern 4: Using device clock for `updated_at`
Phones have drifting clocks and timezone shenanigans; a device whose clock is 10 minutes fast will always "win" LWW. **Instead:** server stamps `updated_at` inside the push transaction (`Clock.System.now()` on the server, or `now()` in SQL). The client only stores what the server returns. Local-only edits carry `pending=1` until acknowledged.
### Anti-Pattern 5: Putting UI, HTTP, or DB types in `shared/commonMain`
PROJECT.md scopes `shared/` to domain models + DTOs. Dragging Ktor or SQLDelight into `shared/` pulls platform-specific deps into the server build graph and vice versa. **Instead:** client-only concerns live in `composeApp/`, server-only in `server/`, and `shared/` stays a pure-Kotlin library with `kotlinx.serialization` + `kotlinx.datetime` as its only non-stdlib deps.
## Build Order Implication
The layer that must exist first is **auth + a working Ktor skeleton that echoes an authenticated principal**, because every subsequent layer depends on having a real `householdId` to scope against. After that the unblock order is: (1) **sync engine foundation** — outbox table, empty push/pull endpoints, cursor persistence — so every feature slots into an already-synced path instead of being retrofitted; (2) **catalog read path** — lets the UI render recipes without any write-path complexity, proving HTTP + SQLDelight + Coil end-to-end on a trivial aggregate; (3) **household write path** — the planner as the first real outbox-backed aggregate, which flushes out LWW edge cases; (4) **UI chrome** — Haze-backed glass, navigation polish, theming — last, because decorating a working app is cheap while architecting around decoration is expensive. Skipping step 1 or 2 and jumping to the planner looks faster for a week and costs a month.
## Sources
- `/Users/rwilk/dev/repo/recipe/.planning/PROJECT.md` (authoritative stack + constraints)
- Training knowledge: Compose Multiplatform 1.7+, Jetpack Nav CMP port 2.9.x, SQLDelight 2.x coroutine extensions, Ktor 3.x auth-jwt + JWKS, Exposed DSL transaction semantics, Authentik OIDC discovery
- No web searches needed — patterns are standard within the locked stack
---
*Architecture research for: KMP + Ktor household meal planner*
*Researched: 2026-04-23*