Files
familysync/docs/ARCHITECTURE.md
T
Lucas Berger 46bea03757 feat(15-01): fix 13 markdown violations + wire md:lint to fast-checks CI step
- Fix MD040 (11 bare fences): add language tags (text/bash) across 7 files
- Fix MD031 (2 violations): add blank lines around fence in GETTING-STARTED.md
- Wire 'Markdown lint' step to fast-checks job (after Format check, before Typecheck)
- Reformat .markdownlint-cli2.jsonc per Prettier (trailing commas in JSONC)
- pnpm md:lint exits 0; pnpm format:check exits 0; gate can fail on bare fence (verified)
2026-06-12 10:46:01 -04:00

18 KiB

FamilySync Architecture

FamilySync is a self-hosted family organization hub — a unified, color-coded calendar and shared collaborative lists — delivered as a React PWA. The system is a two-container Docker Compose stack (API + MariaDB) running on Unraid, exposed through a Pangolin/Newt tunnel with Authelia providing OIDC authentication.


System Overview

FamilySync follows a layered architecture with Fastmail CalDAV as the external calendar source of truth. The React PWA talks exclusively to a single Hono API backend. The backend handles authentication (Authelia OIDC), calendar read/write via CalDAV (Fastmail), list persistence (MariaDB), and real-time push delivery.

graph TD
    subgraph "Client (Browser / Home Screen PWA)"
        PWA["React 19 PWA\n(Vite + vite-plugin-pwa)"]
        SW["Service Worker\n(Workbox precache + push)"]
    end

    subgraph "API (apps/api — Hono on Node 22)"
        AUTH["OIDC Auth\n(@hono/oidc-auth)"]
        ROUTES["API Routes\n/events /lists /me /push /sse"]
        BROKER["CalDAV Broker\n(tsdav + ical.js + rrule)"]
        OUTBOX["Outbox Worker\n(15s drain loop)"]
        POLLER["CalDAV Poller\n(5-min setInterval)"]
        REMINDER["Reminder Scheduler\n(1-min setInterval)"]
        SSE_LIB["List Emitter\n(in-process EventEmitter)"]
        PUSH_LIB["Push Dispatcher\n(web-push VAPID)"]
    end

    subgraph "Persistence"
        DB["MariaDB 11\n(Drizzle ORM)"]
    end

    subgraph "External Services"
        AUTHELIA["Authelia\n(OIDC / OAuth2 IdP)"]
        FASTMAIL["Fastmail CalDAV\ncaldav.fastmail.com"]
        PUSH_SVC["Browser Push Services\n(APNs / FCM)"]
    end

    PWA -- "HTTPS (same-origin via Pangolin)" --> ROUTES
    SW -- "push events" --> PWA
    ROUTES --> AUTH
    AUTH -- "authorization-code + PKCE" --> AUTHELIA
    ROUTES --> BROKER
    ROUTES --> SSE_LIB
    ROUTES --> DB
    BROKER -- "PROPFIND / REPORT / PUT / DELETE" --> FASTMAIL
    POLLER --> BROKER
    OUTBOX --> BROKER
    BROKER --> DB
    REMINDER --> DB
    REMINDER --> PUSH_LIB
    OUTBOX --> PUSH_LIB
    SSE_LIB -- "text/event-stream" --> PWA
    PUSH_LIB -- "VAPID push" --> PUSH_SVC
    PUSH_SVC --> SW

Directory Structure

familysync/
├── apps/
│   ├── api/                  # Hono backend (Node 22 + TypeScript)
│   │   └── src/
│   │       ├── index.ts      # App entry: mounts routes, starts background workers
│   │       ├── routes/       # HTTP route handlers
│   │       ├── auth/         # OIDC middleware + dev-bypass + session persistence
│   │       ├── broker/       # CalDAV integration layer
│   │       ├── db/           # Drizzle schema, client, migrations
│   │       └── lib/          # Shared dispatchers and utilities
│   └── pwa/                  # React 19 PWA (Vite + vite-plugin-pwa)
│       └── src/
│           ├── App.tsx        # BrowserRouter shell with persistent nav chrome
│           ├── routes/        # Page-level React components
│           ├── components/    # Shared UI components
│           ├── api/           # Typed fetch wrappers (client.ts, listsClient.ts)
│           ├── hooks/         # Custom hooks (useListSSE, usePushSubscription)
│           ├── store/         # Zustand UI-state stores
│           ├── lib/           # Pure utilities (event date/time, login redirect)
│           └── sw.ts          # Custom Workbox service worker
├── docker-compose.yml         # Production stack
└── docker-compose.dev.yml     # Dev overrides (port-binds, volume mounts)

