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:
Janpeter Visser 2026-04-27 02:11:08 +02:00
parent 1e548da9bf
commit 562735b98b
3 changed files with 345 additions and 6 deletions

View file

@ -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;