style(13-03): apply Prettier formatting across repo
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.
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
---
|
||||
|
||||
<user_constraints>
|
||||
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
@@ -47,14 +48,15 @@
|
||||
</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 |
|
||||
| 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 |
|
||||
| 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>
|
||||
|
||||
@@ -77,9 +79,11 @@ The three technically novel sub-problems are: (a) scoped SSE fan-out where event
|
||||
**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]
|
||||
@@ -88,18 +92,18 @@ Also confirmed in `STATE.md` Blockers/Concerns:
|
||||
|
||||
## 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 |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -107,26 +111,27 @@ Also confirmed in `STATE.md` Blockers/Concerns:
|
||||
|
||||
### 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 |
|
||||
| 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 |
|
||||
| Library | Version | Ecosystem | Purpose | Verdict |
|
||||
| --------------------- | ------- | --------- | ----------------------------- | -------------------------------------------------------------------- |
|
||||
| `react-router` | 7.17.0 | npm | SPA routing (D-17) | SUS (too-new version, but package is legitimate — 47M/wk, est. 2014) |
|
||||
| `@dnd-kit/core` | 6.3.1 | npm | Drag-and-drop core | OK |
|
||||
| `@dnd-kit/sortable` | 10.0.0 | npm | Sortable list abstraction | OK |
|
||||
| `fractional-indexing` | 3.2.0 | npm | String rank generation (D-13) | OK |
|
||||
|
||||
**Installation:**
|
||||
|
||||
```bash
|
||||
# PWA
|
||||
pnpm --filter @familysync/pwa add react-router@7 @dnd-kit/core @dnd-kit/sortable fractional-indexing
|
||||
@@ -136,6 +141,7 @@ 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]
|
||||
@@ -144,12 +150,12 @@ pnpm --filter @familysync/api add fractional-indexing
|
||||
|
||||
## 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 |
|
||||
| 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
|
||||
|
||||
@@ -259,22 +265,22 @@ The D-18 "design for N members" concern is valid but does NOT require Redis in P
|
||||
// apps/api/src/lib/listEmitter.ts
|
||||
// Source: Node.js EventEmitter docs + established SSE fan-out pattern
|
||||
|
||||
import { EventEmitter } from 'node:events'
|
||||
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
|
||||
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
|
||||
}
|
||||
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)
|
||||
emitter.emit(`list:${listId}`, event);
|
||||
}
|
||||
|
||||
/** Subscribe to events for a specific list. Returns an unsubscribe function. */
|
||||
@@ -282,9 +288,9 @@ export function subscribeListEvents(
|
||||
listId: number,
|
||||
handler: (event: ListEvent) => void,
|
||||
): () => void {
|
||||
const channel = `list:${listId}`
|
||||
emitter.on(channel, handler)
|
||||
return () => emitter.off(channel, handler)
|
||||
const channel = `list:${listId}`;
|
||||
emitter.on(channel, handler);
|
||||
return () => emitter.off(channel, handler);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -295,42 +301,42 @@ The SSE endpoint receives the user's identity (via `resolveUserId`), queries `li
|
||||
```typescript
|
||||
// apps/api/src/routes/sse.ts — add to existing sseRouter
|
||||
sseRouter.get('/lists', async (c) => {
|
||||
const userId = await resolveUserId(c)
|
||||
if (!userId) return c.json({ error: 'unauthorized' }, 401)
|
||||
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
|
||||
const accessibleListIds = await getAccessibleListIds(userId); // DB query
|
||||
|
||||
return streamSSE(c, async (stream) => {
|
||||
const unsubscribers: Array<() => void> = []
|
||||
const unsubscribers: Array<() => void> = [];
|
||||
|
||||
for (const listId of accessibleListIds) {
|
||||
const unsub = subscribeListEvents(listId, async (event) => {
|
||||
if (stream.aborted) return
|
||||
if (stream.aborted) return;
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify(event),
|
||||
event: event.type,
|
||||
id: `${listId}-${Date.now()}`,
|
||||
})
|
||||
})
|
||||
unsubscribers.push(unsub)
|
||||
});
|
||||
});
|
||||
unsubscribers.push(unsub);
|
||||
}
|
||||
|
||||
// Heartbeat to keep Pangolin connection alive (proven in smoke test)
|
||||
let tick = 0
|
||||
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)
|
||||
});
|
||||
await stream.sleep(30_000);
|
||||
}
|
||||
|
||||
// Cleanup on disconnect
|
||||
unsubscribers.forEach((unsub) => unsub())
|
||||
})
|
||||
})
|
||||
unsubscribers.forEach((unsub) => unsub());
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Why this satisfies D-18 (N-member agnostic):**
|
||||
@@ -352,30 +358,34 @@ Replace `listEmitter.ts` internals with `ioredis.publish` / `ioredis.subscribe`.
|
||||
**Source:** github.com/rocicorp/fractional-indexing — the reference implementation used by Linear, Notion, and Figma for stable drag-drop ordering. [VERIFIED: npm registry + official README]
|
||||
|
||||
**API:**
|
||||
|
||||
```typescript
|
||||
import { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing'
|
||||
import { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing';
|
||||
|
||||
// Insert at beginning (before first item)
|
||||
const rank = generateKeyBetween(null, firstItem.rank) // → "a0"
|
||||
const rank = generateKeyBetween(null, firstItem.rank); // → "a0"
|
||||
|
||||
// Insert at end (after last item)
|
||||
const rank = generateKeyBetween(lastItem.rank, null) // → "a1"
|
||||
const rank = generateKeyBetween(lastItem.rank, null); // → "a1"
|
||||
|
||||
// Insert between two items
|
||||
const rank = generateKeyBetween(itemA.rank, itemB.rank) // → "a0V"
|
||||
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"]
|
||||
const ranks = generateNKeysBetween(null, null, 5); // → ["a0", "a1", "a2", "a3", "a4"]
|
||||
```
|
||||
|
||||
**Column datatype:**
|
||||
|
||||
```sql
|
||||
rank VARCHAR(255) NOT NULL DEFAULT 'a0'
|
||||
```
|
||||
|
||||
- VARCHAR(255) is generous; typical fractional-indexing strings are 2-10 chars even after hundreds of reorders between the same two items. Only pathological "zipper" inserts (always inserting at the same mid-point) approach long strings, and `generateNKeysBetween` can rebalance.
|
||||
- Indexed: `INDEX idx_list_items_rank (list_id, rank)` for ORDER BY efficiency.
|
||||
|
||||
**A single move = one-row write:**
|
||||
|
||||
```sql
|
||||
UPDATE list_items SET rank = ? WHERE id = ?
|
||||
-- Only the dragged item's row is updated.
|
||||
@@ -383,12 +393,14 @@ UPDATE list_items SET rank = ? WHERE id = ?
|
||||
```
|
||||
|
||||
**Concurrent reorder convergence (D-15):**
|
||||
|
||||
- Both members drag simultaneously → each PATCH sends a new rank computed from their local view.
|
||||
- Server applies last-write-wins per the `updatedAt` column (MariaDB `DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`).
|
||||
- Each member receives an SSE event → React Query refetches → both converge to the last server state within ~1 RTT. No drift.
|
||||
- This matches D-15 exactly: "Last-write-wins with brief settle."
|
||||
|
||||
**Ordering query:**
|
||||
|
||||
```sql
|
||||
SELECT * FROM list_items
|
||||
WHERE list_id = ? AND checked = false
|
||||
@@ -406,24 +418,21 @@ ORDER BY rank ASC
|
||||
```typescript
|
||||
// DB query used at SSE connection time
|
||||
async function getAccessibleListIds(userId: number): Promise<number[]> {
|
||||
const owned = await db.select({ id: lists.id })
|
||||
.from(lists)
|
||||
.where(eq(lists.ownerId, userId))
|
||||
const owned = await db.select({ id: lists.id }).from(lists).where(eq(lists.ownerId, userId));
|
||||
|
||||
const shared = await db.select({ listId: listShares.listId })
|
||||
const shared = await db
|
||||
.select({ listId: listShares.listId })
|
||||
.from(listShares)
|
||||
.where(eq(listShares.userId, userId))
|
||||
.where(eq(listShares.userId, userId));
|
||||
|
||||
return [
|
||||
...owned.map((r) => r.id),
|
||||
...shared.map((r) => r.listId),
|
||||
]
|
||||
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.
|
||||
|
||||
@@ -439,75 +448,76 @@ The browser's native `EventSource` auto-reconnects forever with no backoff contr
|
||||
// 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'
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
type SyncState = 'connected' | 'reconnecting' | 'disconnected'
|
||||
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
|
||||
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
|
||||
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 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()
|
||||
esRef.current?.close();
|
||||
|
||||
const es = new EventSource('/api/sse/lists', { withCredentials: true })
|
||||
esRef.current = es
|
||||
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.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')
|
||||
attemptsRef.current = 0;
|
||||
onStateChange('connected');
|
||||
// Full refetch on reconnect (D-10)
|
||||
queryClient.invalidateQueries({ queryKey: ['list', listId] })
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['list', listId] });
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
es.close()
|
||||
const attempt = attemptsRef.current
|
||||
es.close();
|
||||
const attempt = attemptsRef.current;
|
||||
if (attempt >= MAX_ATTEMPTS) {
|
||||
// Backoff exhausted — show "Updates paused"
|
||||
onStateChange('disconnected')
|
||||
onStateChange('disconnected');
|
||||
// D-12 polling fallback is already active via refetchInterval on the query
|
||||
return
|
||||
return;
|
||||
}
|
||||
onStateChange('reconnecting')
|
||||
const delay = BACKOFF_STEPS_MS[attempt]
|
||||
attemptsRef.current = attempt + 1
|
||||
timerRef.current = setTimeout(connect, delay)
|
||||
}
|
||||
}, [listId, queryClient, onStateChange])
|
||||
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] })
|
||||
queryClient.invalidateQueries({ queryKey: ['list', listId] });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
connect()
|
||||
connect();
|
||||
return () => {
|
||||
esRef.current?.close()
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [connect])
|
||||
esRef.current?.close();
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [connect]);
|
||||
}
|
||||
```
|
||||
|
||||
**D-12 polling fallback integration:**
|
||||
|
||||
```typescript
|
||||
// In the ListDetail component — always enable refetchInterval; SSE delivers
|
||||
// instant updates when connected; polling delivers updates within 30s when not.
|
||||
@@ -515,7 +525,7 @@ 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]
|
||||
@@ -529,21 +539,22 @@ const { data } = useQuery({
|
||||
React Router v7 supports two modes: "data router" (framework mode with loaders) and "declarative" (library mode, identical to v6). For this app — which uses TanStack Query for all data fetching — the declarative mode is correct. No loader functions, no remix-style conventions. [CITED: https://github.com/remix-run/react-router/blob/main/docs/start/declarative/routing.md]
|
||||
|
||||
**App.tsx transformation:**
|
||||
|
||||
```tsx
|
||||
// Before:
|
||||
export default function App() {
|
||||
return <CalendarShell />
|
||||
return <CalendarShell />;
|
||||
}
|
||||
|
||||
// After:
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AppShell /> {/* renders BottomTabBar + <Outlet /> */}
|
||||
<AppShell /> {/* renders BottomTabBar + <Outlet /> */}
|
||||
</BrowserRouter>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AppShell() {
|
||||
@@ -557,37 +568,33 @@ function AppShell() {
|
||||
</Routes>
|
||||
<BottomTabBar />
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**BottomTabBar using NavLink:**
|
||||
|
||||
```tsx
|
||||
import { NavLink } from 'react-router'
|
||||
import { NavLink } from 'react-router';
|
||||
|
||||
function BottomTabBar() {
|
||||
return (
|
||||
<nav style={{ position: 'fixed', bottom: 0, /* ... */ }}>
|
||||
<NavLink
|
||||
to="/calendar"
|
||||
className={({ isActive }) => isActive ? 'tab tab--active' : 'tab'}
|
||||
>
|
||||
<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'}
|
||||
>
|
||||
<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.
|
||||
@@ -600,42 +607,41 @@ function BottomTabBar() {
|
||||
|
||||
```tsx
|
||||
// Check off item — optimistic update example (per D-07/D-08)
|
||||
const queryClient = useQueryClient()
|
||||
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])
|
||||
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
|
||||
),
|
||||
}))
|
||||
items: old.items.map((item) => (item.id === itemId ? { ...item, checked } : item)),
|
||||
}));
|
||||
|
||||
return { previous } // returned as context for rollback
|
||||
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)
|
||||
queryClient.setQueryData(['list', listId], context.previous);
|
||||
}
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
// Always refetch to reconcile with server
|
||||
queryClient.invalidateQueries({ queryKey: ['list', listId] })
|
||||
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`
|
||||
@@ -653,6 +659,7 @@ const checkMutation = useMutation({
|
||||
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 }`
|
||||
@@ -660,14 +667,16 @@ UI-SPEC mandates `@dnd-kit/core` + `@dnd-kit/sortable`. Do NOT use `react-beauti
|
||||
- Drag handle: attach `listeners` to the `GripVertical` icon element only (not the whole row)
|
||||
|
||||
**Pattern with drag handle:** [CITED: https://github.com/clauderic/dnd-kit/blob/main/apps/docs/docs/react/guides/multiple-sortable-lists.mdx]
|
||||
|
||||
```tsx
|
||||
import { DndContext, closestCenter } from '@dnd-kit/core'
|
||||
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
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 })
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: item.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -685,34 +694,36 @@ function ItemRow({ item, onReorder }) {
|
||||
</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 { 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)
|
||||
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
|
||||
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)
|
||||
const newRank = generateKeyBetween(prevRank, nextRank);
|
||||
|
||||
// Fire optimistic PATCH
|
||||
reorderMutation.mutate({ itemId: active.id, rank: newRank })
|
||||
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} />)}
|
||||
{items.map((item) => (
|
||||
<ItemRow key={item.id} item={item} />
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -727,6 +738,7 @@ function ActiveItemsList({ items, listId }) {
|
||||
**Hard constraint (confirmed in project memory and STATE.md):** `drizzle-kit push` is unsafe on populated MariaDB — it emits false destructive diffs (table truncation). Phase 4 MUST use `generate` + `migrate`. [VERIFIED: memory/drizzle-mariadb-push-unsafe.md + STATE.md]
|
||||
|
||||
**Workflow:**
|
||||
|
||||
```bash
|
||||
# 1. Add new tables to schema.ts
|
||||
|
||||
@@ -741,6 +753,7 @@ pnpm --filter @familysync/api db:migrate
|
||||
```
|
||||
|
||||
**drizzle.config.ts already configured:**
|
||||
|
||||
```typescript
|
||||
// apps/api/drizzle.config.ts — confirmed from codebase
|
||||
out: './src/db/migrations', // ← migrations land in src/db/migrations/
|
||||
@@ -752,11 +765,13 @@ dialect: 'mysql', // ← MariaDB wire-compatible with mysql dialect
|
||||
**Note on snapshot:** The first `db:generate` run after adding list tables will create a `_journal.json` and a snapshot alongside the SQL file. The snapshot allows future `generate` runs to diff correctly. [CITED: https://github.com/drizzle-team/drizzle-orm-docs/blob/main/src/content/docs/migrations.mdx]
|
||||
|
||||
**Auto-migration on container start (optional but recommended):**
|
||||
|
||||
```typescript
|
||||
// apps/api/src/index.ts — add before startBrokerPoller()
|
||||
import { migrate } from 'drizzle-orm/mysql2/migrator'
|
||||
await migrate(db, { migrationsFolder: './src/db/migrations' })
|
||||
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]
|
||||
|
||||
---
|
||||
@@ -785,7 +800,7 @@ export const lists = mysqlTable(
|
||||
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.
|
||||
@@ -808,7 +823,7 @@ export const listShares = mysqlTable(
|
||||
unique('uniq_list_share').on(t.listId, t.userId),
|
||||
index('idx_list_shares_user_id').on(t.userId),
|
||||
],
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Items within a list.
|
||||
@@ -832,10 +847,11 @@ export const listItems = mysqlTable(
|
||||
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)`
|
||||
@@ -845,16 +861,16 @@ export const listItems = mysqlTable(
|
||||
|
||||
## 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 |
|
||||
| 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.
|
||||
|
||||
@@ -929,25 +945,28 @@ export const listItems = mysqlTable(
|
||||
## Code Examples
|
||||
|
||||
### Verified Pattern: Hono streamSSE (existing sse.ts)
|
||||
|
||||
```typescript
|
||||
// Source: apps/api/src/routes/sse.ts [VERIFIED: read from codebase]
|
||||
sseRouter.get('/heartbeat', (c) => {
|
||||
return streamSSE(c, async (stream) => {
|
||||
let id = 0
|
||||
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)
|
||||
});
|
||||
await stream.sleep(10_000);
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
The live-list SSE endpoint follows this exact pattern. Extend `sseRouter` with `/lists`.
|
||||
|
||||
### Verified Pattern: Drizzle schema conventions (existing schema.ts)
|
||||
|
||||
```typescript
|
||||
// Source: apps/api/src/db/schema.ts [VERIFIED: read from codebase]
|
||||
// Pattern: int().primaryKey().autoincrement()
|
||||
@@ -956,36 +975,36 @@ The live-list SSE endpoint follows this exact pattern. Extend `sseRouter` with `
|
||||
// index('idx_name').on(t.col)
|
||||
// timestamp().defaultNow().onUpdateNow() // for updatedAt
|
||||
```
|
||||
|
||||
New list tables follow these exact conventions.
|
||||
|
||||
### Verified Pattern: React Query optimistic update
|
||||
|
||||
```typescript
|
||||
// Source: https://github.com/tanstack/query/blob/main/docs/framework/react/guides/optimistic-updates.md
|
||||
// [VERIFIED: Context7 fetch]
|
||||
useMutation({
|
||||
mutationFn: updateTodo,
|
||||
onMutate: async (newTodo, context) => {
|
||||
await context.client.cancelQueries({ queryKey: ['todos', newTodo.id] })
|
||||
const previousTodo = context.client.getQueryData(['todos', newTodo.id])
|
||||
context.client.setQueryData(['todos', newTodo.id], newTodo)
|
||||
return { previousTodo, newTodo }
|
||||
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)
|
||||
context.client.setQueryData(['todos', onMutateResult.newTodo.id], onMutateResult.previousTodo);
|
||||
},
|
||||
onSettled: (newTodo, error, variables, onMutateResult, context) =>
|
||||
context.client.invalidateQueries({ queryKey: ['todos', newTodo.id] }),
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
### Verified Pattern: NavLink with active styling
|
||||
|
||||
```tsx
|
||||
// Source: https://github.com/remix-run/react-router/blob/main/docs/start/declarative/navigating.md
|
||||
// [VERIFIED: Context7 fetch]
|
||||
<NavLink
|
||||
to="/lists"
|
||||
className={({ isActive }) => isActive ? 'tab tab--active' : 'tab'}
|
||||
>
|
||||
<NavLink to="/lists" className={({ isActive }) => (isActive ? 'tab tab--active' : 'tab')}>
|
||||
Lists
|
||||
</NavLink>
|
||||
```
|
||||
@@ -996,27 +1015,27 @@ useMutation({
|
||||
|
||||
### 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) |
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
@@ -1031,38 +1050,38 @@ useMutation({
|
||||
|
||||
### 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 |
|
||||
| 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 |
|
||||
| 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) |
|
||||
| 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.
|
||||
|
||||
@@ -1072,14 +1091,15 @@ useMutation({
|
||||
|
||||
## 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 |
|
||||
| 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.
|
||||
@@ -1088,14 +1108,14 @@ useMutation({
|
||||
|
||||
## 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. |
|
||||
| # | 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. |
|
||||
|
||||
---
|
||||
|
||||
@@ -1158,6 +1178,7 @@ useMutation({
|
||||
## 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
|
||||
|
||||
Reference in New Issue
Block a user