feat(ST-804): solo-store realtime dispatch + pendingOps
Wire de SSE-events uit /api/realtime/solo door naar de Zustand-store
zodat het Solo Paneel zonder refresh meebeweegt met DB-mutaties uit
welke bron dan ook (web, REST, MCP).
Migratie 20260427000216_extend_realtime_payload: voegt new-state
velden aan de pg_notify-payload toe (task_status, task_sort_order,
task_title, story_status, story_sort_order, story_title, story_code)
zodat de client geen extra fetch nodig heeft per event.
Store-uitbreiding (stores/solo-store.ts):
- pendingOps: Set<task-id> die optimistic-writes markeert; realtime
echos voor die ids worden onderdrukt zodat eigen UI-mutaties niet
twee keer toegepast worden of door een latere echo overschreven
- handleRealtimeEvent: dispatch op entity + op
- task UPDATE/INSERT: bestaande tasks krijgen status/title/sort_order
bijgewerkt; onbekende tasks worden genegeerd (story-context
ontbreekt — gebruiker ziet ze pas na refresh)
- task DELETE: verwijdert uit store
- story UPDATE: werkt story_title/story_code bij op alle child-tasks
in de store
- story DELETE: verwijdert alle child-tasks (cascade reflectie)
Unit-test: 7 scenario's (status update, pendingOps echo-suppression,
DELETE, story-rename cascade, story-delete cascade, unknown task
no-op).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1e548da9bf
commit
562735b98b
3 changed files with 345 additions and 6 deletions
109
__tests__/stores/solo-store-realtime.test.ts
Normal file
109
__tests__/stores/solo-store-realtime.test.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useSoloStore } from '@/stores/solo-store'
|
||||
import type { RealtimeEvent } from '@/stores/solo-store'
|
||||
import type { SoloTask } from '@/components/solo/solo-board'
|
||||
|
||||
const baseTask = (id: string, overrides: Partial<SoloTask> = {}): SoloTask => ({
|
||||
id,
|
||||
title: `Task ${id}`,
|
||||
description: null,
|
||||
implementation_plan: null,
|
||||
priority: 1,
|
||||
sort_order: 1,
|
||||
status: 'TO_DO',
|
||||
story_id: 'story-1',
|
||||
story_code: 'ST-100',
|
||||
story_title: 'Original Story',
|
||||
task_code: 'ST-100.1',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const taskEvent = (overrides: Partial<RealtimeEvent>): RealtimeEvent => ({
|
||||
op: 'U',
|
||||
entity: 'task',
|
||||
id: 'task-1',
|
||||
story_id: 'story-1',
|
||||
product_id: 'prod-1',
|
||||
sprint_id: 'sprint-1',
|
||||
assignee_id: 'user-1',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const storyEvent = (overrides: Partial<RealtimeEvent>): RealtimeEvent => ({
|
||||
op: 'U',
|
||||
entity: 'story',
|
||||
id: 'story-1',
|
||||
product_id: 'prod-1',
|
||||
sprint_id: 'sprint-1',
|
||||
assignee_id: 'user-1',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('solo-store realtime', () => {
|
||||
beforeEach(() => {
|
||||
useSoloStore.setState({ tasks: {}, pendingOps: new Set() })
|
||||
})
|
||||
|
||||
it('applies a task status update', () => {
|
||||
useSoloStore.getState().initTasks([baseTask('task-1', { status: 'TO_DO' })])
|
||||
useSoloStore.getState().handleRealtimeEvent(taskEvent({ task_status: 'IN_PROGRESS' }))
|
||||
expect(useSoloStore.getState().tasks['task-1'].status).toBe('IN_PROGRESS')
|
||||
})
|
||||
|
||||
it('skips a task update when pendingOps has the id', () => {
|
||||
useSoloStore.getState().initTasks([baseTask('task-1', { status: 'TO_DO' })])
|
||||
useSoloStore.getState().markPending('task-1')
|
||||
useSoloStore.getState().handleRealtimeEvent(taskEvent({ task_status: 'DONE' }))
|
||||
expect(useSoloStore.getState().tasks['task-1'].status).toBe('TO_DO')
|
||||
})
|
||||
|
||||
it('applies the update again once pendingOps is cleared', () => {
|
||||
useSoloStore.getState().initTasks([baseTask('task-1', { status: 'TO_DO' })])
|
||||
useSoloStore.getState().markPending('task-1')
|
||||
useSoloStore.getState().handleRealtimeEvent(taskEvent({ task_status: 'DONE' }))
|
||||
expect(useSoloStore.getState().tasks['task-1'].status).toBe('TO_DO')
|
||||
useSoloStore.getState().clearPending('task-1')
|
||||
useSoloStore.getState().handleRealtimeEvent(taskEvent({ task_status: 'DONE' }))
|
||||
expect(useSoloStore.getState().tasks['task-1'].status).toBe('DONE')
|
||||
})
|
||||
|
||||
it('removes a task on D op', () => {
|
||||
useSoloStore.getState().initTasks([baseTask('task-1'), baseTask('task-2')])
|
||||
useSoloStore.getState().handleRealtimeEvent(taskEvent({ id: 'task-1', op: 'D' }))
|
||||
expect(useSoloStore.getState().tasks['task-1']).toBeUndefined()
|
||||
expect(useSoloStore.getState().tasks['task-2']).toBeDefined()
|
||||
})
|
||||
|
||||
it('ignores task INSERT/UPDATE for tasks not in the store', () => {
|
||||
useSoloStore.getState().initTasks([baseTask('task-1')])
|
||||
useSoloStore.getState().handleRealtimeEvent(taskEvent({ id: 'task-other', task_status: 'DONE' }))
|
||||
expect(Object.keys(useSoloStore.getState().tasks)).toEqual(['task-1'])
|
||||
})
|
||||
|
||||
it('updates story_title/code on all child tasks via story UPDATE', () => {
|
||||
useSoloStore.getState().initTasks([
|
||||
baseTask('task-1', { story_id: 'story-1' }),
|
||||
baseTask('task-2', { story_id: 'story-1' }),
|
||||
baseTask('task-3', { story_id: 'story-other', story_title: 'Other' }),
|
||||
])
|
||||
useSoloStore.getState().handleRealtimeEvent(
|
||||
storyEvent({ story_title: 'Renamed Story', story_code: 'ST-100B' }),
|
||||
)
|
||||
expect(useSoloStore.getState().tasks['task-1'].story_title).toBe('Renamed Story')
|
||||
expect(useSoloStore.getState().tasks['task-1'].story_code).toBe('ST-100B')
|
||||
expect(useSoloStore.getState().tasks['task-2'].story_title).toBe('Renamed Story')
|
||||
expect(useSoloStore.getState().tasks['task-3'].story_title).toBe('Other')
|
||||
})
|
||||
|
||||
it('removes all tasks of a story on story DELETE', () => {
|
||||
useSoloStore.getState().initTasks([
|
||||
baseTask('task-1', { story_id: 'story-1' }),
|
||||
baseTask('task-2', { story_id: 'story-1' }),
|
||||
baseTask('task-3', { story_id: 'story-other' }),
|
||||
])
|
||||
useSoloStore.getState().handleRealtimeEvent(storyEvent({ op: 'D' }))
|
||||
expect(useSoloStore.getState().tasks['task-1']).toBeUndefined()
|
||||
expect(useSoloStore.getState().tasks['task-2']).toBeUndefined()
|
||||
expect(useSoloStore.getState().tasks['task-3']).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
-- ST-804 prereq: extend the realtime trigger payload with the new-state
|
||||
-- fields the Solo Paneel needs for in-place rendering, so the client doesn't
|
||||
-- have to refetch on every update.
|
||||
--
|
||||
-- Added fields:
|
||||
-- task → task_status, task_sort_order, task_title
|
||||
-- story → story_status, story_sort_order, story_title, story_code
|
||||
--
|
||||
-- Description and implementation_plan stay out of the payload — they can
|
||||
-- be large and aren't needed for kanban-board rendering. UI fetches them
|
||||
-- on demand when the detail dialog opens.
|
||||
|
||||
CREATE OR REPLACE FUNCTION notify_task_change() RETURNS trigger AS $$
|
||||
DECLARE
|
||||
rec record;
|
||||
story_row record;
|
||||
payload jsonb;
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
rec := OLD;
|
||||
ELSE
|
||||
rec := NEW;
|
||||
END IF;
|
||||
|
||||
SELECT product_id, sprint_id, assignee_id
|
||||
INTO story_row
|
||||
FROM stories
|
||||
WHERE id = rec.story_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN rec;
|
||||
END IF;
|
||||
|
||||
payload := jsonb_build_object(
|
||||
'op', CASE TG_OP
|
||||
WHEN 'INSERT' THEN 'I'
|
||||
WHEN 'UPDATE' THEN 'U'
|
||||
WHEN 'DELETE' THEN 'D'
|
||||
END,
|
||||
'entity', 'task',
|
||||
'id', rec.id,
|
||||
'story_id', rec.story_id,
|
||||
'product_id', story_row.product_id,
|
||||
'sprint_id', story_row.sprint_id,
|
||||
'assignee_id', story_row.assignee_id,
|
||||
'task_status', rec.status,
|
||||
'task_sort_order', rec.sort_order,
|
||||
'task_title', rec.title
|
||||
);
|
||||
|
||||
IF TG_OP = 'UPDATE' THEN
|
||||
payload := payload || jsonb_build_object(
|
||||
'changed_fields',
|
||||
COALESCE((
|
||||
SELECT jsonb_agg(n.key)
|
||||
FROM jsonb_each(to_jsonb(NEW)) n
|
||||
JOIN jsonb_each(to_jsonb(OLD)) o USING (key)
|
||||
WHERE n.value IS DISTINCT FROM o.value
|
||||
), '[]'::jsonb)
|
||||
);
|
||||
END IF;
|
||||
|
||||
PERFORM pg_notify('scrum4me_changes', payload::text);
|
||||
RETURN rec;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION notify_story_change() RETURNS trigger AS $$
|
||||
DECLARE
|
||||
rec record;
|
||||
payload jsonb;
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
rec := OLD;
|
||||
ELSE
|
||||
rec := NEW;
|
||||
END IF;
|
||||
|
||||
payload := jsonb_build_object(
|
||||
'op', CASE TG_OP
|
||||
WHEN 'INSERT' THEN 'I'
|
||||
WHEN 'UPDATE' THEN 'U'
|
||||
WHEN 'DELETE' THEN 'D'
|
||||
END,
|
||||
'entity', 'story',
|
||||
'id', rec.id,
|
||||
'product_id', rec.product_id,
|
||||
'sprint_id', rec.sprint_id,
|
||||
'assignee_id', rec.assignee_id,
|
||||
'story_status', rec.status,
|
||||
'story_sort_order', rec.sort_order,
|
||||
'story_title', rec.title,
|
||||
'story_code', rec.code
|
||||
);
|
||||
|
||||
IF TG_OP = 'UPDATE' THEN
|
||||
payload := payload || jsonb_build_object(
|
||||
'changed_fields',
|
||||
COALESCE((
|
||||
SELECT jsonb_agg(n.key)
|
||||
FROM jsonb_each(to_jsonb(NEW)) n
|
||||
JOIN jsonb_each(to_jsonb(OLD)) o USING (key)
|
||||
WHERE n.value IS DISTINCT FROM o.value
|
||||
), '[]'::jsonb)
|
||||
);
|
||||
END IF;
|
||||
|
||||
PERFORM pg_notify('scrum4me_changes', payload::text);
|
||||
RETURN rec;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
|
@ -3,8 +3,9 @@ import type { SoloTask } from '@/components/solo/solo-board'
|
|||
|
||||
type TaskStatus = SoloTask['status']
|
||||
|
||||
// Payload-shape gepubliceerd door de Postgres-trigger via pg_notify (ST-801).
|
||||
// Komt het Solo Paneel binnen via de SSE-stream uit /api/realtime/solo (ST-802).
|
||||
// Payload-shape gepubliceerd door de Postgres-trigger via pg_notify (ST-801
|
||||
// + ST-804 prereq). Komt het Solo Paneel binnen via de SSE-stream uit
|
||||
// /api/realtime/solo (ST-802).
|
||||
export interface RealtimeEvent {
|
||||
op: 'I' | 'U' | 'D'
|
||||
entity: 'task' | 'story'
|
||||
|
|
@ -13,22 +14,40 @@ export interface RealtimeEvent {
|
|||
product_id: string
|
||||
sprint_id: string | null
|
||||
assignee_id: string | null
|
||||
// Task-specifieke velden (alleen aanwezig als entity === 'task')
|
||||
task_status?: TaskStatus
|
||||
task_sort_order?: number
|
||||
task_title?: string
|
||||
// Story-specifieke velden (alleen aanwezig als entity === 'story')
|
||||
story_status?: 'OPEN' | 'IN_SPRINT' | 'DONE'
|
||||
story_sort_order?: number
|
||||
story_title?: string
|
||||
story_code?: string | null
|
||||
// Op UPDATE: lijst van kolommen die zijn veranderd
|
||||
changed_fields?: string[]
|
||||
}
|
||||
|
||||
interface SoloStore {
|
||||
tasks: Record<string, SoloTask>
|
||||
/** Task-ids die op dit moment een eigen optimistic write in de lucht hebben.
|
||||
* Realtime echos voor deze ids worden onderdrukt zodat de eigen update niet
|
||||
* twee keer toegepast wordt of door een latere echo overschreven. */
|
||||
pendingOps: Set<string>
|
||||
|
||||
initTasks: (tasks: SoloTask[]) => void
|
||||
optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null
|
||||
rollback: (taskId: string, prevStatus: TaskStatus) => void
|
||||
updatePlan: (taskId: string, plan: string | null) => void
|
||||
// ST-803 stub. Echte implementatie in ST-804 met pendingOps en
|
||||
// gedifferentieerde apply{Task,Story}{Update,Create,Delete}.
|
||||
|
||||
markPending: (taskId: string) => void
|
||||
clearPending: (taskId: string) => void
|
||||
|
||||
handleRealtimeEvent: (event: RealtimeEvent) => void
|
||||
}
|
||||
|
||||
export const useSoloStore = create<SoloStore>((set, get) => ({
|
||||
tasks: {},
|
||||
pendingOps: new Set<string>(),
|
||||
|
||||
initTasks: (tasks) =>
|
||||
set({ tasks: Object.fromEntries(tasks.map(t => [t.id, t])) }),
|
||||
|
|
@ -46,7 +65,107 @@ export const useSoloStore = create<SoloStore>((set, get) => ({
|
|||
updatePlan: (taskId, plan) =>
|
||||
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], implementation_plan: plan } } })),
|
||||
|
||||
handleRealtimeEvent: (_event) => {
|
||||
// ST-803 stub — vol invullen in ST-804.
|
||||
markPending: (taskId) =>
|
||||
set((s) => {
|
||||
if (s.pendingOps.has(taskId)) return s
|
||||
const next = new Set(s.pendingOps)
|
||||
next.add(taskId)
|
||||
return { pendingOps: next }
|
||||
}),
|
||||
|
||||
clearPending: (taskId) =>
|
||||
set((s) => {
|
||||
if (!s.pendingOps.has(taskId)) return s
|
||||
const next = new Set(s.pendingOps)
|
||||
next.delete(taskId)
|
||||
return { pendingOps: next }
|
||||
}),
|
||||
|
||||
handleRealtimeEvent: (event) => {
|
||||
if (event.entity === 'task') {
|
||||
const { id, op } = event
|
||||
|
||||
if (op === 'D') {
|
||||
set((s) => {
|
||||
if (!(id in s.tasks)) return s
|
||||
const next = { ...s.tasks }
|
||||
delete next[id]
|
||||
return { tasks: next }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// INSERT en UPDATE: alleen bestaande taken bijwerken. Nieuwe taken
|
||||
// zonder story-context (story_title, story_code) renderen we niet
|
||||
// — gebruiker ziet ze pas na een refresh. Acceptabel voor v1.
|
||||
const existing = get().tasks[id]
|
||||
if (!existing) return
|
||||
|
||||
if (get().pendingOps.has(id)) {
|
||||
// Echo van een eigen optimistic move — laat de optimistic-state staan
|
||||
return
|
||||
}
|
||||
|
||||
const updates: Partial<SoloTask> = {}
|
||||
if (event.task_status !== undefined && event.task_status !== existing.status) {
|
||||
updates.status = event.task_status
|
||||
}
|
||||
if (
|
||||
event.task_sort_order !== undefined &&
|
||||
event.task_sort_order !== existing.sort_order
|
||||
) {
|
||||
updates.sort_order = event.task_sort_order
|
||||
}
|
||||
if (event.task_title !== undefined && event.task_title !== existing.title) {
|
||||
updates.title = event.task_title
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) return
|
||||
set((s) => ({ tasks: { ...s.tasks, [id]: { ...s.tasks[id], ...updates } } }))
|
||||
return
|
||||
}
|
||||
|
||||
if (event.entity === 'story') {
|
||||
const { id, op } = event
|
||||
|
||||
if (op === 'D') {
|
||||
// Story-cascade pakt tasks ook in de DB; verwijder de bijbehorende
|
||||
// SoloTask-records uit de store.
|
||||
set((s) => {
|
||||
const next: Record<string, SoloTask> = {}
|
||||
for (const [taskId, task] of Object.entries(s.tasks)) {
|
||||
if (task.story_id !== id) next[taskId] = task
|
||||
}
|
||||
return { tasks: next }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const tasks = get().tasks
|
||||
const affectedIds = Object.entries(tasks)
|
||||
.filter(([, t]) => t.story_id === id)
|
||||
.map(([taskId]) => taskId)
|
||||
|
||||
if (affectedIds.length === 0) return
|
||||
|
||||
const newTitle = event.story_title
|
||||
const newCode = event.story_code ?? null
|
||||
|
||||
set((s) => {
|
||||
const next = { ...s.tasks }
|
||||
for (const taskId of affectedIds) {
|
||||
const t = next[taskId]
|
||||
const titleChanged = newTitle !== undefined && t.story_title !== newTitle
|
||||
const codeChanged = newCode !== t.story_code
|
||||
if (!titleChanged && !codeChanged) continue
|
||||
next[taskId] = {
|
||||
...t,
|
||||
...(titleChanged && newTitle !== undefined && { story_title: newTitle }),
|
||||
...(codeChanged && { story_code: newCode }),
|
||||
}
|
||||
}
|
||||
return { tasks: next }
|
||||
})
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue