From c6fdd45d98c248f1e4b3494d1e536fa0792572f1 Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Wed, 29 Apr 2026 20:35:40 +0200 Subject: [PATCH] chore: debug-realtime tooling for SSE pipeline diagnostics (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(debug): add /debug-realtime page + bare SSE endpoint Tijdelijke debug-tooling voor M8-acceptance op Vercel preview. - app/api/debug/realtime-stream/route.ts — geen auth, geen filtering; dropt elke pg_notify-event op scrum4me_changes rauw door als SSE - app/debug-realtime/page.tsx — open zonder login op de root, toont binnenkomende events in een simpele Doel: isoleren of de SSE + Postgres LISTEN-pipe op Vercel überhaupt events laat zien, los van iron-session, productfilter of solo-store. Als ook deze niets binnen krijgt: probleem zit in pg connection of Vercel function lifecycle. Als deze wel events toont: probleem zit hoger in de stack (filter, store, hook). VERWIJDEREN voordat de PR uit draft gaat. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(debug): extend /debug-realtime with stats, emit-button and filters Bouwt de basale luister-tabel uit met diagnostische tooling om de SSE+LISTEN-pipe stress-vrij te kunnen valideren. Toegevoegd: - POST /api/debug/emit-test-notify — vuurt een handmatige pg_notify op scrum4me_changes met een synthetic payload (debug:true) zonder een echte DB-UPDATE te doen. Isoleert de SSE-route van Prisma/triggers. - DebugRealtimeClient: stats-grid (status, reconnects, total events, since last event met >30s rood-warning, largest gap, first-event- time), emit-button, reset-stats, filters op type en entity (incl. "debug only"). - Type/entity kolom in de tabel met kleuring per type. Geen impact op productie- of solo-flow. Tijdelijke testtooling; verwijderen wanneer we deze pagina niet meer nodig hebben. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(debug): add Layer 2 — mini Zustand-store + dispatch toggles Test of SSE-event → store → render-pipeline werkt buiten de Solo Paneel context. Mirrort het patroon van solo-store maar minimaal. - debug-store.ts: kleine Zustand-store met tasks + applyEvent + applyCount/skipCount-tellers - store-panel.tsx: rendert store-state in een tabel met statuskleuring - client.tsx: drie layer-toggles (dispatch / flushSync / startView- Transition) + lift dispatch in onmessage. Zo kunnen we elke combinatie isoleren Bevestigd: alle drie de toggles werken op het bare /debug-realtime endpoint. Volgende laag is Server Action revalidation. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- app/api/debug/emit-test-notify/route.ts | 59 +++ app/api/debug/realtime-stream/route.ts | 114 ++++++ app/debug-realtime/client.tsx | 490 ++++++++++++++++++++++++ app/debug-realtime/debug-store.ts | 71 ++++ app/debug-realtime/page.tsx | 23 ++ app/debug-realtime/store-panel.tsx | 111 ++++++ 6 files changed, 868 insertions(+) create mode 100644 app/api/debug/emit-test-notify/route.ts create mode 100644 app/api/debug/realtime-stream/route.ts create mode 100644 app/debug-realtime/client.tsx create mode 100644 app/debug-realtime/debug-store.ts create mode 100644 app/debug-realtime/page.tsx create mode 100644 app/debug-realtime/store-panel.tsx diff --git a/app/api/debug/emit-test-notify/route.ts b/app/api/debug/emit-test-notify/route.ts new file mode 100644 index 0000000..e480258 --- /dev/null +++ b/app/api/debug/emit-test-notify/route.ts @@ -0,0 +1,59 @@ +// TIJDELIJKE debug-endpoint. Stuurt een handmatige pg_notify op +// `scrum4me_changes` zonder een echte UPDATE te doen. Bedoeld om de +// SSE-pipe te testen los van Prisma/triggers. +// +// VERWIJDEREN voor M8 out-of-draft. + +import { Client } from 'pg' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const CHANNEL = 'scrum4me_changes' + +export async function POST(request: Request) { + const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL + if (!directUrl) { + return Response.json({ error: 'DIRECT_URL/DATABASE_URL niet gezet' }, { status: 500 }) + } + + let body: unknown = null + try { + body = await request.json() + } catch { + // empty body is OK — we vullen defaults in + } + + const overrides = (body && typeof body === 'object' ? body : {}) as Record + const payload = { + op: 'U', + entity: 'task', + id: `debug-${Date.now()}`, + story_id: 'debug-story', + product_id: 'debug-product', + sprint_id: null, + assignee_id: null, + task_status: 'TO_DO', + task_sort_order: 1, + task_title: 'manual debug emit', + changed_fields: ['status'], + debug: true, + emitted_at: new Date().toISOString(), + ...overrides, + } + + const client = new Client({ connectionString: directUrl }) + try { + await client.connect() + // pg_notify met JSON-string als payload — zelfde formaat als de trigger + await client.query('SELECT pg_notify($1, $2)', [CHANNEL, JSON.stringify(payload)]) + return Response.json({ ok: true, payload }) + } catch (err) { + return Response.json( + { ok: false, error: err instanceof Error ? err.message : String(err) }, + { status: 500 }, + ) + } finally { + try { await client.end() } catch {} + } +} diff --git a/app/api/debug/realtime-stream/route.ts b/app/api/debug/realtime-stream/route.ts new file mode 100644 index 0000000..e909bfc --- /dev/null +++ b/app/api/debug/realtime-stream/route.ts @@ -0,0 +1,114 @@ +// TIJDELIJKE debug-endpoint voor M8-acceptance. +// Geen auth, geen filtering — alle pg_notify-events op `scrum4me_changes` +// stromen rauw door naar de browser. Bedoeld om te isoleren of de +// SSE + LISTEN-pipe op Vercel werkt, los van iron-session, productfilter +// of solo-store. +// +// VERWIJDEREN VOOR M8 OUT-OF-DRAFT. + +import { NextRequest } from 'next/server' +import { Client } from 'pg' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 300 + +const CHANNEL = 'scrum4me_changes' + +export async function GET(request: NextRequest) { + const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL + if (!directUrl) { + return Response.json({ error: 'DIRECT_URL/DATABASE_URL niet gezet' }, { status: 500 }) + } + + const isPooled = directUrl.includes('pooler.') + const hostHint = directUrl.match(/@([^/]+)/)?.[1] ?? 'unknown-host' + console.log(`[debug-realtime] connecting (${isPooled ? 'POOLED' : 'direct'}) host=${hostHint}`) + + const encoder = new TextEncoder() + const pgClient = new Client({ connectionString: directUrl }) + let closed = false + let heartbeatTimer: ReturnType | null = null + + const stream = new ReadableStream({ + async start(controller) { + const enqueue = (chunk: string) => { + if (closed) return + try { + controller.enqueue(encoder.encode(chunk)) + } catch { + // already closed + } + } + + const cleanup = async (reason: string) => { + if (closed) return + closed = true + if (heartbeatTimer) clearInterval(heartbeatTimer) + try { + await pgClient.end() + } catch { + // ignore + } + try { + controller.close() + } catch { + // already closed + } + console.log(`[debug-realtime] closed: ${reason}`) + } + + try { + await pgClient.connect() + await pgClient.query(`LISTEN ${CHANNEL}`) + console.log('[debug-realtime] LISTEN ready') + } catch (err) { + console.error('[debug-realtime] pg connect/listen failed:', err) + enqueue(`event: error\ndata: ${JSON.stringify({ message: String(err) })}\n\n`) + await cleanup('pg connect failed') + return + } + + pgClient.on('notification', (msg) => { + console.log(`[debug-realtime] RAW notification length=${msg.payload?.length ?? 0}`) + if (!msg.payload) return + enqueue(`data: ${msg.payload}\n\n`) + }) + + pgClient.on('error', async (err) => { + console.error('[debug-realtime] pg client error:', err) + await cleanup('pg error') + }) + + pgClient.on('end', () => { + console.log('[debug-realtime] pg client end') + }) + + enqueue( + `event: ready\ndata: ${JSON.stringify({ + host: hostHint, + pooled: isPooled, + channel: CHANNEL, + time: new Date().toISOString(), + })}\n\n`, + ) + + heartbeatTimer = setInterval(() => { + enqueue(`: heartbeat ${new Date().toISOString()}\n\n`) + }, 25_000) + + request.signal.addEventListener('abort', () => { + cleanup('client aborted') + }) + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }) +} diff --git a/app/debug-realtime/client.tsx b/app/debug-realtime/client.tsx new file mode 100644 index 0000000..67ce1ef --- /dev/null +++ b/app/debug-realtime/client.tsx @@ -0,0 +1,490 @@ +'use client' + +import { useEffect, useMemo, useRef, useState } from 'react' +import { flushSync } from 'react-dom' +import { useDebugStore, type DebugRealtimeEvent } from './debug-store' +import { StorePanel } from './store-panel' + +type RowType = 'ready' | 'message' | 'error' | 'open' | 'close' + +interface Row { + receivedAt: number + type: RowType + raw: string + parsed?: Record +} + +interface Stats { + reconnects: number + totalEvents: number + firstEventAt: number | null + lastEventAt: number | null + largestGapMs: number + emitInFlight: boolean + lastEmitResult: string | null +} + +const MAX_ROWS = 500 +const HEARTBEAT_WARN_MS = 30_000 + +export function DebugRealtimeClient() { + const [rows, setRows] = useState([]) + const [status, setStatus] = useState<'connecting' | 'open' | 'closed' | 'error'>('connecting') + const [stats, setStats] = useState({ + reconnects: 0, + totalEvents: 0, + firstEventAt: null, + lastEventAt: null, + largestGapMs: 0, + emitInFlight: false, + lastEmitResult: null, + }) + const [filterType, setFilterType] = useState<'all' | RowType>('all') + const [filterEntity, setFilterEntity] = useState<'all' | 'task' | 'story' | 'debug'>('all') + const [tickNow, setTickNow] = useState(() => Date.now()) + + // Layer-toggles — elke combinatie isoleert een potentiële bron van bugs. + // - dispatchToStore: voert applyEvent uit op een mini Zustand-store + // - useFlushSync: forceert React om synchroon te renderen tijdens dispatch + // - useViewTransition: wrap dispatch in document.startViewTransition + const [dispatchToStore, setDispatchToStore] = useState(true) + const [useFlushSync, setUseFlushSync] = useState(false) + const [useViewTransition, setUseViewTransition] = useState(false) + const dispatchToStoreRef = useRef(dispatchToStore) + const useFlushSyncRef = useRef(useFlushSync) + const useViewTransitionRef = useRef(useViewTransition) + useEffect(() => { dispatchToStoreRef.current = dispatchToStore }, [dispatchToStore]) + useEffect(() => { useFlushSyncRef.current = useFlushSync }, [useFlushSync]) + useEffect(() => { useViewTransitionRef.current = useViewTransition }, [useViewTransition]) + + const sourceRef = useRef(null) + const lastEventTimeRef = useRef(null) + + // Tick every second om "since last event"-counter levend te houden + useEffect(() => { + const t = setInterval(() => setTickNow(Date.now()), 1000) + return () => clearInterval(t) + }, []) + + useEffect(() => { + const append = (row: Row) => { + setRows((prev) => [row, ...prev].slice(0, MAX_ROWS)) + lastEventTimeRef.current = row.receivedAt + setStats((s) => { + const prevLast = s.lastEventAt + const gap = prevLast ? row.receivedAt - prevLast : 0 + return { + ...s, + totalEvents: s.totalEvents + 1, + firstEventAt: s.firstEventAt ?? row.receivedAt, + lastEventAt: row.receivedAt, + largestGapMs: Math.max(s.largestGapMs, gap), + } + }) + } + + const open = () => { + const source = new EventSource('/api/debug/realtime-stream') + sourceRef.current = source + + append({ receivedAt: Date.now(), type: 'open', raw: '(EventSource opening)' }) + setStats((s) => ({ ...s, reconnects: s.reconnects + 1 })) + setStatus('connecting') + + source.addEventListener('ready', (e) => { + setStatus('open') + const data = (e as MessageEvent).data ?? '' + const parsed = safeParse(data) + append({ receivedAt: Date.now(), type: 'ready', raw: data, parsed }) + }) + + source.addEventListener('error', (e) => { + setStatus('error') + const data = (e as MessageEvent).data ?? '(no data)' + append({ receivedAt: Date.now(), type: 'error', raw: data }) + }) + + source.onmessage = (e) => { + const parsed = safeParse(e.data ?? '') + append({ receivedAt: Date.now(), type: 'message', raw: e.data ?? '', parsed }) + + // Dispatch naar de mini Zustand-store, optioneel met flushSync + // en/of startViewTransition om de echte solo-flow te mimeken. + if (dispatchToStoreRef.current && parsed) { + const dispatch = () => + useDebugStore.getState().applyEvent(parsed as DebugRealtimeEvent) + const wrapWithFlush = useFlushSyncRef.current + ? () => flushSync(dispatch) + : dispatch + if ( + useViewTransitionRef.current && + typeof document !== 'undefined' && + typeof (document as Document & { startViewTransition?: unknown }) + .startViewTransition === 'function' + ) { + ;( + document as Document & { + startViewTransition: (cb: () => void) => unknown + } + ).startViewTransition(wrapWithFlush) + } else { + wrapWithFlush() + } + } + } + + source.onerror = () => { + setStatus('error') + append({ receivedAt: Date.now(), type: 'close', raw: '(EventSource error/close)' }) + } + } + + open() + + return () => { + sourceRef.current?.close() + sourceRef.current = null + } + }, []) + + async function emitTestEvent() { + setStats((s) => ({ ...s, emitInFlight: true, lastEmitResult: null })) + try { + const res = await fetch('/api/debug/emit-test-notify', { method: 'POST' }) + const json = (await res.json()) as { ok?: boolean; error?: string } + setStats((s) => ({ + ...s, + emitInFlight: false, + lastEmitResult: json.ok ? 'sent ✓' : `failed: ${json.error ?? 'unknown'}`, + })) + } catch (err) { + setStats((s) => ({ + ...s, + emitInFlight: false, + lastEmitResult: `failed: ${err instanceof Error ? err.message : String(err)}`, + })) + } + } + + function reset() { + setRows([]) + setStats({ + reconnects: 0, + totalEvents: 0, + firstEventAt: null, + lastEventAt: null, + largestGapMs: 0, + emitInFlight: false, + lastEmitResult: null, + }) + lastEventTimeRef.current = null + } + + const filteredRows = useMemo(() => { + return rows.filter((row) => { + if (filterType !== 'all' && row.type !== filterType) return false + if (filterEntity !== 'all') { + const entity = (row.parsed?.entity as string | undefined) ?? null + if (filterEntity === 'debug') { + if (!row.parsed?.debug) return false + } else { + if (entity !== filterEntity) return false + } + } + return true + }) + }, [rows, filterType, filterEntity]) + + const sinceLastEvent = lastEventTimeRef.current + ? tickNow - lastEventTimeRef.current + : null + + return ( +
+ + + {dispatchToStore && } +
+ + + + + + + + + + {filteredRows.length === 0 ? ( + + + + ) : ( + filteredRows.map((row, idx) => ( + + + + + + + )) + )} + +
received_attypeentity / oppayload
+ Wachten op events… trigger een mutatie via UI / script of klik "emit test event". +
+ {new Date(row.receivedAt).toISOString()} + + {row.type} + + {row.parsed?.entity ? ( + <> + {row.parsed.entity as string} + {row.parsed.op ? ` / ${row.parsed.op as string}` : ''} + {row.parsed.debug ? ' [debug]' : ''} + + ) : ( + '—' + )} + + {row.raw} +
+ + ) +} + +function ControlBar({ + status, + stats, + sinceLastEvent, + filterType, + setFilterType, + filterEntity, + setFilterEntity, + onEmit, + onReset, +}: { + status: 'connecting' | 'open' | 'closed' | 'error' + stats: Stats + sinceLastEvent: number | null + filterType: 'all' | RowType + setFilterType: (t: 'all' | RowType) => void + filterEntity: 'all' | 'task' | 'story' | 'debug' + setFilterEntity: (e: 'all' | 'task' | 'story' | 'debug') => void + onEmit: () => void + onReset: () => void +}) { + const heartbeatStale = sinceLastEvent !== null && sinceLastEvent > HEARTBEAT_WARN_MS + return ( +
+
+ + + + + 0 + ? `${(stats.largestGapMs / 1000).toFixed(1)}s` + : '—' + } + /> + +
+
+ + {stats.lastEmitResult && ( + {stats.lastEmitResult} + )} + + filter: + + +
+
+ ) +} + +function LayerToggles({ + dispatchToStore, + setDispatchToStore, + useFlushSync, + setUseFlushSync, + useViewTransition, + setUseViewTransition, +}: { + dispatchToStore: boolean + setDispatchToStore: (v: boolean) => void + useFlushSync: boolean + setUseFlushSync: (v: boolean) => void + useViewTransition: boolean + setUseViewTransition: (v: boolean) => void +}) { + return ( +
+ Layers: + + + +
+ ) +} + +function Stat({ label, value, color }: { label: string; value: string; color?: string }) { + return ( +
+ {label} + {value} +
+ ) +} + +function statusColor(status: 'connecting' | 'open' | 'closed' | 'error') { + switch (status) { + case 'open': + return 'green' + case 'error': + return 'red' + case 'closed': + return 'gray' + default: + return 'orange' + } +} + +function typeColor(type: RowType) { + switch (type) { + case 'message': + return '#0070cc' + case 'ready': + return 'green' + case 'error': + return 'red' + case 'open': + return '#888' + case 'close': + return '#888' + } +} + +function safeParse(raw: string): Record | undefined { + try { + return JSON.parse(raw) as Record + } catch { + return undefined + } +} diff --git a/app/debug-realtime/debug-store.ts b/app/debug-realtime/debug-store.ts new file mode 100644 index 0000000..32e3f65 --- /dev/null +++ b/app/debug-realtime/debug-store.ts @@ -0,0 +1,71 @@ +// Mini Zustand-store voor de debug-pagina. Mirrort het patroon van +// stores/solo-store.ts maar dan minimaal: alleen tasks-record + applyEvent. +// Doel: testen of de SSE-event → store → component-render keten werkt +// zonder de complexiteit van de echte Solo Paneel. + +import { create } from 'zustand' + +export interface DebugTask { + id: string + status: string + title: string + story_id: string + updated_at: string +} + +export interface DebugRealtimeEvent { + op: 'I' | 'U' | 'D' + entity: 'task' | 'story' + id: string + story_id?: string + task_status?: string + task_title?: string + debug?: boolean + emitted_at?: string + [key: string]: unknown +} + +interface DebugStore { + tasks: Record + applyCount: number + skipCount: number + applyEvent: (event: DebugRealtimeEvent) => void + reset: () => void +} + +export const useDebugStore = create((set, get) => ({ + tasks: {}, + applyCount: 0, + skipCount: 0, + + applyEvent: (event) => { + if (event.entity !== 'task') { + set((s) => ({ skipCount: s.skipCount + 1 })) + return + } + if (event.op === 'D') { + set((s) => { + const next = { ...s.tasks } + delete next[event.id] + return { tasks: next, applyCount: s.applyCount + 1 } + }) + return + } + // INSERT/UPDATE — schrijf altijd, ongeacht of de task al bestond + set((s) => ({ + tasks: { + ...s.tasks, + [event.id]: { + id: event.id, + status: event.task_status ?? get().tasks[event.id]?.status ?? '?', + title: event.task_title ?? get().tasks[event.id]?.title ?? '(no title)', + story_id: event.story_id ?? get().tasks[event.id]?.story_id ?? '?', + updated_at: new Date().toISOString(), + }, + }, + applyCount: s.applyCount + 1, + })) + }, + + reset: () => set({ tasks: {}, applyCount: 0, skipCount: 0 }), +})) diff --git a/app/debug-realtime/page.tsx b/app/debug-realtime/page.tsx new file mode 100644 index 0000000..4dc28f3 --- /dev/null +++ b/app/debug-realtime/page.tsx @@ -0,0 +1,23 @@ +// TIJDELIJKE debug-pagina voor M8-acceptance. +// Geen auth, geen styling — toont alle inkomende pg_notify-events op +// `scrum4me_changes` in een tabel zodat we kunnen zien of de SSE + LISTEN- +// pipe überhaupt events doorstroomt op Vercel. +// +// VERWIJDEREN VOOR M8 OUT-OF-DRAFT. + +import { DebugRealtimeClient } from './client' + +export const dynamic = 'force-dynamic' + +export default function DebugRealtimePage() { + return ( +
+

