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

56 KiB
Raw Blame History

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:

# 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
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:

// 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).

// 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:

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:

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:

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:

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):

// 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/listsWHERE 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

// 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:

// 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:

// 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:

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]

// 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]

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:

# 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:

// 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):

// 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

// 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)

// 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)

// 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

// 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] }),
})
// 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.06.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-kituseSortable 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)