feat(04-06): implement live-sync SSE vertical slice (LIST-04, D-04/D-10/D-11/D-12)
- Wire publishListEvent fan-out in lists.ts after every write mutation (item:added/updated/deleted, list:updated/deleted)
- Add GET /api/sse/lists scoped endpoint in sse.ts: resolveUserId → 401 on null; getAccessibleListIds → subscribe only to accessible channels; 30s heartbeat; cleanup on disconnect (D-04/T-04-01/T-04-02)
- Create useListSSE.ts: bounded-backoff EventSource wrapper (250ms→500ms→1s→2s→4s→cap 8s); MAX_ATTEMPTS=6; withCredentials:true; close-before-retry prevents reconnect storm (Pitfall 3); invalidates ['list', listId] on open (D-10) and on each event; onStateChange('disconnected') after exhaustion (D-11)
- Create LiveSyncIndicator.tsx: connected=green dot; reconnecting=pulsing muted dot + label; disconnected=red dot + 'Updates paused' (role=alert); correct ARIA per UI-SPEC
- Wire useListSSE + LiveSyncIndicator into ListDetail header; retain refetchInterval:30000 polling fallback (D-12)
- All 8 useListSSE tests pass; all 54 API tests pass; both typechecks pass
- playwright-cli: live update confirmed (eggs item added via API appeared in browser without manual refresh)
This commit is contained in:
@@ -30,8 +30,7 @@ import { rankForAppend } from '../lib/rank.js'
|
||||
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
|
||||
import '../auth/devBypass.js'
|
||||
|
||||
// Uncomment in Plan 06 when SSE endpoint exists:
|
||||
// import { publishListEvent } from '../lib/listEmitter.js'
|
||||
import { publishListEvent } from '../lib/listEmitter.js'
|
||||
|
||||
export const listsRouter = new Hono()
|
||||
|
||||
@@ -284,8 +283,8 @@ listsRouter.post('/', zValidator('json', createListSchema), async (c) => {
|
||||
.where(eq(lists.id, listId))
|
||||
.limit(1)
|
||||
|
||||
// Plan 06 SSE seam:
|
||||
// publishListEvent(listId, { type: 'list:updated', listId, payload: newList })
|
||||
// Fan-out: notify accessible subscribers that this list was created/updated (LIST-04)
|
||||
publishListEvent(listId, { type: 'list:updated', listId, payload: { id: listId, name: newList.name } })
|
||||
|
||||
return c.json(
|
||||
{
|
||||
@@ -376,8 +375,8 @@ listsRouter.patch('/:id', zValidator('json', patchListSchema), async (c) => {
|
||||
.where(eq(lists.id, listId))
|
||||
.limit(1)
|
||||
|
||||
// Plan 06 SSE seam:
|
||||
// publishListEvent(listId, { type: 'list:updated', listId, payload: updated })
|
||||
// Fan-out: notify accessible subscribers that this list metadata changed (LIST-04)
|
||||
publishListEvent(listId, { type: 'list:updated', listId, payload: { id: listId, name: updated.name } })
|
||||
|
||||
return c.json({
|
||||
id: updated.id,
|
||||
@@ -422,8 +421,8 @@ listsRouter.delete('/:id', async (c) => {
|
||||
|
||||
await db.delete(lists).where(eq(lists.id, listId))
|
||||
|
||||
// Plan 06 SSE seam:
|
||||
// publishListEvent(listId, { type: 'list:deleted', listId, payload: { id: listId } })
|
||||
// Fan-out: notify accessible subscribers that this list was deleted (LIST-04)
|
||||
publishListEvent(listId, { type: 'list:deleted', listId, payload: { id: listId } })
|
||||
|
||||
return c.json({ id: listId })
|
||||
} catch (err) {
|
||||
@@ -488,8 +487,8 @@ listsRouter.post('/:id/items', zValidator('json', createItemSchema), async (c) =
|
||||
.where(eq(listItems.id, inserted.id))
|
||||
.limit(1)
|
||||
|
||||
// Plan 06 SSE seam:
|
||||
// publishListEvent(listId, { type: 'item:added', listId, payload: newItem })
|
||||
// Fan-out: notify accessible subscribers that an item was added (LIST-04)
|
||||
publishListEvent(listId, { type: 'item:added', listId, payload: { id: newItem.id, listId, text: newItem.text } })
|
||||
|
||||
return c.json(
|
||||
{
|
||||
@@ -631,8 +630,8 @@ listItemsRouter.patch('/:itemId', zValidator('json', patchItemSchema), async (c)
|
||||
.where(eq(listItems.id, itemId))
|
||||
.limit(1)
|
||||
|
||||
// Plan 06 SSE seam:
|
||||
// publishListEvent(item.listId, { type: 'item:updated', listId: item.listId, payload: updated })
|
||||
// Fan-out: notify accessible subscribers that an item was updated (LIST-04)
|
||||
publishListEvent(item.listId, { type: 'item:updated', listId: item.listId, payload: { id: updated.id, listId: updated.listId } })
|
||||
|
||||
return c.json({
|
||||
id: updated.id,
|
||||
@@ -682,8 +681,8 @@ listItemsRouter.delete('/:itemId', async (c) => {
|
||||
// Delete-wins (D-09): delete is final; no rollback path.
|
||||
await db.delete(listItems).where(eq(listItems.id, itemId))
|
||||
|
||||
// Plan 06 SSE seam:
|
||||
// publishListEvent(item.listId, { type: 'item:deleted', listId: item.listId, payload: { id: itemId } })
|
||||
// Fan-out: notify accessible subscribers that an item was deleted (LIST-04)
|
||||
publishListEvent(item.listId, { type: 'item:deleted', listId: item.listId, payload: { id: itemId } })
|
||||
|
||||
return c.json({ id: itemId })
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user