LIST-03 collation gap closed, T-04-05 + T-04-08 closed by plan 04-07. Phase 04 sign-off complete.
181 lines
17 KiB
Markdown
181 lines
17 KiB
Markdown
---
|
|
phase: 04-shared-lists-live-sync
|
|
verified: 2026-06-09T18:30:00Z
|
|
status: passed
|
|
score: 4/4 must-haves verified
|
|
overrides_applied: 0
|
|
re_verification:
|
|
previous_status: gaps_found
|
|
previous_score: 3/4
|
|
gaps_closed:
|
|
- "LIST-03 drag-to-top: list_items.rank migrated to COLLATE utf8mb4_bin (migration 0002); uppercase-prefixed rank 'Zz' now sorts before 'a0' in DB ORDER BY, matching JS string order; regression test added"
|
|
- "T-04-08 / T-04-05: owner-only guard added at lists.ts:336; sharee sending { isShared } receives 403; list_shares never mutated by non-owner; negative tests added and passing"
|
|
gaps_remaining: []
|
|
regressions: []
|
|
---
|
|
|
|
# Phase 4: Shared Lists + Live Sync Verification Report
|
|
|
|
**Phase Goal:** Both members can create and manage shared named lists with real-time co-edit sync — edits by one member appear for the other without any manual refresh
|
|
**Verified:** 2026-06-09T18:30:00Z
|
|
**Status:** passed
|
|
**Re-verification:** Yes — after gap-closure plan 04-07
|
|
|
|
---
|
|
|
|
## Goal Achievement
|
|
|
|
### Observable Truths (Roadmap Success Criteria)
|
|
|
|
| # | Truth | Status | Evidence |
|
|
|---|-------|--------|----------|
|
|
| 1 | Either member can create a named list and delete a list they no longer need | VERIFIED | `POST /api/lists` with auto-share wired in `lists.ts:249`; `DELETE /api/lists/:id` owner-only at `lists.ts:403`; `CreateListSheet.tsx` (336 lines, real form); `ListDeleteDialog.tsx` (196 lines); 184 API tests pass |
|
|
| 2 | Either member can add items to a list, check items off, reorder them by drag-and-drop, and delete individual items | VERIFIED | `POST /:id/items` + `PATCH /list-items/:itemId` (exact-one-field LWW) + `DELETE /list-items/:itemId`; `ListDetail.tsx` (501 lines) with `DndContext`/`SortableContext`; `ItemRow.tsx` (273 lines) with `useSortable`; fractional rank assigned on create; optimistic mutations wired for all four operations; 184 API tests pass |
|
|
| 3 | When one member adds or checks off an item, the other member sees the change appear without refreshing — even after a brief network gap | VERIFIED | `publishListEvent` called after every write in `lists.ts`; scoped `GET /api/sse/lists` in `sse.ts:85` subscribes per accessible list via `getAccessibleListIds`; `useListSSE.ts` (114 lines) implements bounded-backoff EventSource (D-11); `refetchInterval:30000` polling fallback active (D-12); D-04 no-leak invariant tested in `lists.test.ts:856` |
|
|
| 4 | A member can drag an active item to a new position and the order persists (reorder via drag-to-top) | VERIFIED | `list_items.rank` column migrated to `COLLATE utf8mb4_bin` via migration `0002_yielding_mattie_franklin.sql`; upstream uppercase-prefixed rank `'Zz'` (produced by `generateKeyBetween(null, 'a0')`) now sorts BEFORE lowercase ranks in DB `ORDER BY rank`, matching JS string order; drag-to-top persists across refetch; regression test at `lists.test.ts:1091` passes against real DB |
|
|
|
|
**Score:** 4/4 truths verified — phase goal fully achieved.
|
|
|
|
---
|
|
|
|
### Gap Closure Detail: LIST-03 Rank Collation
|
|
|
|
**Root cause (previously):** `list_items.rank` inherited the DB default `utf8mb4_uca1400_ai_ci` (case-insensitive), causing `ORDER BY rank` to place uppercase-prefixed keys (`Zz`) AFTER lowercase keys (`a0`), contradicting JS string order.
|
|
|
|
**Fix applied (plan 04-07):**
|
|
- `apps/api/src/db/schema.ts`: `varcharBin` `customType` factory emits `varchar(255) COLLATE utf8mb4_bin`; `listItems.rank` switched from bare `varchar` to `varcharBin('rank').notNull()`.
|
|
- `apps/api/src/db/migrations/0002_yielding_mattie_franklin.sql`: Single-line `ALTER TABLE list_items MODIFY COLUMN rank varchar(255) COLLATE utf8mb4_bin NOT NULL` — no DROP, no TRUNCATE, no length or nullability change. Applied via `db:migrate` (never `db:push`).
|
|
- `apps/api/tests/routes/lists.test.ts:1091`: Regression test seeds item with rank `a0`, drags second item to top via PATCH `{ position: 'Zz' }`, then GETs items and asserts `Zz`-ranked item is at index 0. Exercises real DB `ORDER BY rank`.
|
|
|
|
**Verification:**
|
|
- Migration body: `grep -ciE 'drop|truncate' 0002_yielding_mattie_franklin.sql` → `0` (confirmed)
|
|
- Schema: `grep -c 'utf8mb4_bin' schema.ts` → `4` (factory definition + 3 doc comments)
|
|
- Guard line: `lists.ts:336` confirmed
|
|
- Test suite: 184/184 pass against live MariaDB (`DB_HOST=127.0.0.1`)
|
|
|
|
### Gap Closure Detail: T-04-08 / T-04-05 Owner Guard
|
|
|
|
**Root cause (previously):** PATCH `/:id` `isShared` reconciliation block ran for any allowed user (owner OR sharee). A sharee sending `{ isShared: false }` deleted all `list_shares` rows; sending `{ isShared: true }` injected shares for every user without owner consent.
|
|
|
|
**Fix applied (plan 04-07):**
|
|
- `apps/api/src/routes/lists.ts:334-338`: Owner-only guard inserted after `checkListAccess` and before `updateValues` construction:
|
|
```
|
|
if (patch.isShared !== undefined && !access.isOwner) {
|
|
return c.json({ error: 'Only the list owner can change sharing settings' }, 403)
|
|
}
|
|
```
|
|
Stale inline comment at line 350 updated to reflect the now-real guard.
|
|
- `apps/api/tests/routes/lists.test.ts:452-500`: Two new negative tests (T-04-08):
|
|
1. Sharee sends `{ isShared: false }` → asserts 403 + `list_shares` unchanged (sharee row still present, length `1`).
|
|
2. Sharee sends `{ isShared: true }` on private list → asserts 403 + no new shares inserted (count unchanged).
|
|
|
|
**Verification:**
|
|
- Guard present: `grep -n "patch.isShared !== undefined && !access.isOwner" lists.ts` → line 336 (confirmed)
|
|
- Sharee rename test (pre-existing, `lists.test.ts:438`) still passes — rename allowed for sharees, only `isShared` is owner-gated.
|
|
- All owner-path `isShared` toggle tests (`false→true`, `true→false`) still pass (no regression).
|
|
- 184/184 API tests pass.
|
|
|
|
---
|
|
|
|
### Required Artifacts
|
|
|
|
| Artifact | Expected | Status | Details |
|
|
|----------|----------|--------|---------|
|
|
| `apps/api/src/db/schema.ts` | lists, listShares, listItems Drizzle tables; listItems.rank with COLLATE utf8mb4_bin | VERIFIED | All three tables present; `varcharBin` customType factory at lines 22-25 emits `varchar(255) COLLATE utf8mb4_bin`; `rank` column uses `varcharBin` |
|
|
| `apps/api/src/db/migrations/0001_lists_schema.sql` | Additive CREATE TABLE migration | VERIFIED | File exists; CREATE TABLE for lists/list_items/list_shares; no DROP/TRUNCATE; FKs correct |
|
|
| `apps/api/src/db/migrations/0002_yielding_mattie_franklin.sql` | Non-destructive ALTER TABLE for rank collation | VERIFIED | Single-line `ALTER TABLE list_items MODIFY COLUMN rank varchar(255) COLLATE utf8mb4_bin NOT NULL`; 0 DROP/TRUNCATE occurrences; applied via db:migrate |
|
|
| `apps/pwa/src/components/BottomTabBar.tsx` | Calendar/Lists bottom tab navigation | VERIFIED | 88 lines; NavLink to `/calendar` and `/lists`; 44px+ touch targets; active-state accent via isActive callback |
|
|
| `apps/pwa/src/routes/ListsIndex.tsx` | Lists surface with empty state | VERIFIED | Full implementation; useQuery(['lists']); ListsEmptyState; ListCard; CreateListSheet; ListDeleteDialog wired |
|
|
| `apps/api/src/routes/lists.ts` | POST/GET/PATCH/DELETE /api/lists with scoped access; owner-only isShared guard | VERIFIED | 693+ lines; all four verbs; scoped GET; auto-share on create; owner guard at line 336; publishListEvent fan-out on every write |
|
|
| `apps/pwa/src/components/CreateListSheet.tsx` | New-list form with shared/private toggle | VERIFIED | 336 lines; default shared=true; form validation; useMutation wired |
|
|
| `apps/pwa/src/components/ListCard.tsx` | List summary card navigating to /lists/:id | VERIFIED | 176 lines; item counts; navigate to /lists/:id |
|
|
| `apps/pwa/src/components/ListDeleteDialog.tsx` | List-delete confirmation (D-06) | VERIFIED | 196 lines; reuses dialog pattern; owner-only delete path |
|
|
| `apps/api/src/lib/rank.ts` | fractional rank helpers (rankForAppend, rankBetween) | VERIFIED | 43 lines; wraps `generateKeyBetween`; pure functions; no DB access |
|
|
| `apps/pwa/src/routes/ListDetail.tsx` | List detail with active/completed split + add/check/delete | VERIFIED | 501 lines; DndContext/SortableContext; D-05 active/completed split; all four mutations; D-09 delete-wins |
|
|
| `apps/pwa/src/components/ItemRow.tsx` | dnd-kit sortable item with drag handle | VERIFIED | 273 lines; `useSortable`; handle-scoped listeners; CSS.Transform animation (D-14) |
|
|
| `apps/pwa/src/components/AddItemInput.tsx` | Sticky add-item input | VERIFIED | 105 lines; onAdd callback; isPending state |
|
|
| `apps/api/src/routes/sse.ts` | GET /api/sse/lists scoped SSE stream | VERIFIED | 122 lines; `/lists` route present; `subscribeListEvents` + `getAccessibleListIds`; 30s heartbeat |
|
|
| `apps/pwa/src/hooks/useListSSE.ts` | Bounded-backoff EventSource wrapper invalidating React Query | VERIFIED | 114 lines; 6-step backoff (250ms→8s); `onStateChange` to 'connected'/'reconnecting'/'disconnected'; D-10 full refetch on open |
|
|
| `apps/pwa/src/components/LiveSyncIndicator.tsx` | Connected/reconnecting/disconnected indicator | VERIFIED | 119 lines; three distinct render branches; role="status"/"alert" |
|
|
| `apps/api/src/lib/listEmitter.ts` | In-memory scoped event emitter | VERIFIED | 55 lines; module-level singleton; per-list channels `list:${listId}`; publish/subscribe/unsubscribe |
|
|
| `apps/api/src/lib/listAccess.ts` | getAccessibleListIds access-scope query | VERIFIED | 43 lines; owned UNION shared; deduplicated; used by SSE endpoint |
|
|
| `apps/api/tests/routes/lists.test.ts` | Regression test (LIST-03 collation) + T-04-08 negative tests | VERIFIED | Three new tests: collation regression at line 1091, T-04-08 false→403 at line 452, T-04-08 true→403 at line 477; all pass |
|
|
|
|
### Key Link Verification
|
|
|
|
| From | To | Via | Status | Details |
|
|
|------|----|-----|--------|---------|
|
|
| `apps/pwa/src/App.tsx` | `/lists` route | `BrowserRouter` + `BottomTabBar` NavLink | WIRED | `App.tsx:31`; `<Route path="/lists" element={<ListsIndex />} />`; `<Route path="/lists/:listId" element={<ListDetail />} />` |
|
|
| `apps/api/src/index.ts` | `listsRouter` + `listItemsRouter` | `app.route('/api/lists', listsRouter)` + `app.route('/api/list-items', listItemsRouter)` | WIRED | `index.ts:65-66`; both routers mounted |
|
|
| `apps/api/src/routes/lists.ts` | `list_shares` | Auto-insert shares on create + scoped GET | WIRED | `lists.ts:264-277` (auto-share POST); `lists.ts:169-183` (scoped GET via owned+shared IDs) |
|
|
| `apps/api/src/routes/lists.ts PATCH /:id` | `list_shares` reconciliation block | owner-only guard at line 336 returning 403 for non-owner isShared writes | WIRED | Guard at `lists.ts:336`; reconciliation block at lines 351-374 unreachable for non-owners when `patch.isShared` present |
|
|
| `apps/pwa/src/routes/ListsIndex.tsx` | `/api/lists` | `useQuery + useMutation` in `listsClient` | WIRED | `ListsIndex.tsx:43` (`useQuery(['lists'], fetchLists)`); `useMutation(deleteList)` wired |
|
|
| `apps/pwa/src/routes/ListDetail.tsx` | `/api/lists/:id/items` + `/api/list-items/:id` | `useQuery + optimistic mutations` | WIRED | `ListDetail.tsx:138-186` (fetch + mutations using `fetchListItems`/`addItem`/`patchListItem`/`deleteItem`) |
|
|
| `apps/api/src/routes/lists.ts` | `publishListEvent` | Fan-out trigger after every successful write | WIRED | Calls present after POST list (`lists.ts:287`), PATCH list (`lists.ts:379`), DELETE list (`lists.ts:425`), POST item (`lists.ts:491`), PATCH item (`lists.ts:634`), DELETE item (`lists.ts:685`) |
|
|
| `apps/api/src/routes/sse.ts` | `subscribeListEvents + getAccessibleListIds` | Scoped per-list subscription inside `streamSSE` | WIRED | `sse.ts:89` (`getAccessibleListIds`); `sse.ts:96` (`subscribeListEvents` per listId in loop) |
|
|
| `apps/pwa/src/hooks/useListSSE.ts` | `/api/sse/lists` | `new EventSource(withCredentials) → invalidateQueries` | WIRED | `useListSSE.ts:65` (`new EventSource('/api/sse/lists', { withCredentials: true })`); event listeners call `handleListChange` which invalidates `['list', listId]` |
|
|
| `apps/pwa/src/routes/ListDetail.tsx` | `useListSSE` | Mounted in `ListDetail` render; `setSyncState` passed to hook | WIRED | `ListDetail.tsx:116` (`useListSSE({ listId: parsedListId, onStateChange: setSyncState })`); `<LiveSyncIndicator state={syncState} />` at line 385 |
|
|
| `apps/api/src/db/schema.ts listItems.rank` | `MariaDB list_items.rank` column | `varcharBin` customType → `ALTER TABLE ... MODIFY rank ... COLLATE utf8mb4_bin` | WIRED | `schema.ts:22-25` defines `varcharBin`; `schema.ts:243` applies to `rank`; `0002_yielding_mattie_franklin.sql` applies the ALTER TABLE; migration applied via `db:migrate` |
|
|
|
|
### Data-Flow Trace (Level 4)
|
|
|
|
| Artifact | Data Variable | Source | Produces Real Data | Status |
|
|
|----------|---------------|--------|--------------------|--------|
|
|
| `ListsIndex.tsx` | `data?.lists` | `useQuery(['lists'], fetchLists)` → `GET /api/lists` → DB query (`lists` + `listShares` + `listItems` COUNT) | Yes — DB query with scoped WHERE clause | FLOWING |
|
|
| `ListDetail.tsx` | `data?.items` | `useQuery(['list', listId], fetchListItems)` → `GET /api/lists/:id/items` → DB `SELECT ... ORDER BY rank ASC` using `utf8mb4_bin`-collated `rank` column | Yes — DB query returning real items in correct order | FLOWING |
|
|
| `sse.ts /lists` | SSE events | `subscribeListEvents` ← `publishListEvent` triggered by route writes → real DB mutations | Yes — events fire only after confirmed DB writes | FLOWING |
|
|
|
|
### Behavioral Spot-Checks
|
|
|
|
| Behavior | Command | Result | Status |
|
|
|----------|---------|--------|--------|
|
|
| API test suite (184 tests) | `set -a; . .env; set +a; export DB_HOST=127.0.0.1 DB_PORT=3306; pnpm --filter @familysync/api exec vitest run` | 17 test files, 184 passed, 0 failed | PASS |
|
|
| TypeScript typecheck | `pnpm --filter @familysync/api typecheck` | Clean (no errors) | PASS |
|
|
| Migration non-destructive | `grep -ciE 'drop\|truncate' apps/api/src/db/migrations/0002_yielding_mattie_franklin.sql` | 0 | PASS |
|
|
| rank collation in schema | `grep -c 'utf8mb4_bin' apps/api/src/db/schema.ts` | 4 | PASS |
|
|
| Owner guard in lists.ts | `grep -n "patch.isShared !== undefined && !access.isOwner" apps/api/src/routes/lists.ts` | Line 336 | PASS |
|
|
|
|
### Probe Execution
|
|
|
|
No `scripts/*/tests/probe-*.sh` probes declared or found for this phase.
|
|
|
|
### Requirements Coverage
|
|
|
|
| Requirement | Source Plan | Description | Status | Evidence |
|
|
|-------------|------------|-------------|--------|----------|
|
|
| LIST-01 | 04-03-PLAN.md | User can create and delete named lists | SATISFIED | `POST /DELETE /api/lists`; auto-share; cascade delete; API tests pass |
|
|
| LIST-02 | 04-04-PLAN.md | User can add items, check off, delete | SATISFIED | `POST/PATCH/DELETE /api/list-items`; D-05 checked-sink; D-08 single-field PATCH; API tests pass |
|
|
| LIST-03 | 04-05-PLAN.md | User can reorder items within a list | SATISFIED | dnd-kit + fractional rank wired; rank column carries `COLLATE utf8mb4_bin` via migration 0002; drag-to-top persists — collation regression test passes against real DB |
|
|
| LIST-04 | 04-06-PLAN.md | Both members' list edits appear live without manual refresh | SATISFIED | scoped SSE endpoint; publishListEvent on every write; useListSSE bounded-backoff hook; D-04 no-leak tested; polling fallback active |
|
|
|
|
### Anti-Patterns Found
|
|
|
|
| File | Line | Pattern | Severity | Impact |
|
|
|------|------|---------|----------|--------|
|
|
| `apps/pwa/src/routes/ListsIndex.tsx` | 66 | `TODO: surface "Couldn't delete. Try again." toast (Plan 06 / notification layer)` | Info | Error feedback on delete failure absent; rollback still happens (cache restored); functional correctness unaffected; deferred to Phase 6 notification layer |
|
|
| `apps/pwa/src/routes/ListDetail.tsx` | 379-381 | List detail header shows literal "List" instead of the list name | Info | UX limitation (no extra fetch for name in detail view); noted as future improvement; all item operations work correctly |
|
|
|
|
**Debt markers:** Zero `TBD`, `FIXME`, or `XXX` markers found in any Phase 4 source file (including 04-07 additions).
|
|
|
|
### Human Verification Required
|
|
|
|
None — all functional behaviors verified via automated tests or direct code inspection. The drag-to-top fix is confirmed by passing DB-backed regression test. The T-04-08 guard is confirmed by passing negative tests that assert both the 403 response and the `list_shares` table state.
|
|
|
|
---
|
|
|
|
### Gaps Summary
|
|
|
|
No open gaps. All four LIST-01..LIST-04 requirements are satisfied. Phase 04 is complete.
|
|
|
|
**Previous gap now closed:**
|
|
- LIST-03 drag-to-top: `rank` column carries `COLLATE utf8mb4_bin` in schema and the live DB (applied via additive `ALTER TABLE` migration, no destructive operations). Regression test passes.
|
|
- T-04-08 / T-04-05: Owner-only guard at `lists.ts:336` blocks non-owner `isShared` mutations. Negative tests confirm 403 and unchanged `list_shares` for both `false→` and `true→` paths.
|
|
|
|
**All LIST-01, LIST-02, LIST-03, LIST-04 behaviors are fully implemented and tested.** 184 API tests pass (up from 181 before gap-closure). TypeScript typecheck clean. Production build passes.
|
|
|
|
---
|
|
|
|
_Initial verification: 2026-06-09T14:00:00Z_
|
|
_Re-verification (gap-closure 04-07): 2026-06-09T18:30:00Z_
|
|
_Verifier: Claude (gsd-verifier)_
|