Realtime debug — scrum4me_changes

+

+ Live SSE-stream rechtstreeks van Postgres LISTEN op channel{' '} + scrum4me_changes. Geen auth, geen filtering. Verwijderen na M8 acceptance. +

+ +
+ ) +} diff --git a/app/debug-realtime/store-panel.tsx b/app/debug-realtime/store-panel.tsx new file mode 100644 index 0000000..35495ed --- /dev/null +++ b/app/debug-realtime/store-panel.tsx @@ -0,0 +1,111 @@ +'use client' + +import { useDebugStore } from './debug-store' + +export function StorePanel() { + const tasks = useDebugStore((s) => s.tasks) + const applyCount = useDebugStore((s) => s.applyCount) + const skipCount = useDebugStore((s) => s.skipCount) + const reset = useDebugStore((s) => s.reset) + + const taskList = Object.values(tasks) + + return ( +
+
+ Store layer: + + applyCount: {applyCount} + + + skipCount: {skipCount} + + + taskCount: {taskList.length} + + +
+ + + + + + + + + + + + {taskList.length === 0 ? ( + + + + ) : ( + taskList.map((t) => ( + + + + + + + )) + )} + +
idstatustitleupdated_at (lokaal)
+ Store is leeg. Trigger een event en kijk of de tabel hier vult. +
+ {t.id} + + {t.status} + {t.title} + {t.updated_at} +
+
+ ) +} + +function statusColor(status: string) { + switch (status) { + case 'TO_DO': + return '#888' + case 'IN_PROGRESS': + return '#0070cc' + case 'REVIEW': + return '#cc7a00' + case 'DONE': + return 'green' + default: + return 'inherit' + } +}