Files
familysync/.planning/codebase/ARCHITECTURE.md
T
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

25 KiB
Raw Blame History

Architecture

Analysis Date: 2026-06-09

System Overview

┌─────────────────────────────────────────────────────────────┐
│                   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