From bc45bddac9f7177448afb977c14d1faa895014f6 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Tue, 9 Jun 2026 10:01:47 -0400 Subject: [PATCH] =?UTF-8?q?docs(04):=20research=20phase=20=E2=80=94=20list?= =?UTF-8?q?s=20schema,=20SSE=20fan-out,=20fractional-indexing,=20dnd-kit,?= =?UTF-8?q?=20react-router?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../04-shared-lists-live-sync/04-RESEARCH.md | 1167 +++++++++++++++++ 1 file changed, 1167 insertions(+) create mode 100644 .planning/phases/04-shared-lists-live-sync/04-RESEARCH.md diff --git a/.planning/phases/04-shared-lists-live-sync/04-RESEARCH.md b/.planning/phases/04-shared-lists-live-sync/04-RESEARCH.md new file mode 100644 index 0000000..ba8bc1f --- /dev/null +++ b/.planning/phases/04-shared-lists-live-sync/04-RESEARCH.md @@ -0,0 +1,1167 @@ +# Phase 4: Shared Lists + Live Sync — Research + +**Researched:** 2026-06-09 +**Domain:** Collaborative lists with real-time SSE sync, fractional-indexing reorder, React Router v7 routing, dnd-kit drag-and-drop, MariaDB schema migration +**Confidence:** HIGH + +--- + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +- **D-01:** Lists default to Shared; creator can toggle a list to Private. +- **D-02:** `list_shares` join table — member-count-agnostic. NOT a boolean flag. +- **D-03:** Private list live-syncs across owner's own devices only; never to other members. +- **D-04:** SSE fan-out MUST be scoped to members with access (owner + shares). Never all clients. +- **D-05:** Checked items sink to "completed" section at bottom; active items stay on top. +- **D-06:** Confirm-on-delete for whole lists only. Items delete instantly. Reuse `DeleteConfirmationDialog`. +- **D-07:** Optimistic UI — editing member's change shows instantly, reconciles on server confirm. Use React Query optimistic updates. +- **D-08:** Per-field writes + per-field last-write-wins. PATCH only the changed field. No CRDTs. +- **D-09:** Delete-wins. In-flight edits are dropped if item was deleted. +- **D-10:** Full refetch on SSE reconnect. No Last-Event-ID replay. +- **D-11:** Silent bounded-backoff reconnect (250ms→500ms→1s→2s→4s→cap 8s). After 6 failed attempts: show "Updates paused" indicator. Stop retrying. +- **D-12:** React Query `refetchInterval: 30000` polling fallback when SSE disconnects. +- **D-13:** String-based fractional rank for positions. NOT floats. NOT integer renumber. +- **D-14:** Animate remote reorders (CSS transition 150ms ease-out). No hard snap. +- **D-15:** Last-write-wins on concurrent reorder of same item. No drag-state broadcasting. +- **D-16:** Bottom tab bar: Calendar | Lists. Thumb-reachable, matches native iOS/Android. +- **D-17:** Add react-router. Real URLs `/lists/:listId`. Enables Phase 5 push deep-links. +- **D-18:** Design for N family members. Schema, auth, SSE fan-out must be member-count-agnostic. +- **Schema constraint:** drizzle-kit generate + migrate — NEVER push on MariaDB (unsafe on populated DB). + +### Claude's Discretion + +- Fan-out mechanism: in-memory EventEmitter vs Redis pub/sub. API is a single Node process today; ioredis NOT installed but redis in docker-compose. +- Exact position-rank datatype/column. +- SSE auth/middleware wiring. +- React Query cache-key structure. + +### Deferred Ideas (OUT OF SCOPE) + +- Anonymous list sharing via unique public URL. +- Granular per-recipient sharing UI (member picker). +- List metadata: icons, per-list colors, max items. + + + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| LIST-01 | User can create and delete named lists | Schema (lists table), REST API (POST /api/lists, DELETE /api/lists/:id), ListsIndex + CreateListSheet UI, DeleteConfirmationDialog reuse | +| LIST-02 | User can add items, check them off, delete them | Schema (list_items table), REST API (POST /api/list-items, PATCH /api/list-items/:id, DELETE /api/list-items/:id), ItemRow + AddItemInput UI, optimistic updates | +| LIST-03 | User can reorder items by drag-and-drop | fractional-indexing for rank, @dnd-kit/core + @dnd-kit/sortable, PATCH /api/list-items/:id with position field | +| LIST-04 | Both members' edits appear live without refresh | Scoped SSE fan-out (EventEmitter + per-list rooms), React Query invalidateQueries on event, bounded-backoff EventSource wrapper | + + + +--- + +## Summary + +Phase 4 adds two fully independent capabilities to the existing Hono + MariaDB + React PWA stack: (1) CRUD for named shared lists with items, stored app-natively in MariaDB; and (2) real-time co-edit sync delivered over SSE. The lists track is independent of the CalDAV write path — no Fastmail involvement. + +The SSE entry gate (Pangolin idle-timeout smoke test, D-14 / issue #1034) PASSED on 2026-06-08. A 6-minute test over `familysync-dev.bergerhouse.net` delivered 35 heartbeats with no cut and confirmed incremental delivery (buffering OFF). Live sync can be built on SSE without fallback being mandatory; the polling fallback (D-12) remains as belt-and-suspenders for edge cases. + +The three technically novel sub-problems are: (a) scoped SSE fan-out where events only reach members with access to a specific list; (b) fractional-indexing for O(1) single-row reorder writes that play well with live sync; and (c) bounded-backoff EventSource reconnect, since the browser's native `EventSource` reconnects forever with no backoff control. + +**Primary recommendation:** Use an in-memory EventEmitter for fan-out (single process, YAGNI), fractional-indexing v3.2.0 for rank strings (rocicorp, established, 1M/wk downloads), @dnd-kit/core + @dnd-kit/sortable for drag-and-drop (17M/wk, React 19-compatible), and react-router v7.x for routing. Add migrate-on-startup via drizzle-kit generate+migrate workflow. + +--- + +## Entry Gate Status: CLEARED + +**SSE-over-Pangolin smoke test:** PASS (2026-06-08) + +Confirmed in `.planning/phases/01-foundation-broker-spike/01-HUMAN-UAT.md`: +> "GET /api/sse/heartbeat over familysync-dev.bergerhouse.net (Pangolin→Newt→api) with a valid session cookie held open ~6 min (01:37:53Z→01:43:54Z), 35 heartbeat events id 0→34 at ~10s cadence; response bytes grew 71→2535 (incremental delivery → Pangolin buffering OFF); no early cut." + +Also confirmed in `STATE.md` Blockers/Concerns: +> "Phase 4 ENTRY GATE: Pangolin SSE pass-through (issue #1034) unverified — CLEARED 2026-06-08" + +**Implication:** Live sync can be built directly on SSE. The polling fallback (D-12, `refetchInterval: 30000`) is belt-and-suspenders, not mandatory — but implement it anyway per D-12 since React Query makes it trivial. [VERIFIED: .planning/phases/01-foundation-broker-spike/01-HUMAN-UAT.md] + +--- + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| List/item CRUD persistence | API / DB | — | App-native data; MariaDB is sole source of truth | +| Sharing access control | API | — | Server enforces list_shares membership; never trust client claim | +| SSE fan-out (live sync) | API | — | Node.js EventEmitter in same process as route handlers | +| Fractional rank computation | API | Frontend | Server is authoritative; frontend computes optimistic rank for immediate UI | +| Drag-and-drop UX | Browser/Client | — | @dnd-kit runs entirely in the browser | +| Bounded-backoff reconnect | Browser/Client | — | EventSource wrapper lives in the PWA | +| Optimistic UI + rollback | Browser/Client | — | React Query onMutate/onError pattern, client-side only | +| Polling fallback | Browser/Client | — | React Query refetchInterval, client-side only | +| Routing (tab bar, /lists/:id) | Browser/Client | — | react-router BrowserRouter in App.tsx | +| Schema migration | DB (operator) | API (startup hook) | drizzle-kit generate+migrate; can auto-run on container start | + +--- + +## Standard Stack + +### Core (already installed — no new installs needed) + +| Library | Version | Purpose | Status | +|---------|---------|---------|--------| +| Hono | 4.12.23 | HTTP framework + streamSSE | Already in `apps/api/package.json` | +| Drizzle ORM + mysql2 | 0.45.2 + 3.22.4 | MariaDB query layer | Already installed | +| drizzle-kit | 0.31.10 | Schema migration (generate+migrate) | Already in devDependencies | +| @tanstack/react-query | 5.101.0 | Server state, optimistic updates, polling | Already in `apps/pwa/package.json` | +| zustand | 5.0.14 | UI-only state (active tab, sheet open) | Already installed | +| zod + @hono/zod-validator | ^3.25.0 + 0.8.0 | Request validation | Already installed | +| lucide-react | 1.17.0 | Icons (GripVertical, ChevronLeft, etc.) | Already installed | + +### New Dependencies (must install) + +| Library | Version | Ecosystem | Purpose | Verdict | +|---------|---------|-----------|---------|---------| +| `react-router` | 7.17.0 | npm | SPA routing (D-17) | SUS (too-new version, but package is legitimate — 47M/wk, est. 2014) | +| `@dnd-kit/core` | 6.3.1 | npm | Drag-and-drop core | OK | +| `@dnd-kit/sortable` | 10.0.0 | npm | Sortable list abstraction | OK | +| `fractional-indexing` | 3.2.0 | npm | String rank generation (D-13) | OK | + +**Installation:** +```bash +# PWA +pnpm --filter @familysync/pwa add react-router@7 @dnd-kit/core @dnd-kit/sortable fractional-indexing + +# API — fractional-indexing also needed server-side for rank generation on PATCH +pnpm --filter @familysync/api add fractional-indexing +``` + +**Peer dependency notes:** +- `@dnd-kit/core` requires React >=16.8.0 — compatible with React 19. [VERIFIED: npm registry] +- `@dnd-kit/sortable` requires `@dnd-kit/core ^6.3.0` and React >=16.8.0 — both satisfied. [VERIFIED: npm registry] +- `react-router` v7 requires React >=18 and react-dom >=18 — compatible with React 19. [VERIFIED: npm registry] + +--- + +## Package Legitimacy Audit + +| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition | +|---------|----------|-----|-----------|-------------|---------|-------------| +| `@dnd-kit/core` | npm | ~4 yrs | 17M/wk | github.com/clauderic/dnd-kit | OK | Approved | +| `@dnd-kit/sortable` | npm | ~4 yrs | 16.9M/wk | github.com/clauderic/dnd-kit | OK | Approved | +| `react-router` | npm | ~12 yrs (est. 2014) | 47.5M/wk | github.com/remix-run/react-router | SUS (version 7.17.0 published 2026-06-04 — flagged "too-new" by seam; package itself is the canonical React Router) | Approved — seam flags the recent version publish, not the package identity. Package has 12 years of history, 47M weekly downloads. Planner should add a note but NOT a human-verify checkpoint for this established package. | +| `fractional-indexing` | npm | ~3 yrs | 1M/wk | github.com/rocicorp/fractional-indexing | OK | Approved | + +**Packages removed due to SLOP verdict:** none + +**Packages flagged as suspicious (SUS):** `react-router` — seam flagged version 7.17.0 as "too-new" (published 2026-06-04, 5 days before research). The underlying package is the canonical React Router by Remix/Shopify with 12 years of history and 47M weekly downloads. This is a false positive from the version-recency signal. No human checkpoint required. + +--- + +## Architecture Patterns + +### System Architecture Diagram + +``` +Browser PWA (React 19) +│ +├─ react-router BrowserRouter +│ ├─ / → CalendarShell (existing) +│ ├─ /lists → ListsIndex +│ └─ /lists/:listId → ListDetail +│ └─ SSEConnection hook (EventSource wrapper) +│ ├─ backoff: 250→500→1000→2000→4000→cap 8000ms +│ └─ on-event: queryClient.invalidateQueries(['list', listId]) +│ +├─ TanStack Query (server state) +│ ├─ ['lists'] → GET /api/lists +│ ├─ ['list', listId] → GET /api/lists/:id/items +│ └─ mutations → POST/PATCH/DELETE with onMutate optimistic + onError rollback +│ +└─ Zustand (UI state only) + ├─ activeTab: 'calendar' | 'lists' + └─ createListSheetOpen, etc. + +Hono API (Node.js 22) +│ +├─ GET /api/lists → lists owned by or shared with currentUser +├─ POST /api/lists → create list + list_shares row if shared +├─ PATCH /api/lists/:id → update name/visibility +├─ DELETE /api/lists/:id → delete list + cascade items+shares +│ +├─ GET /api/lists/:id/items → items for list (access-gated) +├─ POST /api/lists/:id/items → add item (generates fractional rank) +├─ PATCH /api/list-items/:id → update field (checked/text/position) — per-field LWW +├─ DELETE /api/list-items/:id → delete item (delete-wins) +│ +├─ GET /api/sse/lists → SSE stream scoped to user's accessible lists +│ └─ on write: listEventEmitter.emit(`list:${listId}`, event) +│ listEventEmitter subscribers filter by user access +│ +└─ listEventEmitter (in-memory EventEmitter, module-level singleton) + └─ rooms: Map> — computed on subscriber join + +MariaDB +├─ lists (id, owner_id, name, is_shared, created_at, updated_at) +├─ list_items (id, list_id, text, checked, rank VARCHAR(255), created_at, updated_at) +└─ list_shares (id, list_id, user_id, created_at) ← join table; member-count-agnostic +``` + +### Recommended Project Structure + +``` +apps/api/src/ +├─ routes/ +│ ├─ lists.ts # CRUD for lists + items; fan-out trigger +│ └─ sse.ts # add /lists SSE endpoint (extend existing sseRouter) +├─ lib/ +│ └─ listEmitter.ts # module-level EventEmitter singleton + room management +└─ db/ + ├─ schema.ts # add lists, list_items, list_shares tables + └─ migrations/ + └─ 0002_lists_schema.sql # generated by drizzle-kit generate + +apps/pwa/src/ +├─ App.tsx # add BrowserRouter + BottomTabBar +├─ routes/ # new directory for route-level components +│ ├─ ListsIndex.tsx +│ └─ ListDetail.tsx +├─ components/ +│ ├─ BottomTabBar.tsx +│ ├─ ListCard.tsx +│ ├─ ItemRow.tsx +│ ├─ AddItemInput.tsx +│ ├─ CreateListSheet.tsx +│ ├─ LiveSyncIndicator.tsx +│ └─ ListsEmptyState.tsx +├─ hooks/ +│ └─ useListSSE.ts # EventSource wrapper with bounded backoff +├─ api/ +│ └─ listsClient.ts # typed fetch functions for lists/items API +└─ store/ + └─ listsStore.ts # Zustand store for lists UI state only +``` + +--- + +## Research Finding 1: SSE Fan-out Mechanism + +### Recommendation: In-Memory EventEmitter (YAGNI — single process today) + +**Rationale:** + +The API runs as a single Node.js process (no replicas, no horizontal scaling in the Unraid/Docker setup). Redis pub/sub adds a network hop, a new npm dependency (`ioredis`), and operational overhead for zero benefit when all SSE connections share the same process. `ioredis` is NOT installed today. + +The D-18 "design for N members" concern is valid but does NOT require Redis in Phase 4. The correct abstraction is to isolate the fan-out behind a module-level interface (`listEmitter.ts`) with a clean `publish(listId, event)` / `subscribe(listId, handler)` API. When the architecture ever requires Redis (multiple API replicas), the implementation of `listEmitter.ts` changes — callers do not. [ASSUMED — architectural judgment; no tool-verified benchmarks for this specific topology] + +**In-memory implementation:** + +```typescript +// apps/api/src/lib/listEmitter.ts +// Source: Node.js EventEmitter docs + established SSE fan-out pattern + +import { EventEmitter } from 'node:events' + +// Module-level singleton — one emitter shared across all route handlers +// in this Node.js process. +const emitter = new EventEmitter() +emitter.setMaxListeners(200) // 100 members × 2 devices, generous headroom + +export type ListEvent = { + type: 'item:added' | 'item:updated' | 'item:deleted' | 'list:updated' | 'list:deleted' + listId: number + payload: unknown +} + +/** Broadcast an event to all SSE subscribers watching this list. */ +export function publishListEvent(listId: number, event: ListEvent): void { + emitter.emit(`list:${listId}`, event) +} + +/** Subscribe to events for a specific list. Returns an unsubscribe function. */ +export function subscribeListEvents( + listId: number, + handler: (event: ListEvent) => void, +): () => void { + const channel = `list:${listId}` + emitter.on(channel, handler) + return () => emitter.off(channel, handler) +} +``` + +**Scoped fan-out (D-04 — critical):** + +The SSE endpoint receives the user's identity (via `resolveUserId`), queries `list_shares` for all lists the user can access, subscribes to each of those `list:N` channels, and forwards events to the client stream. New list shares are picked up on reconnect (full refetch on reconnect per D-10 is the reconciliation point). + +```typescript +// apps/api/src/routes/sse.ts — add to existing sseRouter +sseRouter.get('/lists', async (c) => { + const userId = await resolveUserId(c) + if (!userId) return c.json({ error: 'unauthorized' }, 401) + + // Fetch all list IDs this user can see (owner OR in list_shares) + const accessibleListIds = await getAccessibleListIds(userId) // DB query + + return streamSSE(c, async (stream) => { + const unsubscribers: Array<() => void> = [] + + for (const listId of accessibleListIds) { + const unsub = subscribeListEvents(listId, async (event) => { + if (stream.aborted) return + await stream.writeSSE({ + data: JSON.stringify(event), + event: event.type, + id: `${listId}-${Date.now()}`, + }) + }) + unsubscribers.push(unsub) + } + + // Heartbeat to keep Pangolin connection alive (proven in smoke test) + let tick = 0 + while (!stream.aborted) { + await stream.writeSSE({ + data: JSON.stringify({ ts: new Date().toISOString() }), + event: 'heartbeat', + id: String(tick++), + }) + await stream.sleep(30_000) + } + + // Cleanup on disconnect + unsubscribers.forEach((unsub) => unsub()) + }) +}) +``` + +**Why this satisfies D-18 (N-member agnostic):** + +- `list_shares` join table means any number of members can be authorized per list. +- Fan-out loop iterates over all accessible list IDs — no hard-coded member count. +- The EventEmitter channel is keyed by `listId`, not by user pairing. + +**Redis migration path (when needed):** + +Replace `listEmitter.ts` internals with `ioredis.publish` / `ioredis.subscribe`. Callers (`lists.ts` route, `sse.ts`) do not change. [ASSUMED — standard adapter pattern] + +--- + +## Research Finding 2: Fractional Indexing for Reorder (D-13) + +### Library: `fractional-indexing` v3.2.0 by Rocicorp + +**Source:** github.com/rocicorp/fractional-indexing — the reference implementation used by Linear, Notion, and Figma for stable drag-drop ordering. [VERIFIED: npm registry + official README] + +**API:** +```typescript +import { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing' + +// Insert at beginning (before first item) +const rank = generateKeyBetween(null, firstItem.rank) // → "a0" + +// Insert at end (after last item) +const rank = generateKeyBetween(lastItem.rank, null) // → "a1" + +// Insert between two items +const rank = generateKeyBetween(itemA.rank, itemB.rank) // → "a0V" + +// Batch insert N items (e.g., initial seeding) +const ranks = generateNKeysBetween(null, null, 5) // → ["a0", "a1", "a2", "a3", "a4"] +``` + +**Column datatype:** +```sql +rank VARCHAR(255) NOT NULL DEFAULT 'a0' +``` +- VARCHAR(255) is generous; typical fractional-indexing strings are 2-10 chars even after hundreds of reorders between the same two items. Only pathological "zipper" inserts (always inserting at the same mid-point) approach long strings, and `generateNKeysBetween` can rebalance. +- Indexed: `INDEX idx_list_items_rank (list_id, rank)` for ORDER BY efficiency. + +**A single move = one-row write:** +```sql +UPDATE list_items SET rank = ? WHERE id = ? +-- Only the dragged item's row is updated. +-- Items above/below are untouched. +``` + +**Concurrent reorder convergence (D-15):** +- Both members drag simultaneously → each PATCH sends a new rank computed from their local view. +- Server applies last-write-wins per the `updatedAt` column (MariaDB `DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`). +- Each member receives an SSE event → React Query refetches → both converge to the last server state within ~1 RTT. No drift. +- This matches D-15 exactly: "Last-write-wins with brief settle." + +**Ordering query:** +```sql +SELECT * FROM list_items +WHERE list_id = ? AND checked = false +ORDER BY rank ASC +``` + +--- + +## Research Finding 3: Scoped SSE Fan-out (D-04) + +**Load-bearing correctness requirement:** A private list must NEVER broadcast events to members who are not the owner. A shared list broadcasts to owner + all `list_shares` recipients. + +**Pattern (expand on Finding 1):** + +```typescript +// DB query used at SSE connection time +async function getAccessibleListIds(userId: number): Promise { + const owned = await db.select({ id: lists.id }) + .from(lists) + .where(eq(lists.ownerId, userId)) + + const shared = await db.select({ listId: listShares.listId }) + .from(listShares) + .where(eq(listShares.userId, userId)) + + return [ + ...owned.map((r) => r.id), + ...shared.map((r) => r.listId), + ] +} +``` + +**When a new list is shared with a user mid-session:** The subscriber set is computed once at SSE connection time. The new share is visible after the user's SSE reconnects. Since D-10 specifies full refetch on reconnect, this is acceptable for Phase 4. (Phase 5 push or a future "sharing added" SSE event type could close this gap if needed.) + +**Security invariant in REST layer:** +- `GET /api/lists` → `WHERE owner_id = ? OR id IN (SELECT list_id FROM list_shares WHERE user_id = ?)` — server enforces, never trust client. +- All item mutations verify list access before writing. + +--- + +## Research Finding 4: EventSource Backoff Wrapper (D-11) + +The browser's native `EventSource` auto-reconnects forever with no backoff control. The browser uses a fixed 3-second retry interval (from the `retry:` SSE field) or defaults to ~3s. This violates D-11 which requires bounded exponential backoff + a give-up indicator. + +**Pattern: Manual reconnect loop hook** + +```typescript +// apps/pwa/src/hooks/useListSSE.ts +// Source: D-11 CONTEXT.md specification + established EventSource wrapper pattern [ASSUMED] + +import { useEffect, useRef, useCallback } from 'react' +import { useQueryClient } from '@tanstack/react-query' + +type SyncState = 'connected' | 'reconnecting' | 'disconnected' + +const BACKOFF_STEPS_MS = [250, 500, 1000, 2000, 4000, 8000] // cap at 8000ms +const MAX_ATTEMPTS = BACKOFF_STEPS_MS.length + +interface UseListSSEOptions { + listId: number + onStateChange: (state: SyncState) => void +} + +export function useListSSE({ listId, onStateChange }: UseListSSEOptions) { + const queryClient = useQueryClient() + const esRef = useRef(null) + const attemptsRef = useRef(0) + const timerRef = useRef | null>(null) + + const connect = useCallback(() => { + // Close any existing connection + esRef.current?.close() + + const es = new EventSource('/api/sse/lists', { withCredentials: true }) + esRef.current = es + + es.addEventListener('item:added', handleListChange) + es.addEventListener('item:updated', handleListChange) + es.addEventListener('item:deleted', handleListChange) + es.addEventListener('list:updated', handleListChange) + + es.onopen = () => { + attemptsRef.current = 0 + onStateChange('connected') + // Full refetch on reconnect (D-10) + queryClient.invalidateQueries({ queryKey: ['list', listId] }) + } + + es.onerror = () => { + es.close() + const attempt = attemptsRef.current + if (attempt >= MAX_ATTEMPTS) { + // Backoff exhausted — show "Updates paused" + onStateChange('disconnected') + // D-12 polling fallback is already active via refetchInterval on the query + return + } + onStateChange('reconnecting') + const delay = BACKOFF_STEPS_MS[attempt] + attemptsRef.current = attempt + 1 + timerRef.current = setTimeout(connect, delay) + } + }, [listId, queryClient, onStateChange]) + + function handleListChange() { + queryClient.invalidateQueries({ queryKey: ['list', listId] }) + } + + useEffect(() => { + connect() + return () => { + esRef.current?.close() + if (timerRef.current) clearTimeout(timerRef.current) + } + }, [connect]) +} +``` + +**D-12 polling fallback integration:** +```typescript +// In the ListDetail component — always enable refetchInterval; SSE delivers +// instant updates when connected; polling delivers updates within 30s when not. +const { data } = useQuery({ + queryKey: ['list', listId], + queryFn: () => fetchListItems(listId), + refetchInterval: 30_000, // D-12: polling fallback always active +}) +``` + +**Note:** One SSE stream per user session is the intent from UI-SPEC. The `/api/sse/lists` endpoint subscribes to ALL lists the user can access (not per-list). The client-side hook lives in ListDetail but the stream is shared across list navigation. Consider hoisting the SSE connection to the Lists route level (or using a context) so it doesn't reconnect on every list navigation. [ASSUMED — implementation detail for planner to decide] + +--- + +## Research Finding 5: React Router v7 Integration (D-16/D-17) + +### Pattern: Declarative mode (no data router) + +React Router v7 supports two modes: "data router" (framework mode with loaders) and "declarative" (library mode, identical to v6). For this app — which uses TanStack Query for all data fetching — the declarative mode is correct. No loader functions, no remix-style conventions. [CITED: https://github.com/remix-run/react-router/blob/main/docs/start/declarative/routing.md] + +**App.tsx transformation:** +```tsx +// Before: +export default function App() { + return +} + +// After: +import { BrowserRouter, Routes, Route, Navigate } from 'react-router' + +export default function App() { + return ( + + {/* renders BottomTabBar + */} + + ) +} + +function AppShell() { + return ( + <> + + } /> + } /> + } /> + } /> + + + + ) +} +``` + +**BottomTabBar using NavLink:** +```tsx +import { NavLink } from 'react-router' + +function BottomTabBar() { + return ( + + ) +} +``` + +**PWA / vite-plugin-pwa interaction:** +- `BrowserRouter` uses the HTML5 History API. The service worker must handle navigation requests to `/lists/*` by serving `index.html` (SPA fallback). `vite-plugin-pwa` with `navigateFallback: 'index.html'` in the Workbox config handles this automatically — already configured in Phase 3. [ASSUMED — Workbox navigateFallback behavior; standard React SPA + PWA pattern] +- Back button works natively with History API routing. +- Phase 5 deep-link push notifications can use `clients.openWindow('/lists/123')` directly. + +--- + +## Research Finding 6: Optimistic UI + Per-Field PATCH (D-07/D-08) + +**Pattern: React Query optimistic updates with rollback** [VERIFIED: https://github.com/tanstack/query/blob/main/docs/framework/react/guides/optimistic-updates.md] + +```tsx +// Check off item — optimistic update example (per D-07/D-08) +const queryClient = useQueryClient() + +const checkMutation = useMutation({ + mutationFn: ({ itemId, checked }: { itemId: number; checked: boolean }) => + patchListItem(itemId, { checked }), // PATCH /api/list-items/:id with { checked } only + + onMutate: async ({ itemId, checked }) => { + await queryClient.cancelQueries({ queryKey: ['list', listId] }) + const previous = queryClient.getQueryData(['list', listId]) + + // Optimistically update single field + queryClient.setQueryData(['list', listId], (old: ListItemsResponse) => ({ + ...old, + items: old.items.map((item) => + item.id === itemId ? { ...item, checked } : item + ), + })) + + return { previous } // returned as context for rollback + }, + + onError: (_err, _vars, context) => { + // Roll back to previous state + if (context?.previous) { + queryClient.setQueryData(['list', listId], context.previous) + } + }, + + onSettled: () => { + // Always refetch to reconcile with server + queryClient.invalidateQueries({ queryKey: ['list', listId] }) + }, +}) +``` + +**Per-field PATCH contract:** +- `PATCH /api/list-items/:id` with body `{ checked: true }` — only updates `checked` +- `PATCH /api/list-items/:id` with body `{ text: "milk" }` — only updates `text` +- `PATCH /api/list-items/:id` with body `{ position: "a0V" }` — only updates `rank` +- Zod schema: `z.object({ checked: z.boolean(), text: z.string(), position: z.string() }).partial()` with `.refine(obj => Object.keys(obj).length === 1)` to enforce single-field writes. +- Server applies the write with `updatedAt = NOW()` — last-write-wins per D-08. + +**Delete-wins (D-09):** No optimistic rollback on item delete. `onMutate` removes the item from cache; no `onError` rollback. If server rejects (extremely rare — only if list was deleted concurrently), the SSE event or next refetch corrects state. + +--- + +## Research Finding 7: Drag-and-Drop (LIST-03) + +### Library: @dnd-kit/core 6.3.1 + @dnd-kit/sortable 10.0.0 + +UI-SPEC mandates `@dnd-kit/core` + `@dnd-kit/sortable`. Do NOT use `react-beautiful-dnd` (deprecated) or HTML5 drag API (poor mobile). [VERIFIED from 04-UI-SPEC.md] + +**Key components:** +- `DndContext` — wraps the sortable list; receives `onDragEnd` +- `SortableContext` — provides sort order context to children +- `useSortable` — hook per item, returns `{ attributes, listeners, setNodeRef, transform, transition }` +- `CSS.Transform.toString(transform)` — converts transform for inline style +- Drag handle: attach `listeners` to the `GripVertical` icon element only (not the whole row) + +**Pattern with drag handle:** [CITED: https://github.com/clauderic/dnd-kit/blob/main/apps/docs/docs/react/guides/multiple-sortable-lists.mdx] +```tsx +import { DndContext, closestCenter } from '@dnd-kit/core' +import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' + +function ItemRow({ item, onReorder }) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = + useSortable({ id: item.id }) + + return ( +
+ {/* Drag handle — listeners on handle only, not whole row */} + + {item.text} +
+ ) +} + +function ActiveItemsList({ items, listId }) { + function handleDragEnd(event) { + const { active, over } = event + if (!over || active.id === over.id) return + + const oldIndex = items.findIndex((i) => i.id === active.id) + const newIndex = items.findIndex((i) => i.id === over.id) + + // Compute new fractional rank + const prevRank = newIndex > 0 ? items[newIndex - 1].rank : null + const nextRank = newIndex < items.length - 1 ? items[newIndex + 1].rank : null + // Note: after arrayMove, the item lands at newIndex, so compute rank for that position + const newRank = generateKeyBetween(prevRank, nextRank) + + // Fire optimistic PATCH + reorderMutation.mutate({ itemId: active.id, rank: newRank }) + } + + return ( + + i.id)} strategy={verticalListSortingStrategy}> + {items.map((item) => )} + + + ) +} +``` + +**Touch support:** dnd-kit supports touch natively. The UI-SPEC specifies 200ms long-press delay on handle. Use `useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } })`. + +**Keyboard reorder fallback (accessibility):** dnd-kit provides keyboard reorder via `KeyboardSensor` out of the box — attach `KeyboardSensor` in `useSensors`. [CITED: https://github.com/clauderic/dnd-kit/blob/main/apps/docs] + +--- + +## Research Finding 8: Drizzle generate+migrate Workflow (MariaDB) + +**Hard constraint (confirmed in project memory and STATE.md):** `drizzle-kit push` is unsafe on populated MariaDB — it emits false destructive diffs (table truncation). Phase 4 MUST use `generate` + `migrate`. [VERIFIED: memory/drizzle-mariadb-push-unsafe.md + STATE.md] + +**Workflow:** +```bash +# 1. Add new tables to schema.ts + +# 2. Generate SQL migration file (compares current schema to previous snapshot) +pnpm --filter @familysync/api db:generate +# Creates: apps/api/src/db/migrations/0002_lists_schema.sql + +# 3. Review the generated SQL (confirm it's additive: CREATE TABLE, no DROP) + +# 4. Apply migration +pnpm --filter @familysync/api db:migrate +``` + +**drizzle.config.ts already configured:** +```typescript +// apps/api/drizzle.config.ts — confirmed from codebase +out: './src/db/migrations', // ← migrations land in src/db/migrations/ +dialect: 'mysql', // ← MariaDB wire-compatible with mysql dialect +``` + +**Migrations dir:** `apps/api/src/db/migrations/` — already contains `0001_calendars_user_url_unique.sql`. New migration will be `0002_lists_schema.sql`. + +**Note on snapshot:** The first `db:generate` run after adding list tables will create a `_journal.json` and a snapshot alongside the SQL file. The snapshot allows future `generate` runs to diff correctly. [CITED: https://github.com/drizzle-team/drizzle-orm-docs/blob/main/src/content/docs/migrations.mdx] + +**Auto-migration on container start (optional but recommended):** +```typescript +// apps/api/src/index.ts — add before startBrokerPoller() +import { migrate } from 'drizzle-orm/mysql2/migrator' +await migrate(db, { migrationsFolder: './src/db/migrations' }) +``` +This makes deployments self-healing. The migrate call is idempotent — skips already-applied migrations. [CITED: drizzle-orm docs on programmatic migration] + +--- + +## Database Schema Design + +### New Tables + +```typescript +// apps/api/src/db/schema.ts — append these tables + +/** + * Named lists (grocery, gift ideas, etc.) — stored in MariaDB, not CalDAV. + * owner_id: the creating member. is_shared: default true (D-01). + */ +export const lists = mysqlTable( + 'lists', + { + id: int().primaryKey().autoincrement(), + ownerId: int('owner_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + name: varchar('name', { length: 255 }).notNull(), + isShared: boolean('is_shared').default(true).notNull(), // D-01: default shared + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), + }, + (t) => [index('idx_lists_owner_id').on(t.ownerId)], +) + +/** + * Join table for list sharing (D-02). Member-count-agnostic. + * A row here means `user_id` can read and edit `list_id`. + * On list create with is_shared=true: insert rows for all other members. + */ +export const listShares = mysqlTable( + 'list_shares', + { + id: int().primaryKey().autoincrement(), + listId: int('list_id') + .notNull() + .references(() => lists.id, { onDelete: 'cascade' }), + userId: int('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (t) => [ + unique('uniq_list_share').on(t.listId, t.userId), + index('idx_list_shares_user_id').on(t.userId), + ], +) + +/** + * Items within a list. + * rank: string fractional index (D-13), VARCHAR(255). + * checked: true → item sinks to "completed" section (D-05). + */ +export const listItems = mysqlTable( + 'list_items', + { + id: int().primaryKey().autoincrement(), + listId: int('list_id') + .notNull() + .references(() => lists.id, { onDelete: 'cascade' }), + text: varchar('text', { length: 500 }).notNull(), + checked: boolean('checked').default(false).notNull(), + rank: varchar('rank', { length: 255 }).notNull(), // fractional-indexing string + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), + }, + (t) => [ + index('idx_list_items_list_id_rank').on(t.listId, t.rank), + index('idx_list_items_list_id_checked').on(t.listId, t.checked), + ], +) +``` + +**Rank assignment for new items:** +- First item in list: `generateKeyBetween(null, null)` → `"a0"` +- Append to end of active items: `generateKeyBetween(lastActiveRank, null)` +- Prepend: `generateKeyBetween(null, firstActiveRank)` +- Uncheck (move to bottom of active): `generateKeyBetween(lastActiveRank, null)` + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Item position ordering | Float-based position column | `fractional-indexing` | Floats exhaust precision after ~50 mid-point inserts; string fractional indexing degrades gracefully | +| Item position ordering | Integer renumber on reorder | `fractional-indexing` | Integer renumber = O(n) writes, noisy over SSE, causes fan-out storms | +| Drag-and-drop | HTML5 drag API | `@dnd-kit/core` + `@dnd-kit/sortable` | HTML5 has no touch support, no keyboard support, poor mobile UX | +| Drag-and-drop | `react-beautiful-dnd` | `@dnd-kit/core` + `@dnd-kit/sortable` | react-beautiful-dnd deprecated; no React 18/19 support | +| SSE reconnect backoff | Intervals/timers from scratch | Pattern in Finding 4 | Custom timer logic has edge cases (multiple concurrent timers, stale closures); the hook pattern is well-specified | +| Conflict resolution | CRDTs or vector clocks | Last-write-wins per `updatedAt` | CRDTs are massive complexity for a two-person household; LWW is correct per D-08 | +| Routing | Manual hash routing or window.history | `react-router` (declarative mode) | react-router handles History API, back-button, deep-links, PWA navigation fallback | +| Schema migrations | Hand-written SQL | `drizzle-kit generate` + review + `migrate` | Drizzle generates correct MariaDB DDL; hand-written risks column type mismatches | + +**Key insight:** The two highest-risk hand-roll areas are (1) SSE fan-out scope (leaking private list events to wrong users if scope logic is wrong) and (2) fractional indexing if hand-rolled with floats (precision exhaustion breaks ordering silently). Both are solved by established libraries with well-defined semantics. + +--- + +## Common Pitfalls + +### Pitfall 1: SSE fan-out leaking private list events to all clients + +**What goes wrong:** If the SSE endpoint subscribes to ALL list events (no scope), a private list's changes broadcast to every connected user. + +**Why it happens:** Using a single global EventEmitter channel instead of per-list channels, or computing the accessible list set incorrectly. + +**How to avoid:** The `getAccessibleListIds(userId)` query MUST include `list_shares` for shared lists AND restrict to `owner_id = userId` for private lists. The SSE handler subscribes ONLY to channels for `accessibleListIds`. Verify with a test: create a private list as user A, have user B connect — user B's stream should never emit events for user A's private list. + +**Warning signs:** Seeing events from other lists on the client; `list:N` event arriving for a list the user cannot see in `GET /api/lists`. + +### Pitfall 2: Float-based positions exhausting precision + +**What goes wrong:** Inserting between position 0.5 and 0.5 → 0.5; JavaScript `(0.5 + 0.5) / 2` = 0.5, not 0.25. Two items get identical position. Sort order becomes undefined. + +**Why it happens:** Float midpoints converge after ~50 inserts at the same gap. + +**How to avoid:** Use `fractional-indexing` (Finding 2). VARCHAR rank column; never a float column. + +**Warning signs:** Items appearing out of order; duplicate ranks in DB. + +### Pitfall 3: Raw EventSource reconnect storm + +**What goes wrong:** `EventSource.onerror` fires → browser auto-reconnects immediately → error again → rapid-fire reconnect loop, hammering the SSE endpoint. + +**Why it happens:** Native EventSource reconnects with its own fixed interval (browser-controlled, ~3s) AND the `onerror` callback is called on each attempt — if the handler creates a new EventSource it stacks with the browser's built-in reconnect. + +**How to avoid:** The pattern in Finding 4: close the EventSource on error (`es.close()`), then manually schedule the next connect with `setTimeout`. This prevents browser auto-reconnect from stacking with the manual one. [ASSUMED — this is the standard documented pattern for controlled EventSource reconnect] + +**Warning signs:** Network tab showing rapid succession of `/api/sse/lists` requests. + +### Pitfall 4: `drizzle-kit push` on populated MariaDB + +**What goes wrong:** Push computes a diff between current schema and DB state; on MariaDB it misreads column metadata and schedules `TRUNCATE TABLE` for existing tables. + +**Why it happens:** Confirmed MariaDB-specific behavior with drizzle-kit's mysql dialect. [VERIFIED: memory/drizzle-mariadb-push-unsafe.md] + +**How to avoid:** ALWAYS use `db:generate` then `db:migrate`. Never run `db:push` on Phase 4+. + +**Warning signs:** Drizzle push output containing `DROP TABLE` or `TRUNCATE` for existing tables. + +### Pitfall 5: dnd-kit drag handle with touch — accidental drags + +**What goes wrong:** Without an activation delay on touch, scrolling a list triggers drag behavior — especially frustrating on the item list. + +**How to avoid:** Use `TouchSensor` with `activationConstraint: { delay: 200, tolerance: 5 }` as specified in UI-SPEC. Mouse can be immediate; touch requires deliberate 200ms hold on the handle. + +**Warning signs:** Scroll gestures turning into reorder operations on mobile. + +### Pitfall 6: React Router + vite-plugin-pwa navigation fallback + +**What goes wrong:** User navigates to `/lists/42`, bookmarks it, reloads — server returns 404 because it doesn't have a `/lists/42` route. PWA service worker must serve `index.html` for all navigation requests. + +**How to avoid:** Verify `vite-plugin-pwa` config has `navigateFallback: 'index.html'` and `navigateFallbackAllowlist: [/^(?!\/_)/]` (or similar). This should already be in place from Phase 3's PWA setup — confirm it covers `/lists/*`. [ASSUMED — standard Workbox PWA SPA config] + +**Warning signs:** Hard refresh on a `/lists/:id` URL returns 404 or API error instead of the PWA. + +### Pitfall 7: SSE auth with `withCredentials` + +**What goes wrong:** `new EventSource(url)` does NOT send cookies by default. OIDC session cookie is required for the auth middleware. + +**How to avoid:** Always use `new EventSource('/api/sse/lists', { withCredentials: true })`. [ASSUMED — standard EventSource credential behavior] + +--- + +## Code Examples + +### Verified Pattern: Hono streamSSE (existing sse.ts) +```typescript +// Source: apps/api/src/routes/sse.ts [VERIFIED: read from codebase] +sseRouter.get('/heartbeat', (c) => { + return streamSSE(c, async (stream) => { + let id = 0 + while (!stream.aborted) { + await stream.writeSSE({ + data: JSON.stringify({ ts: new Date().toISOString(), id }), + event: 'heartbeat', + id: String(id++), + }) + await stream.sleep(10_000) + } + }) +}) +``` +The live-list SSE endpoint follows this exact pattern. Extend `sseRouter` with `/lists`. + +### Verified Pattern: Drizzle schema conventions (existing schema.ts) +```typescript +// Source: apps/api/src/db/schema.ts [VERIFIED: read from codebase] +// Pattern: int().primaryKey().autoincrement() +// .references(() => users.id, { onDelete: 'cascade' }) +// unique('name').on(t.col1, t.col2) +// index('idx_name').on(t.col) +// timestamp().defaultNow().onUpdateNow() // for updatedAt +``` +New list tables follow these exact conventions. + +### Verified Pattern: React Query optimistic update +```typescript +// Source: https://github.com/tanstack/query/blob/main/docs/framework/react/guides/optimistic-updates.md +// [VERIFIED: Context7 fetch] +useMutation({ + mutationFn: updateTodo, + onMutate: async (newTodo, context) => { + await context.client.cancelQueries({ queryKey: ['todos', newTodo.id] }) + const previousTodo = context.client.getQueryData(['todos', newTodo.id]) + context.client.setQueryData(['todos', newTodo.id], newTodo) + return { previousTodo, newTodo } + }, + onError: (err, newTodo, onMutateResult, context) => { + context.client.setQueryData(['todos', onMutateResult.newTodo.id], onMutateResult.previousTodo) + }, + onSettled: (newTodo, error, variables, onMutateResult, context) => + context.client.invalidateQueries({ queryKey: ['todos', newTodo.id] }), +}) +``` + +### Verified Pattern: NavLink with active styling +```tsx +// Source: https://github.com/remix-run/react-router/blob/main/docs/start/declarative/navigating.md +// [VERIFIED: Context7 fetch] + isActive ? 'tab tab--active' : 'tab'} +> + Lists + +``` + +--- + +## Validation Architecture + +### Test Framework + +| Property | Value | +|----------|-------| +| Framework | Vitest ^4.1.8 | +| Config file | `apps/api/vitest.config.ts` + `apps/pwa/vitest.config.ts` (existing) | +| Quick run command | `pnpm --filter @familysync/api test` and `pnpm --filter @familysync/pwa test` | +| Full suite command | `pnpm test` (root, runs all workspaces) | + +### Phase Requirements → Test Map + +| Req ID | Behavior | Test Type | Automated Command | Notes | +|--------|----------|-----------|-------------------|-------| +| LIST-01 | Create list inserts DB row + list_shares for shared | unit (API) | `pnpm --filter @familysync/api test` | Wave 0 test stub needed | +| LIST-01 | Delete list removes list + cascade items/shares | unit (API) | `pnpm --filter @familysync/api test` | | +| LIST-01 | `GET /api/lists` returns only accessible lists (owner + shares) | unit (API) | `pnpm --filter @familysync/api test` | Security-critical | +| LIST-02 | Add item assigns fractional rank | unit (API) | `pnpm --filter @familysync/api test` | | +| LIST-02 | PATCH with `checked: true` only updates `checked` field | unit (API) | `pnpm --filter @familysync/api test` | | +| LIST-03 | PATCH with new rank produces correct fractional order | unit (API) | `pnpm --filter @familysync/api test` | | +| LIST-04 | SSE endpoint emits event on item write | unit (API, integration) | `pnpm --filter @familysync/api test` | May need supertest-style HTTP test | +| LIST-04 | Private list events NOT emitted to non-owner subscriber | unit (API) | `pnpm --filter @familysync/api test` | Critical correctness test | +| D-11 | Bounded backoff hook exhausts after 6 attempts | unit (PWA) | `pnpm --filter @familysync/pwa test` | Mock EventSource | +| D-07 | Optimistic update rolls back on mutation error | unit (PWA) | `pnpm --filter @familysync/pwa test` | React Query test utils | + +### Wave 0 Gaps + +- [ ] `apps/api/src/routes/lists.test.ts` — covers LIST-01/02/03/04 API behavior +- [ ] `apps/api/src/lib/listEmitter.test.ts` — covers scoped fan-out correctness (D-04) +- [ ] `apps/pwa/src/hooks/useListSSE.test.ts` — covers D-11 bounded backoff with mock EventSource +- [ ] `apps/pwa/src/routes/ListDetail.test.tsx` — covers optimistic update + rollback (D-07) + +--- + +## Security Domain + +### Applicable ASVS Categories + +| ASVS Category | Applies | Standard Control | +|---------------|---------|-----------------| +| V2 Authentication | yes (inherited) | OIDC middleware — all `/api/lists*` routes behind same guard as existing `/api/events` | +| V3 Session Management | yes (inherited) | @hono/oidc-auth JWT session cookie — no change needed | +| V4 Access Control | YES (new, critical) | Server enforces list access per `owner_id` + `list_shares`; never trust client-supplied list membership | +| V5 Input Validation | yes | zod on all list/item write payloads (name max 255, text max 500) | +| V6 Cryptography | no | No new cryptographic operations in this phase | + +### Known Threat Patterns for This Phase + +| Pattern | STRIDE | Standard Mitigation | +|---------|--------|---------------------| +| Accessing another user's private list via direct ID enumeration | Elevation of Privilege | `GET /api/lists/:id` and all item endpoints verify user is owner OR in `list_shares` before returning data | +| SSE event leakage to wrong member | Information Disclosure | Scoped subscription: `getAccessibleListIds(userId)` query gates which channels are subscribed | +| XSS via list or item name | Tampering | All text rendered as plain-text JSX children — no `dangerouslySetInnerHTML` (established project pattern, T-03-15) | +| Overposting on PATCH (updating fields beyond checked/text/position) | Tampering | Zod schema for PATCH enforces `.partial()` + `.refine` that exactly one field is present | +| Privilege escalation via self-adding to list_shares | Elevation of Privilege | `POST /api/list-shares` (if exposed) must verify requester is list owner; or shares are server-managed only | + +--- + +## Environment Availability + +| Dependency | Required By | Available | Version | Fallback | +|------------|------------|-----------|---------|----------| +| Node.js 22 | API runtime | Confirmed (existing phases) | 22.x | — | +| MariaDB | Lists persistence | Confirmed (running) | 11.x | — | +| Redis | SSE fan-out (Redis path) | Available in docker-compose but NOT used in Phase 4 | — | In-memory EventEmitter (recommended) | +| ioredis | Redis client (if Redis path chosen) | Not installed | — | Not needed if EventEmitter chosen | +| react-router | SPA routing | Not installed | 7.17.0 on npm | — (required) | +| @dnd-kit/core | Drag-and-drop | Not installed | 6.3.1 on npm | — (required) | +| @dnd-kit/sortable | Sortable list | Not installed | 10.0.0 on npm | — (required) | +| fractional-indexing | Rank generation | Not installed | 3.2.0 on npm | — (required) | + +**Missing dependencies with no fallback:** All 4 new npm packages must be installed before implementation. + +**Missing dependencies with fallback:** Redis/ioredis — recommended to skip in favor of in-memory EventEmitter for Phase 4 (Claude's Discretion recommendation). + +--- + +## State of the Art + +| Old Approach | Current Approach | Impact | +|--------------|------------------|--------| +| `react-beautiful-dnd` | `@dnd-kit/core` + `@dnd-kit/sortable` | rbd deprecated; dnd-kit is the maintained standard with touch + a11y | +| Float-based position columns | String fractional indexing (rocicorp/fractional-indexing) | Floats exhaust; strings are stable | +| `drizzle-kit push` | `drizzle-kit generate` + `migrate` | Push is unsafe on MariaDB; generate+migrate is the safe path | +| Global EventSource reconnect | Manual bounded-backoff EventSource wrapper | Native EventSource has no backoff; wrapper is the recommended pattern | + +**Deprecated/outdated:** +- `react-beautiful-dnd`: archived, no React 18+ support. Replace with `@dnd-kit`. +- Float position columns: known precision exhaustion problem in any reorderable list. Use fractional-indexing strings. +- `drizzle-kit push` on MariaDB: confirmed unsafe; already recorded as project constraint. + +--- + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | In-memory EventEmitter is the correct fan-out mechanism (vs Redis) for a single-process API | Finding 1, Fan-out | If API scales to multiple replicas before Redis is added, events from one replica will not reach SSE connections on another. Mitigation: `listEmitter.ts` abstraction makes Redis swap mechanical. | +| A2 | Workbox `navigateFallback: 'index.html'` is already configured in Phase 3's vite-plugin-pwa setup | Pitfall 6 | PWA hard-refresh on `/lists/:id` would 404. Planner should verify vite.config.ts PWA plugin config covers SPA navigation fallback. | +| A3 | SSE stream scoped at connection time (subscription to accessible lists computed once on connect) is acceptable for Phase 4 | Finding 1, Finding 3 | New list shares made while user is connected won't be received until reconnect. Acceptable per D-10 (full refetch on reconnect). | +| A4 | `new EventSource(url, { withCredentials: true })` is sufficient for sending the OIDC session cookie in production (Pangolin same-parent-domain) | Pitfall 7 | If cookie SameSite settings or Pangolin path stripping breaks credential forwarding, SSE requests will 401. Can be verified in Gate 2-style smoke test. | +| A5 | `react-router` v7 declarative mode (no data router, no loaders) is the correct integration for this TanStack Query app | Finding 5 | If data router features are needed, refactor is straightforward — declarative mode is a strict subset. | +| A6 | `@dnd-kit/sortable` v10 is compatible with `@dnd-kit/core` v6 | Package audit | Peer dep declares `@dnd-kit/core ^6.3.0` — `6.3.1` satisfies it. Low risk. | + +--- + +## Open Questions + +1. **When a list's sharing status changes mid-session (shared → private or vice versa):** + - What we know: SSE subscription set computed at connection time. A share added mid-session won't reach new subscribers. + - What's unclear: Should a visibility change (`is_shared=false`) immediately stop broadcasting to non-owner SSE connections? Current design: they continue receiving events until their next reconnect. + - Recommendation: For Phase 4, this is acceptable. The sharing toggle changes the DB row; the non-owner's next fetch (polling fallback or reconnect) will 403 on item access. Add a `list:access-revoked` SSE event type in Phase 5 or later if needed. + +2. **Uncheck behavior — rank on return to active list:** + - What we know: D-05 says "on uncheck: item moves from completed → top of active section... append to bottom of active, not restored to original rank position." + - What's unclear: "append to bottom of active" means the rank should be after the last active item's rank. + - Recommendation: On PATCH `{ checked: false }`, the server computes `generateKeyBetween(lastActiveItemRank, null)` and updates `rank` in the same transaction. The client's optimistic update uses the same computation. + +3. **List creation and sharing — auto-populate `list_shares`:** + - What we know: `is_shared=true` means all other household members can see it. + - What's unclear: Does `POST /api/lists` with `is_shared=true` automatically insert `list_shares` rows for all other users? Or does a separate endpoint manage shares? + - Recommendation: Auto-insert `list_shares` rows for all users except the creator on list create with `is_shared=true`. Query `users` table to get all members. This is the YAGNI approach for v1 (two members). The join table structure (D-02) makes future per-recipient UI a data migration, not a schema change. + +--- + +## Sources + +### Primary (HIGH confidence — VERIFIED from codebase) + +- `apps/api/src/routes/sse.ts` — confirmed `streamSSE` + `stream.aborted` heartbeat pattern +- `apps/api/src/db/schema.ts` — confirmed Drizzle table conventions (mysqlTable, int autoincrement PK, references + onDelete cascade, unique, index, timestamp defaultNow onUpdateNow) +- `apps/api/src/index.ts` — confirmed route mounting pattern for new lists routes +- `apps/api/drizzle.config.ts` — confirmed `out: './src/db/migrations'`, `dialect: 'mysql'` +- `.planning/phases/01-foundation-broker-spike/01-HUMAN-UAT.md` — SSE smoke test PASS evidence +- `.planning/STATE.md` — Entry gate CLEARED confirmation, drizzle-kit push unsafe constraint +- `04-CONTEXT.md` — all locked decisions D-01 through D-18 +- `04-UI-SPEC.md` — @dnd-kit mandate, component specs, interaction contracts +- `apps/pwa/src/components/DeleteConfirmationDialog.tsx` — reuse pattern confirmed + +### Primary (HIGH confidence — VERIFIED via npm registry + Context7) + +- `@dnd-kit/core` 6.3.1 — [VERIFIED: npm registry] github.com/clauderic/dnd-kit; 17M/wk; React >=16.8 +- `@dnd-kit/sortable` 10.0.0 — [VERIFIED: npm registry] same repo; requires `@dnd-kit/core ^6.3.0` +- `fractional-indexing` 3.2.0 — [VERIFIED: npm registry] github.com/rocicorp/fractional-indexing; 1M/wk; `generateKeyBetween` / `generateNKeysBetween` API confirmed +- Context7 `/clauderic/dnd-kit` — `useSortable` hook pattern, SortableContext, drag handle via listeners +- Context7 `/tanstack/query` — optimistic update onMutate/onError/onSettled pattern (React) +- Context7 `/remix-run/react-router` — NavLink with isActive callback, BrowserRouter, Routes + +### Secondary (MEDIUM confidence — CITED from official docs) + +- Context7 `/drizzle-team/drizzle-orm-docs` — generate + migrate workflow commands +- Context7 `/remix-run/react-router` — declarative routing mode, BrowserRouter, NavLink +- `react-router` 7.17.0 — [VERIFIED: npm registry] 47.5M/wk, est. 2014 (remix-run org); React >=18 peer dep satisfied by React 19 + +### Tertiary (LOW confidence — ASSUMED) + +- In-memory EventEmitter recommendation over Redis (A1) +- Workbox navigateFallback coverage of `/lists/*` (A2) +- EventSource withCredentials behavior through Pangolin (A4) + +--- + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH — all existing packages confirmed from package.json; new packages verified on npm registry with Context7 docs +- Architecture patterns: HIGH — SSE pattern confirmed from codebase; fan-out pattern is established Node.js EventEmitter; schema follows verified Drizzle conventions +- Pitfalls: HIGH — drizzle push unsafe is a verified project constraint; SSE fan-out leak risk is load-bearing (D-04); float position exhaustion is documented behavior +- New library APIs: HIGH — dnd-kit and React Query patterns verified via Context7 official docs + +**Research date:** 2026-06-09 +**Valid until:** 2026-07-09 (stable libraries; React Router and dnd-kit release frequently but API surface is stable in minor versions)