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.
25 KiB
phase, verified, status, score, overrides_applied, re_verification
| phase | verified | status | score | overrides_applied | re_verification | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 04-shared-lists-live-sync | 2026-06-09T18:30:00Z | passed | 4/4 must-haves verified | 0 |
|
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:varcharBincustomTypefactory emitsvarchar(255) COLLATE utf8mb4_bin;listItems.rankswitched from barevarchartovarcharBin('rank').notNull().apps/api/src/db/migrations/0002_yielding_mattie_franklin.sql: Single-lineALTER TABLE list_items MODIFY COLUMN rank varchar(255) COLLATE utf8mb4_bin NOT NULL— no DROP, no TRUNCATE, no length or nullability change. Applied viadb:migrate(neverdb:push).apps/api/tests/routes/lists.test.ts:1091: Regression test seeds item with ranka0, drags second item to top via PATCH{ position: 'Zz' }, then GETs items and assertsZz-ranked item is at index 0. Exercises real DBORDER 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:336confirmed - 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 aftercheckListAccessand beforeupdateValuesconstruction:Stale inline comment at line 350 updated to reflect the now-real guard.if (patch.isShared !== undefined && !access.isOwner) { return c.json({ error: 'Only the list owner can change sharing settings' }, 403) }apps/api/tests/routes/lists.test.ts:452-500: Two new negative tests (T-04-08):- Sharee sends
{ isShared: false }→ asserts 403 +list_sharesunchanged (sharee row still present, length1). - Sharee sends
{ isShared: true }on private list → asserts 403 + no new shares inserted (count unchanged).
- Sharee sends
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, onlyisSharedis owner-gated. - All owner-path
isSharedtoggle 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:
rankcolumn carriesCOLLATE utf8mb4_binin schema and the live DB (applied via additiveALTER TABLEmigration, no destructive operations). Regression test passes. - T-04-08 / T-04-05: Owner-only guard at
lists.ts:336blocks non-ownerisSharedmutations. Negative tests confirm 403 and unchangedlist_sharesfor bothfalse→andtrue→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)