Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
66 KiB
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_sharesjoin 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: 30000polling 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/corerequires React >=16.8.0 — compatible with React 19. [VERIFIED: npm registry]@dnd-kit/sortablerequires@dnd-kit/core ^6.3.0and React >=16.8.0 — both satisfied. [VERIFIED: npm registry]react-routerv7 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:
// 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_sharesjoin 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
generateNKeysBetweencan 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
updatedAtcolumn (MariaDBDEFAULT 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/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
// 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:
BrowserRouteruses the HTML5 History API. The service worker must handle navigation requests to/lists/*by servingindex.html(SPA fallback).vite-plugin-pwawithnavigateFallback: '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/:idwith body{ checked: true }— only updatescheckedPATCH /api/list-items/:idwith body{ text: "milk" }— only updatestextPATCH /api/list-items/:idwith body{ position: "a0V" }— only updatesrank- 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; receivesonDragEndSortableContext— provides sort order context to childrenuseSortable— hook per item, returns{ attributes, listeners, setNodeRef, transform, transition }CSS.Transform.toString(transform)— converts transform for inline style- Drag handle: attach
listenersto theGripVerticalicon 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] }),
});
Verified Pattern: NavLink with active styling
// 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 behaviorapps/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 EventSourceapps/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 pushon 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
-
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-revokedSSE event type in Phase 5 or later if needed.
-
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 computesgenerateKeyBetween(lastActiveItemRank, null)and updatesrankin the same transaction. The client's optimistic update uses the same computation.
-
List creation and sharing — auto-populate
list_shares:- What we know:
is_shared=truemeans all other household members can see it. - What's unclear: Does
POST /api/listswithis_shared=trueautomatically insertlist_sharesrows for all other users? Or does a separate endpoint manage shares? - Recommendation: Auto-insert
list_sharesrows for all users except the creator on list create withis_shared=true. Queryuserstable 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.
- What we know:
Sources
Primary (HIGH confidence — VERIFIED from codebase)
apps/api/src/routes/sse.ts— confirmedstreamSSE+stream.abortedheartbeat patternapps/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 routesapps/api/drizzle.config.ts— confirmedout: './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 constraint04-CONTEXT.md— all locked decisions D-01 through D-1804-UI-SPEC.md— @dnd-kit mandate, component specs, interaction contractsapps/pwa/src/components/DeleteConfirmationDialog.tsx— reuse pattern confirmed
Primary (HIGH confidence — VERIFIED via npm registry + Context7)
@dnd-kit/core6.3.1 — [VERIFIED: npm registry] github.com/clauderic/dnd-kit; 17M/wk; React >=16.8@dnd-kit/sortable10.0.0 — [VERIFIED: npm registry] same repo; requires@dnd-kit/core ^6.3.0fractional-indexing3.2.0 — [VERIFIED: npm registry] github.com/rocicorp/fractional-indexing; 1M/wk;generateKeyBetween/generateNKeysBetweenAPI confirmed- Context7
/clauderic/dnd-kit—useSortablehook 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-router7.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)