Directory Rationale

Directory Purpose
apps/api/src/routes/ One file per resource — events.ts, lists.ts, me.ts, push.ts, sse.ts, health.ts
apps/api/src/broker/ All CalDAV I/O: client.ts (tsdav factory), sync.ts (REPORT→DB), poller.ts (5-min ctag check), outboxWorker.ts (async write-back), expand.ts (RRULE expansion), write.ts (PUT/DELETE), vevent.ts (ICS builder), crypto.ts (AES-256-GCM for app passwords)
apps/api/src/auth/ middleware.ts (re-exports @hono/oidc-auth), devBypass.ts (DEV_AUTH_BYPASS inject), persistSessionCookie.ts (session lifetime extension), user.ts (upsert on first OIDC login)
apps/api/src/db/ schema.ts (Drizzle mysqlTable definitions), client.ts (mysql2 pool), migrations/ (drizzle-kit output)
apps/api/src/lib/ Stateless helpers: listEmitter.ts (EventEmitter fan-out), listChangeDispatcher.ts, eventChangeDispatcher.ts, pushDispatcher.ts (VAPID send), pushCoalescer.ts, listAccess.ts, rank.ts (fractional indexing)
apps/pwa/src/api/ Thin typed fetch wrappers — client.ts (events, me, sync-status), listsClient.ts (lists and items)
apps/pwa/src/store/ calendarStore.ts and listsStore.ts — Zustand UI-only state (no server data)
apps/pwa/src/hooks/ useListSSE.ts (bounded-backoff EventSource), usePushSubscription.ts (VAPID subscribe)

Key Abstractions

Abstraction File Description
app (Hono) apps/api/src/index.ts Root Hono app; mounts all routes and serves the PWA static build
Drizzle schema apps/api/src/db/schema.ts Single source of truth for all table definitions (users, memberCredentials, calendars, calendarEvents, calendarOutbox, lists, listShares, listItems, pushSubscriptions)
syncCalendar apps/api/src/broker/sync.ts REPORT → ical.js parse → onDuplicateKeyUpdate upsert into MariaDB
expandOccurrences apps/api/src/broker/expand.ts Server-side RRULE expansion using ical.js + rrule; never runs in the browser
CalendarOccurrence apps/api/src/broker/expand.ts Wire type for a single concrete event occurrence; mirrored in the PWA's api/client.ts
calendarOutbox table apps/api/src/db/schema.ts Transactional outbox pattern — CalDAV writes are enqueued here and drained asynchronously
runOutboxDrain apps/api/src/broker/outboxWorker.ts Drains pending outbox rows every 15s; handles retry backoff, 412 conflict, dead-lettering, and edit-as-move ordering
publishListEvent / subscribeListEvents apps/api/src/lib/listEmitter.ts In-process EventEmitter fan-out keyed per list; SSE route subscribes on open and unsubscribes on disconnect
dispatchPush apps/api/src/lib/pushDispatcher.ts Centralised VAPID-signed push sender; handles 410/404 subscription pruning
SessionExpiredError apps/pwa/src/api/client.ts Typed error thrown by all fetch wrappers on 401/opaqueredirect; global QueryCache handler arms the session-expiry interstitial
Zustand stores apps/pwa/src/store/ UI-only ephemeral state (open panels, selected date, active tab); server state always in TanStack Query

Data Flow

Calendar read (typical request)

  1. On mount, the PWA's CalendarShell computes a date window and fires fetchEvents(start, end) via TanStack Query.
  2. GET /api/events?start=&end= hits the Hono eventsRouter.
  3. The route queries calendarEvents + calendars from MariaDB, filtering by the authenticated user's accessible calendars.
  4. Raw rawVevent blobs are passed through expandOccurrences() (ical.js + rrule) to produce concrete CalendarOccurrence objects for the window.
  5. The JSON response is cached by TanStack Query; Schedule-X renders the events.

Calendar write (create/edit/delete)

  1. The PWA calls POST /api/events/create (or PATCH /api/events/:uid/edit / DELETE /api/events/:uid).
  2. The route validates the payload with Zod, assigns a UID, and inserts a pending row into calendarOutbox. Returns 202 Accepted immediately.
  3. Every 15 seconds runOutboxDrain picks up pending rows, decrypts the member's Fastmail app password (AES-256-GCM), and dispatches the CalDAV write via tsdav (PUT/DELETE).
  4. On success, the worker triggers a targeted syncCalendar re-sync for that calendar, then marks the outbox row done.
  5. The PWA's SyncStateToast polls GET /api/events/sync-status?uid= and invalidates the ['events'] TanStack Query cache when status transitions to done.

