Files
familysync/.planning/intel/arch-decisions.json
T
Lucas Berger d521839a40 docs(intel): refresh codebase intelligence at 99f59c3 — capture Phase 4 lists + live-sync
Stale intel reflected commit 01f7456 (pre-Phase-4). Refresh adds the shared-lists
+ SSE subsystem: 21 API endpoints (was 10), lists/sse routes, listEmitter/listAccess/rank
libs, PWA lists surface, 7 new arch decisions, and Phase 4 deps (react-router, @dnd-kit,
fractional-indexing). Canonical filenames preserved; API-SURFACE.md + .last-refresh.json regenerated.
2026-06-09 14:57:27 -04:00

161 lines
7.9 KiB
JSON

{
"_meta": {
"updated_at": "2026-06-09T18:56:37.788Z",
"commit": "99f59c3999f4ef992f01ef8f8a38c1d86d2f2a0f",
"version": 3
},
"entries": {
"broker-cache-api-pattern": {
"title": "Broker-Cache-API pattern (two planes never cross)",
"decision": "Backend split into a broker plane (apps/api/src/broker/) that owns all Fastmail I/O and an API plane (apps/api/src/routes/) that reads only from MariaDB. Broker crons are not reachable from the HTTP layer.",
"files": [
"apps/api/src/broker/poller.ts",
"apps/api/src/broker/outboxWorker.ts",
"apps/api/src/routes/"
]
},
"write-broker-boundary": {
"title": "Write-broker boundary invariant",
"decision": "No route file imports tsdav or createFastmailClient; no broker file handles HTTP requests. Routes enqueue calendar_outbox rows and return 202 (optimistic-accept); the outbox worker performs the Fastmail write asynchronously.",
"files": [
"apps/api/src/routes/events.ts",
"apps/api/src/broker/write.ts"
]
},
"identity-keying": {
"title": "Identity keyed on oidc_iss + oidc_sub",
"decision": "Users are keyed on oidc_iss + oidc_sub (never email). A hex color from the palette is auto-assigned on first login.",
"files": [
"apps/api/src/routes/me.ts",
"apps/api/src/auth/middleware.ts"
]
},
"D-03-writable-set": {
"title": "D-03 calendar ownership / writable-set predicate",
"decision": "Every writable-set query uses WHERE userId = currentUser.id OR isShared = true. Another member's personal calendar is a read-only overlay.",
"files": [
"apps/api/src/routes/events.ts"
]
},
"D-13-dual-field-dtstart": {
"title": "D-13 all-day vs timed events (dual dtstart fields)",
"decision": "dtstart_utc is NULL for all-day events; dtstart_date is NULL for timed events. Never coerce DATE to DATETIME.",
"files": [
"apps/api/src/db/schema.ts"
]
},
"D-16-shared-fastmail-account": {
"title": "D-16 shared Fastmail account, per-member credentials",
"decision": "Both members share one Fastmail account. Calendar identity in DB is (userId, url) — the same collection URL appears once per member credential. CalDAV credential per member is stored AES-256-GCM encrypted in member_credentials.",
"files": [
"apps/api/src/db/schema.ts",
"apps/api/src/broker/poller.ts"
]
},
"outbox-status-machine": {
"title": "Outbox status machine",
"decision": "calendar_outbox rows transition pending -> done | failed | dead. failed rows retry up to a limit; dead is terminal. The sync-status endpoint surfaces worst-status-first per uid.",
"files": [
"apps/api/src/broker/outboxWorker.ts",
"apps/api/src/routes/events.ts"
]
},
"oidc-behind-pangolin": {
"title": "OIDC behind Pangolin requires OIDC_AUTH_EXTERNAL_URL",
"decision": "OIDC_AUTH_EXTERNAL_URL must be set to the public HTTPS URL to construct a correct redirect_uri; without it the callback resolves to the internal container address.",
"files": [
"apps/api/src/auth/middleware.ts",
"apps/api/src/index.ts"
]
},
"dev-auth-bypass": {
"title": "Dev auth bypass",
"decision": "DEV_AUTH_BYPASS=true with NODE_ENV!=production injects DEV_USER via Hono context; OIDC middleware is never mounted in this mode.",
"files": [
"apps/api/src/auth/devBypass.js",
"apps/api/src/index.ts"
]
},
"pwa-static-serving": {
"title": "PWA static serving + SPA fallback",
"decision": "Hono serveStatic serves ./public (Vite build output); SPA routes fall through to an index.html catch-all registered after /health, /api/*, and /callback so those win.",
"files": [
"apps/api/src/index.ts"
]
},
"schedule-x-routing": {
"title": "Schedule-X calendar routing",
"decision": "Events are routed to Schedule-X calendars by isShared ? 'shared' : String(ownerUserId) — never by calendarId. hydrateEvents.ts enforces this.",
"files": [
"apps/pwa/src/lib/hydrateEvents.ts",
"apps/pwa/src/components/CalendarShell.tsx"
]
},
"state-ownership": {
"title": "Client state ownership split",
"decision": "Server state is owned by TanStack Query; UI-only state (selected range, color map, drawer) by Zustand. Schedule-X renders the calendar UI.",
"files": [
"apps/pwa/src/store/calendarStore.ts",
"apps/pwa/src/components/CalendarShell.tsx"
]
},
"lists-storage-mariadb-not-caldav": {
"title": "Lists stored in MariaDB, not CalDAV (Phase 4)",
"decision": "Named lists and items are app-owned data in MariaDB (lists, list_items, list_shares tables), not pushed to Fastmail. CalDAV is exclusively for calendar events.",
"files": [
"apps/api/src/db/schema.ts",
"apps/api/src/routes/lists.ts"
]
},
"D-01-D-02-list-sharing": {
"title": "D-01/D-02 list sharing via join table (member-count-agnostic)",
"decision": "isShared=true (default) triggers auto-insert of list_shares rows for all other users at create/patch time. Shares are server-managed only — no client-writable shares endpoint (T-04-08). list_shares join table is member-count-agnostic for future N-member expansion.",
"files": [
"apps/api/src/routes/lists.ts",
"apps/api/src/db/schema.ts"
]
},
"D-04-scoped-sse-fan-out": {
"title": "D-04 scoped SSE fan-out — per-list channels, not global",
"decision": "GET /api/sse/lists resolves the caller's accessible list IDs via getAccessibleListIds, then subscribes one listEmitter channel per ID. Private lists of other members are never delivered. In-memory EventEmitter singleton (D-18) — no Redis; single-process, no replicas.",
"files": [
"apps/api/src/routes/sse.ts",
"apps/api/src/lib/listEmitter.ts",
"apps/api/src/lib/listAccess.ts"
]
},
"D-08-per-field-lww-patch": {
"title": "D-08 per-field last-write-wins PATCH for list items",
"decision": "PATCH /api/list-items/:itemId accepts exactly one field (checked | text | position). Zod enforces single-field constraint. Prevents one client's stale read overwriting concurrent updates to other fields.",
"files": [
"apps/api/src/routes/lists.ts"
]
},
"D-13-fractional-rank": {
"title": "D-13 fractional-indexing rank for list item ordering",
"decision": "list_items.rank is a varchar(255) COLLATE utf8mb4_bin using fractional-indexing strings. A single drag-reorder writes only the moved item's rank (one-row write). utf8mb4_bin collation required so uppercase-prefixed ranks (e.g. 'Zz') sort before lowercase (e.g. 'a0'), matching JS string order.",
"files": [
"apps/api/src/db/schema.ts",
"apps/api/src/lib/rank.ts",
"apps/pwa/src/routes/ListDetail.tsx"
]
},
"D-10-D-11-D-12-sse-resilience": {
"title": "D-10/D-11/D-12 SSE resilience: invalidate-not-patch, bounded backoff, polling fallback",
"decision": "D-10: SSE events carry minimal { type, listId } payload; client full-refetches via TanStack Query invalidation rather than patching cache from event payload. D-11: useListSSE implements bounded backoff (250ms→8s cap, MAX_ATTEMPTS then give-up). D-12: 30s polling fallback always active in ListDetail as safety net.",
"files": [
"apps/pwa/src/hooks/useListSSE.ts",
"apps/pwa/src/routes/ListDetail.tsx"
]
},
"react-router-spa-shell": {
"title": "react-router BrowserRouter SPA shell with BottomTabBar",
"decision": "App.tsx wraps routes in BrowserRouter with declarative Routes. BottomTabBar is a sibling of Routes (not inside) so it persists across navigation. SW navigateFallback covers /lists/* deep-links.",
"files": [
"apps/pwa/src/App.tsx",
"apps/pwa/src/components/BottomTabBar.tsx"
]
}
}
}