Files
familysync/.planning/codebase/ARCHITECTURE.md
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
2026-06-11 20:35:18 -04:00

346 lines
25 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.
<!-- refreshed: 2026-06-09 -->
# Architecture
**Analysis Date:** 2026-06-09
## System Overview
```text
┌─────────────────────────────────────────────────────────────┐
│ PWA Frontend (React 19) │
│ CalendarShell + Schedule-X calendar + EventForm + UI state │
│ TanStack Query (server state) + Zustand (UI-only state) │
│ `apps/pwa/src/` │
└────────┬──────────────────────────────────────────────────┬─┘
│ │
│ fetch (with credentials) │ SSE
│ (OIDC session cookie) │
▼ ▼
┌──────────────────────────────────────────────────────────────┐
│ Backend API (Hono + Node.js) — Port 3000 │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Auth Layer (OIDC + Authelia) │ │
│ │ `apps/api/src/auth/middleware.ts`, `devBypass.ts` │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Route Handlers — Read from MariaDB cache only │ │
│ │ GET /api/events — windowed occurrences via expand.ts │ │
│ │ GET /api/me — current user profile + color │ │
│ │ POST /api/events/create, PATCH /:uid/edit — enqueue │ │
│ │ DELETE /:uid — enqueue delete to outbox │ │
│ │ GET /api/sse/heartbeat — SSE smoke test │ │
│ │ `apps/api/src/routes/` │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ DB Layer (Drizzle ORM + mysql2) │ │
│ │ Schema: users, member_credentials, calendars, │ │
│ │ calendar_events, calendar_outbox │ │
│ │ `apps/api/src/db/` │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Background Broker (CalDAV sync & write-back) │ │
│ │ - Poller (5-min): PROPFIND → ctag change detect │ │
│ │ - Sync (per-cal): REPORT → ical.js → MariaDB upsert │ │
│ │ - OutboxWorker (15-sec): drain pending writes to │ │
│ │ Fastmail (PUT/DELETE via tsdav) │ │
│ │ `apps/api/src/broker/` │ │
│ └──────────────────────────────────────────────────────────┘ │
└────────┬──────────────────────────────────────────────────┬──┘
│ │
└─ Fastmail CalDAV + app passwords ────────────────┘
(tsdav client, encrypted credentials)
(PROPFIND, REPORT, PUT, DELETE)
MariaDB (persistent cache)
(read on every request, written by broker)
```
## Component Responsibilities
| Component | Responsibility | File |
| ------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------ |
| **CalendarShell** | React entry point; wires TanStack Query + Schedule-X + Zustand + event modals | `apps/pwa/src/components/CalendarShell.tsx` |
| **AppNav** | Navigation header/sidebar; responsive (phone/tablet layout) | `apps/pwa/src/components/AppNav.tsx` |
| **EventDetailPopover** | Displays event details; driven by Zustand.openEventId | `apps/pwa/src/components/EventDetailPopover.tsx` |
| **EventForm** | Create/edit event modal; builds payloads for POST/PATCH endpoints | `apps/pwa/src/components/EventForm.tsx` |
| **SyncStateToast** | Toast polling `/api/events/sync-status?uid=` for optimistic-accept feedback | `apps/pwa/src/components/SyncStateToast.tsx` |
| **Zustand calendarStore** | UI-only state: selectedView, openEventId, eventFormOpen, calendarRange, deleteDialog state | `apps/pwa/src/store/calendarStore.ts` |
| **TanStack Query hooks** | Server state: fetchMe (user profile), fetchEvents (windowed occurrences), fetchSyncStatus | `apps/pwa/src/api/client.ts` + CalendarShell.tsx |
| **hydrateEvents** | Transforms raw CalendarOccurrence[] to Schedule-X CalendarType[] with Temporal dates | `apps/pwa/src/lib/hydrateEvents.ts` |
| **buildCalendarConfig** | Builds Schedule-X calendar configuration (views, plugins, columns) | `apps/pwa/src/lib/calendarConfig.ts` |
| **OIDC middleware** | Protects /api/\* routes; 302-redirects unauthenticated requests to Authelia | `apps/api/src/auth/middleware.ts` |
| **devAuthBypass** | Dev-only passthrough auth for local development without Authelia | `apps/api/src/auth/devBypass.ts` |
| **upsertUser** | Identity upsert by (oidc_iss, oidc_sub); auto-assigns color from palette | `apps/api/src/auth/user.ts` |
| **eventsRouter** | GET /api/events (windowed reads), POST/PATCH/DELETE (enqueue outbox), GET /writable-calendars | `apps/api/src/routes/events.ts` |
| **meRouter** | GET /api/me — returns authenticated user profile (id, displayName, color) | `apps/api/src/routes/me.ts` |
| **sseRouter** | GET /api/sse/heartbeat — SSE smoke test for Pangolin proxy validation | `apps/api/src/routes/sse.ts` |
| **CalDAV Broker** | Decrypts credentials, manages tsdav clients, syncs calendars, drains outbox | `apps/api/src/broker/*.ts` |
| **Poller** | 5-min background job: PROPFIND → ctag comparison → skip or syncCalendar | `apps/api/src/broker/poller.ts` |
| **syncCalendar** | REPORT → ical.js parse → VEVENT upsert; prunes deleted events | `apps/api/src/broker/sync.ts` |
| **OutboxWorker** | 15-sec background job: drain pending outbox rows, build VEVENTs, PUT/DELETE to Fastmail | `apps/api/src/broker/outboxWorker.ts` |
| **expandOccurrences** | Server-side RRULE expansion via ical.js; produces concrete occurrences for UI | `apps/api/src/broker/expand.ts` |
## Pattern Overview
**Overall:** Three-tier monolith (PWA frontend, Node.js backend, MariaDB), split between `apps/pwa` (React) and `apps/api` (Hono + broker).
**Request-response pattern:**
- Frontend reads from MariaDB cache via REST endpoints (GET only)
- Frontend enqueues writes to transactional outbox (POST/PATCH/DELETE return 202 immediately)
- Background broker drains outbox, calls Fastmail CalDAV, updates cache
- Real-time updates via SSE (Phase 4) and/or polling (SyncStateToast for write feedback)
**Data ownership pattern:**
- Poller owns calendar collection discovery + change detection (D-13 ctag polling)
- syncCalendar owns per-calendar event cache (REPORT → parse → upsert)
- OutboxWorker owns write-back to Fastmail (D-05 transactional outbox)
- Routes own read authorization and ownership checks (T-03-06..T-03-11)
**Key Characteristics:**
- Events endpoint shares MariaDB cache — no direct Fastmail I/O from routes (T-03-02 broker boundary)
- Write operations use optimistic-accept pattern: 202 + immediate UI response, success confirmed via polling
- All server state in TanStack Query; UI state only in Zustand (clear separation)
- User identity keyed on (oidc_iss, oidc_sub) not email (D-10); color auto-assigned (D-06)
- All-day events stored as DATE, timed events as TIMESTAMP UTC (D-13 schema contract)
- Recurring events expanded server-side (D-09); client receives concrete occurrences only
## Layers
**Presentation (React PWA):**
- Purpose: Display calendar, handle user interactions, manage UI state (view selection, modals, popovers)
- Location: `apps/pwa/src/`
- Contains: Components (CalendarShell, EventForm, EventDetailPopover, AppNav, SyncStateToast), UI hooks (CalendarShell's useQuery for data, Zustand for view state)
- Depends on: Schedule-X (calendar library), @tanstack/react-query (server state), Zustand (UI state), TanStack utilities
- Used by: Browser tab (Vite dev proxy or production Pangolin tunnel)
**API / Route Layer:**
- Purpose: Validate requests, enforce authorization (T-03-06..T-03-11), read from cache, enqueue writes
- Location: `apps/api/src/routes/`
- Contains: Route handlers (events.ts, me.ts, health.ts, sse.ts); Zod schemas for input validation
- Depends on: Hono framework, Drizzle ORM, @hono/zod-validator, auth middleware
- Used by: PWA frontend (fetch with OIDC cookie), load balancer redirects
- Architecture invariant: Routes **never** import tsdav or call Fastmail directly (T-03-02)
**Database / ORM Layer:**
- Purpose: Type-safe query building, schema definition, migrations
- Location: `apps/api/src/db/`
- Contains: Drizzle schema (users, member_credentials, calendars, calendar_events, calendar_outbox), mysql2 client
- Depends on: mysql2 driver, Drizzle ORM
- Used by: All route handlers, broker modules
**Broker / Background Worker Layer:**
- Purpose: Keep MariaDB calendar cache in sync with Fastmail; drain transactional outbox
- Location: `apps/api/src/broker/`
- Contains: Poller (5-min cron), syncCalendar (REPORT parse), OutboxWorker (15-sec drain), supporting utilities
- Depends on: tsdav (CalDAV client), ical.js (VEVENT parsing), node-cron (scheduling), Drizzle ORM
- Used by: Scheduled background jobs (started in index.ts only when module is main)
- Data sources: member_credentials (encrypted), calendars, calendar_events (cache), calendar_outbox (pending writes)
**Auth / Session Layer:**
- Purpose: OIDC authentication via Authelia, user identity upsert, session cookies
- Location: `apps/api/src/auth/`
- Contains: Middleware (oidcAuthMiddleware, processOAuthCallback from @hono/oidc-auth), upsertUser color assignment, dev bypass
- Depends on: @hono/oidc-auth, Drizzle ORM for user upsert
- Used by: Hono middleware stack, route handlers via getAuth(c) or c.get('user')
## Data Flow
### Primary Request Path (GET /api/events)
1. **Client request** — CalendarShell's eventsQuery fires when meQuery succeeds
2. **OIDC guard** (`apps/api/src/auth/middleware.ts:oidcAuthMiddleware`) — 302-redirect if unauthenticated; session cookie checked
3. **Route handler** (`apps/api/src/routes/events.ts:eventsRouter.get('/')`) — validate start/end dates, resolve userId via getAuth + upsertUser
4. **SQL pre-filter** — Select from calendar_events JOIN calendars JOIN users; WHERE matches:
- Ownership: current user's own calendars OR shared-family calendar (isShared=true)
- Date window: recurring masters (hasRrule=1) OR non-recurring timed (dtstartUtc in range) OR all-day (dtstartDate in range)
5. **Expansion** (`apps/api/src/broker/expand.ts:expandOccurrences`) — For each row, parse rawVevent with ical.js, expand RRULE into occurrences, emit CalendarOccurrence[] with stable IDs
6. **Response** — JSON { occurrences: CalendarOccurrence[] }
7. **Client hydration** (`apps/pwa/src/lib/hydrateEvents.ts`) — Convert occurrence ISO strings to Temporal.ZonedDateTime for Schedule-X
8. **Schedule-X render** — eventsService.set() updates calendar model; re-render with color routing (isShared ? 'shared' : String(ownerUserId))
**State Management:**
- TanStack Query caches result with key ['events', start, end]; staleTime 5 min
- Zustand calendarRange (start/end) drives query key → navigation re-fetches
- SyncStateToast polls `/api/events/sync-status?uid=` to show write-back progress
### Write Path (POST /api/events/create)
1. **User interaction** — EventForm.onSubmit calls POST /api/events/create with CreateEventPayload
2. **OIDC guard** — Session verified
3. **Route validation** (`apps/api/src/routes/events.ts:eventsRouter.post('/create')`) — Zod validates payload (title, start, end, location, description, recurrence)
4. **Calendar ownership check** — If calendarUrl supplied, verify it's owned by currentUser OR isShared; else default to user's first calendar
5. **Outbox enqueue** — INSERT into calendar_outbox with status='pending', operation='create', uid=randomUUID
6. **202 response** — Return immediately with { uid } (optimistic-accept, D-05)
7. **UI toast** — Zustand setLastSyncedUid; SyncStateToast polls sync-status for this uid
8. **Background drain** — OutboxWorker (15-sec cron):
- SELECT outbox WHERE status='pending' AND next_attempt_at <= NOW()
- Decrypt credential from member_credentials
- Call `/broker/write.ts:createCalendarEvent` — builds VEVENT from payload, PUT to Fastmail
- On 2xx: mark done, trigger targeted sync (syncCalendar) to refetch the calendar
- On 412 conflict: mark failed (no retry), trigger sync (UI sees server state)
- On 5xx/408/429: exponential backoff, mark dead after 5 attempts
- On 400/401/403: mark failed immediately
9. **Cache update** — syncCalendar upserts calendar_events from REPORT; GET /api/events now includes the new event
10. **Client refetch** — SyncStateToast sees status='done'; TanStack Query invalidateQueries refetches events
### Calendar Sync (Background Poller → syncCalendar)
1. **Poller fires** — node-cron 5-min schedule calls runPoll()
2. **Load credentials** — SELECT member_credentials; decrypt each app password (T-03-04 — never log plaintext)
3. **Per-credential**: Create tsdav client, PROPFIND to discover calendars
4. **Per-calendar**:
- Look up known ctag from calendar_events join
- If ctag unchanged and not null: SKIP (no DB write, no Fastmail round-trip)
- If ctag changed or null: call syncCalendar
5. **syncCalendar** (`apps/api/src/broker/sync.ts`):
- Upsert calendars row with new ctag/syncToken
- REPORT (calendar-query) → tsdav.fetchCalendarObjects() → array of { data, etag, url }
- For each: Parse with ICAL.parse(), extract VEVENT, build dtstartUtc/dtstartDate per schema contract (D-13)
- Upsert calendar_events with onDuplicateKeyUpdate (idempotency key: calendarId + uid)
- Prune deletes: DELETE events whose uid is no longer on server (BUG B: scope by (userId, url) for shared account)
**Ownership Model (D-03, D-16):**
- Shared Fastmail account: both members' credentials fetch the same calendar collections
- Stored as (userId, url) composite unique key so each member caches the same calendar separately
- eventsRouter ownership check: calendar.userId = currentUserId OR isShared=true (writable set)
- poller lookup: AND(userId, url) to fetch the right member's cached version
## Key Abstractions
**CalendarOccurrence:**
- Purpose: Single concrete event occurrence ready for UI (expanded from RRULE if needed)
- Examples: `apps/api/src/broker/expand.ts:CalendarOccurrence`, `apps/pwa/src/api/client.ts:CalendarOccurrence`
- Pattern: Backend expands RRULE into N occurrences; each has stable id = `${uid}::${dtstart_iso}`, allowing Schedule-X dedup and Zustand.openEventId routing
**Transactional Outbox (D-05):**
- Purpose: Decouple client request (202 response) from Fastmail write (async worker)
- Examples: `apps/api/src/db/schema.ts:calendarOutbox`
- Pattern: Write endpoint INSERTs pending row; worker POLLs and drains; status machine (pending → done/failed/dead) controls retry + backoff
**Wrapped Schema Contract (D-13):**
- Purpose: Guarantee correct DATE vs TIMESTAMP storage for all-day vs timed events
- Examples: `apps/api/src/db/schema.ts` (dtstartUtc, dtstartDate, allDay); `apps/api/src/broker/sync.ts` (storage logic); `apps/api/src/routes/events.ts` (window predicate)
- Pattern: All-day events NEVER coerce to midnight-UTC (Pitfall 2); timed events always UTC; query pre-filters both branches
**RRULE Expansion (D-09):**
- Purpose: Expand recurring masters server-side so client receives concrete occurrences only
- Examples: `apps/api/src/broker/expand.ts:expandOccurrences`, `apps/pwa/src/lib/hydrateEvents.ts` (no expansion on client)
- Pattern: Route calls expandOccurrences for each cached VEVENT; ical.js handles RRULE parsing, EXDATE exclusion, VTIMEZONE DST adjustment
**Encrypted Credentials:**
- Purpose: Store Fastmail app passwords at rest without exposing plaintext
- Examples: `apps/api/src/db/schema.ts:memberCredentials.encryptedPassword`, `apps/api/src/broker/crypto.ts:decryptPassword`
- Pattern: AES-256-GCM with per-message nonce; stored as JSON { iv, authTag, ciphertext }; decrypted only immediately before tsdav client creation (T-03-04)
## Entry Points
**Browser → PWA:**
- Location: `apps/pwa/src/main.tsx` (Vite SPA entry), `apps/pwa/src/App.tsx` (root component = CalendarShell)
- Triggers: User navigates to / (domain root) or clicks Home
- Responsibilities: Hydrate React app, mount CalendarShell, wire TanStack Query + Zustand
**PWA → API:**
- Location: `apps/pwa/src/api/client.ts` (fetch functions)
- Triggers: CalendarShell useQuery hooks on mount and navigation
- Responsibilities: Fetch events, me profile, sync status; handle OIDC redirects via maybeRedirectToLogin
**Unauthenticated User → OIDC:**
- Location: `apps/api/src/auth/middleware.ts` (oidcAuthMiddleware)
- Triggers: Unauthenticated fetch to /api/\* endpoint
- Responsibilities: 302-redirect to Authelia /authorize; await callback at /callback; set session JWT cookie
**OIDC Callback → API Login:**
- Location: `apps/api/src/index.ts:app.get('/callback')` and `apps/api/src/auth/middleware.ts:processOAuthCallback`
- Triggers: Authelia POST to /callback after authorization-code exchange
- Responsibilities: Exchange code for token, validate nonce, set JWT cookie with refresh token, redirect to /api/login
**API Login → SPA Boot:**
- Location: `apps/api/src/index.ts:app.get('/api/login')`
- Triggers: Top-level navigation after callback redirects here (or direct /api/login hit by PWA)
- Responsibilities: Verify session cookie valid, 302-redirect to / so SPA boots authenticated
**Background Poller:**
- Location: `apps/api/src/broker/poller.ts:startBrokerPoller`, called from `apps/api/src/index.ts` in isMainModule() guard
- Triggers: 5-min node-cron schedule starting at API boot
- Responsibilities: Load all credentials, PROPFIND calendars, compare ctag, call syncCalendar if changed
**Outbox Worker:**
- Location: `apps/api/src/broker/outboxWorker.ts:startOutboxWorker`, called from `apps/api/src/index.ts` in isMainModule() guard
- Triggers: 15-sec node-cron schedule starting at API boot
- Responsibilities: Poll outbox WHERE status='pending', drain to Fastmail via write.ts, update status, trigger refetch
## Architectural Constraints
- **Threading:** Single-threaded event loop (Node.js). Broker poller and outbox worker run in the same process; scheduled tasks do not block request handling.
- **Global state:** None in routes (all state passed via c context). Broker modules keep DB client as singleton. tsdav clients created per-credential per-poll (not cached).
- **Circular imports:** None detected. Routes import from routes only; broker imports from db + auth; auth imports from db; no cycles.
- **Request handling:** Synchronous route completion (routes do not wait for broker background tasks). Writes are optimistic-accept (202); client polls for confirmation.
- **Session cookies:** Signed JWT stored in httpOnly cookie; refresh token included in JWT payload; @hono/oidc-auth handles rotation every 15 min by default.
- **Shared Fastmail account:** Both members' credentials fetch the same calendar collections. Ownership tracked per-user via (userId, url) composite key to avoid cross-member cache contamination (BUG B fix).
- **Database transactions:** Explicit tx() used for edit-as-move (D-04) — delete + create pair atomic. All other operations single-statement (upserts via onDuplicateKeyUpdate).
## Anti-Patterns
### Direct Fastmail calls from routes
**What happens:** Routes call tsdav or make fetch requests directly to Fastmail CalDAV endpoints
**Why it's wrong:** Routes would block on network I/O; Fastmail errors would fail the request immediately instead of retrying via outbox; credential decryption happens on every request instead of once per poller cycle; no centralized write ordering (concurrent POSTs can collide)
**Do this instead:** Routes enqueue outbox rows (202) and let broker handle Fastmail I/O. See `apps/api/src/routes/events.ts:eventsRouter.post('/create')` and `apps/api/src/routes/events.ts:eventsRouter.delete('/:uid')` — both INSERT outbox, never call tsdav.
### Storing displayName as identity key
**What happens:** User row lookup is by email or displayName instead of OIDC issuer+subject
**Why it's wrong:** Email changes (user migrates providers); displayName is user-editable and can collide (two Lucases). If Authelia email claim changes mid-login, the user gets a duplicate row.
**Do this instead:** Key by (oidc_iss, oidc_sub) composite, never email. See `apps/api/src/auth/user.ts:upsertUser` — identity lookup is always by (oidcIss, oidcSub), then displayName is updated as a display hint on re-upsert.
### Caching tsdav clients across polls
**What happens:** Broker reuses the same tsdav client instance for multiple credential sessions
**Why it's wrong:** DAVClient maintains HTTP connection state; reusing across credential changes can cross-contaminate requests or leak auth headers.
**Do this instead:** Create a fresh client per credential per poll. See `apps/api/src/broker/poller.ts:runPoll` — each credential iteration calls `createFastmailClient()` fresh.
### Windowed event query without pre-filter for recurring masters
**What happens:** SQL query only selects non-recurring events in the date window; recurring masters are not included
**Why it's wrong:** A weekly meeting created 3 years ago has dtstartUtc < window start, so it's filtered out. But it has RRULE so it has occurrences in the window (RESEARCH.md Pitfall 5).
**Do this instead:** OR-combine three sub-predicates: (1) non-recurring timed in window, (2) non-recurring all-day in window, (3) recurring masters with dtstartUtc < windowEnd. See `apps/api/src/routes/events.ts` lines 173200 for the full predicate.
### Storing all-day events as midnight-UTC datetime
**What happens:** All-day event is stored as '2026-06-01T00:00:00Z' (datetime) instead of '2026-06-01' (date)
**Why it's wrong:** When the viewer is in a different timezone (e.g., UTC-04:00), the date column renders as 2026-05-31 (one day off). Timezone conversion applies to DATETIME but not DATE.
**Do this instead:** Store all-day events in the DATE column only; timed events in TIMESTAMP UTC. See `apps/api/src/db/schema.ts` (dtstartUtc vs dtstartDate) and `apps/api/src/broker/sync.ts` lines 100112 for the schema contract enforcement.
### Relying on 200 response to mean write success
**What happens:** Route marks an event as written and notifies the client success before verifying the outbox row completed
**Why it's wrong:** Client UI state gets out of sync with server; if the outbox worker later fails, the client never knows.
**Do this instead:** Return 202 Accepted immediately, then client polls `/api/events/sync-status?uid=` to track the outbox status. See `apps/api/src/routes/events.ts:eventsRouter.post('/create')` returns 202, and `apps/pwa/src/components/SyncStateToast.tsx` polls until done/failed/dead.
---
_Architecture analysis: 2026-06-09_