Calendar background sync (poller)

  1. startBrokerPoller fires every 5 minutes via setInterval.
  2. For each row in member_credentials, the poller decrypts the app password, builds a tsdav client, and runs fetchCalendars() (PROPFIND).
  3. Each calendar's live ctag is compared against the stored value. If unchanged, the calendar is skipped.
  4. On change, syncCalendar fetches all objects (REPORT), parses with ical.js, and upserts calendarEvents rows.
  5. Detected event changes are passed to dispatchEventChange, which sends VAPID push notifications to non-actor members' subscriptions.

Live list sync

  1. When the PWA opens a list, useListSSE opens an EventSource to GET /api/sse/lists with withCredentials: true.
  2. The SSE route resolves the caller's accessible list IDs via getAccessibleListIds, then calls subscribeListEvents(listId, handler) for each one.
  3. When any list is mutated via the REST routes (POST /api/lists, PATCH /api/list-items/:id, etc.), the route calls publishListEvent(listId, event).
  4. The in-process EventEmitter fans the event out to all active subscribeListEvents handlers, which write it to the SSE stream.
  5. The PWA's SSE handler calls queryClient.invalidateQueries(['list', listId]), triggering a full refetch.
  6. A 30-second polling fallback (refetchInterval: 30000) is always active in ListDetail as a safety net.

Authentication

  1. An unauthenticated browser navigates to /api/login.
  2. The oidcAuthMiddleware (@hono/oidc-auth) issues a 302 to Authelia's /authorize endpoint with PKCE (S256).
  3. After login, Authelia posts the authorization code to /callback; processOAuthCallback exchanges it for tokens and issues a signed JWT session cookie.
  4. persistSessionCookie middleware re-issues the cookie as persistent on every authenticated response so the PWA session survives browser close.
  5. All /api/* routes require the session cookie; a missing or expired session returns a 302 which the PWA's fetch wrappers detect as opaqueredirect and convert to a SessionExpiredError.

Component Interaction

Backend modules

routes/events.ts  ──→  broker/expand.ts     (read: RRULE expansion)
                  ──→  calendarOutbox (DB)   (write: enqueue)
                  ──→  broker/sync.ts        (write-sync after outbox drain)

broker/outboxWorker.ts  ──→  broker/write.ts      (CalDAV PUT/DELETE)
                        ──→  broker/sync.ts        (targeted re-sync)
                        ──→  lib/eventChangeDispatcher.ts → lib/pushDispatcher.ts

broker/poller.ts  ──→  broker/sync.ts
                  ──→  lib/eventChangeDispatcher.ts

routes/lists.ts   ──→  db (MariaDB)
                  ──→  lib/listEmitter.ts  (publishListEvent)

routes/sse.ts     ──→  lib/listEmitter.ts  (subscribeListEvents)
                  ──→  lib/listAccess.ts

Frontend data ownership

Concern Owner
Calendar events TanStack Query ['events', start, end]
List data + items TanStack Query ['lists'], ['list', listId]
Current user TanStack Query ['me']
Writable calendars TanStack Query ['writableCalendars']
Outbox sync status TanStack Query ['syncStatus', uid]
Selected calendar view + date Zustand calendarStore
Event form open/mode Zustand calendarStore
Active tab, create-list sheet Zustand listsStore

Infrastructure

Component Technology
Runtime Node.js 22 LTS
HTTP framework Hono 4.x (@hono/node-server)
Database MariaDB 11 (Docker volume)
ORM Drizzle ORM 0.45.x (mysql2 dialect)
Auth IdP Authelia (pre-deployed, external) — OIDC authorization code + PKCE
Session middleware @hono/oidc-auth — storage-less signed JWT cookies
Calendar source Fastmail CalDAV (caldav.fastmail.com) — read via tsdav, write via transactional outbox
Calendar parsing ical.js (VCALENDAR/VEVENT parse) + rrule (RRULE expansion)
App password storage AES-256-GCM encrypted in member_credentials.encrypted_password
Push notifications web-push (VAPID) → APNs (iOS) / FCM (Android)
Live list sync In-process Node.js EventEmitter → SSE (text/event-stream)
Redis Present in stack (image: redis:7-alpine); not used in current runtime (reserved for future multi-process pub/sub)
PWA React 19 + Vite 8 + vite-plugin-pwa (Workbox injectManifest mode)
Networking Pangolin/Newt tunnel — no open ports; split-DNS internal domain
Deployment Docker Compose on Unraid; single api container serves both the API and the PWA static build