Files
familysync/.planning/phases/04-shared-lists-live-sync/04-RESEARCH.md
T
Lucas Berger 60745b3281 refactor(04-01): move API list test stubs into tests/ mirror dir to match convention
The two Wave-0 RED stubs (lists.test.ts, listEmitter.test.ts) were co-located in
src/ but all existing API tests live in apps/api/tests/. Move them to tests/routes/
and tests/lib/, add explicit vitest imports to match the tests/ convention, and
update path references in downstream plans 04-02..04-06. PWA tests keep co-location
(that IS the PWA convention).
2026-06-09 12:10:31 -04:00

1168 lines
56 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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>
## 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.
</user_constraints>
<phase_requirements>
## 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 |
</phase_requirements>
---
## 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<listId, Set<userId>> — 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<number[]> {
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<EventSource | null>(null)
const attemptsRef = useRef(0)
const timerRef = useRef<ReturnType<typeof setTimeout> | 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 <CalendarShell />
}
// After:
import { BrowserRouter, Routes, Route, Navigate } from 'react-router'
export default function App() {
return (
<BrowserRouter>
<AppShell /> {/* renders BottomTabBar + <Outlet /> */}
</BrowserRouter>
)
}
function AppShell() {
return (
<>
<Routes>
<Route path="/" element={<Navigate to="/calendar" replace />} />
<Route path="/calendar" element={<CalendarShell />} />
<Route path="/lists" element={<ListsIndex />} />
<Route path="/lists/:listId" element={<ListDetail />} />
</Routes>
<BottomTabBar />
</>
)
}
```
**BottomTabBar using NavLink:**
```tsx
import { NavLink } from 'react-router'
function BottomTabBar() {
return (
<nav style={{ position: 'fixed', bottom: 0, /* ... */ }}>
<NavLink
to="/calendar"
className={({ isActive }) => isActive ? 'tab tab--active' : 'tab'}
>
<CalendarDays size={22} />
<span>Calendar</span>
</NavLink>
<NavLink
to="/lists"
className={({ isActive }) => isActive ? 'tab tab--active' : 'tab'}
>
<List size={22} />
<span>Lists</span>
</NavLink>
</nav>
)
}
```
**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 (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.8 : 1,
}}
{...attributes}
>
{/* Drag handle — listeners on handle only, not whole row */}
<button {...listeners} aria-label="Drag to reorder">
<GripVertical size={16} />
</button>
<span>{item.text}</span>
</div>
)
}
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 (
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={items.map((i) => i.id)} strategy={verticalListSortingStrategy}>
{items.map((item) => <ItemRow key={item.id} item={item} />)}
</SortableContext>
</DndContext>
)
}
```
**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]
<NavLink
to="/lists"
className={({ isActive }) => isActive ? 'tab tab--active' : 'tab'}
>
Lists
</NavLink>
```
---
## 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/tests/routes/lists.test.ts` — covers LIST-01/02/03/04 API behavior
- [ ] `apps/api/tests/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)