fix(M8): make SSE-stream survive Solo Paneel mutations
Symptoom op feat/ST-801-realtime-triggers initial implementation: elke task-update sloot de open SSE-stream af en triggerde een herverbinding met backoff. In de tussentijd gemiste events. Oorzaak: Server Actions in App Router doen een impliciete route-tree refresh die client components remount; daarmee killt React de useEffect die de EventSource beheert. Fix in twee delen: 1. Hef de realtime-hook op naar de (app)-layout via een nieuwe `SoloRealtimeBridge`-component. Layouts overleven Server- Action-refreshes beter dan pages, en de bridge leest het product-id uit de URL via usePathname. Connection-status (status, showConnectingIndicator) gaat naar de solo-store zodat SoloBoard 'm uit een gedeelde plek kan lezen. 2. Vervang updateTaskStatusAction en updateTaskPlanAction in de Solo-componenten door fetch naar de bestaande Route Handler `PATCH /api/tasks/[id]`. Route Handlers triggeren geen page-refresh, dus de SSE-stream blijft staan. lib/api-auth.ts accepteert nu naast Bearer-tokens ook iron-session cookies zodat browser-fetches zonder token werken. Bijkomend: actions/tasks.ts laat /solo bewust niet meer revalideren (wordt nu via realtime gedekt). Sprint/planning blijft wel revalidaten — geen realtime daar. Toegevoegd: - components/solo/realtime-bridge.tsx — mount in (app) layout - scripts/realtime-mutate.ts — handige test-helper voor externe mutaties (alsof MCP/REST schrijft) tijdens acceptance Debug-logs in app/api/realtime/solo/route.ts staan nog aan voor ST-806 acceptance; worden later gestript. Bekend issue: Chrome op localhost (HTTP/1.1) cycle't EventSource om de paar seconden vanwege de 6-connectie-limiet en retry- heuristiek. Safari werkt stabiel. Productie op Vercel (HTTP/2 multiplexing) zou beide browsers stabiel moeten houden — Vercel preview test is volgende stap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f12e50d8cb
commit
847fc84faf
10 changed files with 254 additions and 74 deletions
|
|
@ -101,8 +101,11 @@ export async function updateTaskStatusAction(id: string, status: 'TO_DO' | 'IN_P
|
|||
|
||||
await prisma.task.update({ where: { id }, data: { status } })
|
||||
|
||||
// /solo bewust niet revalideren: dat zou de page soft-navigaten en de
|
||||
// open SSE-stream sluiten. De Solo Paneel-flow leunt op optimistic
|
||||
// store-updates + realtime echo (M8). Sprint/planning heeft geen
|
||||
// realtime en moet wèl revalidaten.
|
||||
revalidatePath(`/products/${task.story.product_id}/sprint/planning`)
|
||||
revalidatePath(`/products/${task.story.product_id}/solo`)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
|
|
@ -150,7 +153,7 @@ export async function updateTaskPlanAction(taskId: string, productId: string, im
|
|||
data: { implementation_plan: implementationPlan || null },
|
||||
})
|
||||
|
||||
revalidatePath(`/products/${productId}/solo`)
|
||||
// /solo bewust niet revalideren — zie updateTaskStatusAction.
|
||||
revalidatePath(`/products/${productId}/sprint/planning`)
|
||||
return { success: true }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { prisma } from '@/lib/prisma'
|
|||
import { NavBar } from '@/components/shared/nav-bar'
|
||||
import { MinWidthBanner } from '@/components/shared/min-width-banner'
|
||||
import { StatusBar } from '@/components/shared/status-bar'
|
||||
import { SoloRealtimeBridge } from '@/components/solo/realtime-bridge'
|
||||
|
||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||
|
|
@ -47,6 +48,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
|||
{children}
|
||||
</main>
|
||||
<StatusBar />
|
||||
<SoloRealtimeBridge />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,10 +118,23 @@ export async function GET(request: NextRequest) {
|
|||
const sprint = await prisma_sprint_findActive(productId)
|
||||
const activeSprintId = sprint?.id ?? null
|
||||
|
||||
const isPooled = directUrl.includes('pooler.')
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log(
|
||||
`[realtime/solo] connecting (${isPooled ? 'POOLED — LISTEN may not work!' : 'direct'})`,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await pgClient.connect()
|
||||
await pgClient.query(`LISTEN ${CHANNEL}`)
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log(`[realtime/solo] LISTEN ${CHANNEL} ready`)
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.error('[realtime/solo] pg connect/listen failed:', err)
|
||||
}
|
||||
enqueue(`event: error\ndata: ${JSON.stringify({ message: 'pg connect failed' })}\n\n`)
|
||||
await cleanup('pg connect failed')
|
||||
return
|
||||
|
|
@ -135,7 +148,13 @@ export async function GET(request: NextRequest) {
|
|||
} catch {
|
||||
return
|
||||
}
|
||||
if (!shouldEmit(payload, productId, activeSprintId, userId)) return
|
||||
const emit = shouldEmit(payload, productId, activeSprintId, userId)
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log(
|
||||
`[realtime/solo] NOTIFY ${payload.entity}:${payload.id} ${payload.op} → ${emit ? 'EMIT' : 'skip'} (sprint=${payload.sprint_id} assignee=${payload.assignee_id} user=${userId})`,
|
||||
)
|
||||
}
|
||||
if (!emit) return
|
||||
enqueue(`data: ${msg.payload}\n\n`)
|
||||
})
|
||||
|
||||
|
|
|
|||
21
components/solo/realtime-bridge.tsx
Normal file
21
components/solo/realtime-bridge.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// SoloRealtimeBridge — mount in de (app)-layout zodat de SSE-verbinding
|
||||
// blijft staan over Server Action-refreshes van de Solo-page heen.
|
||||
//
|
||||
// Leest het huidige product-id uit de URL (`/products/[id]/solo`).
|
||||
// Wanneer de gebruiker niet op het Solo Paneel zit, wordt de stream
|
||||
// gesloten — geen onnodige verbinding open houden.
|
||||
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useSoloRealtime } from '@/lib/realtime/use-solo-realtime'
|
||||
|
||||
const SOLO_PATH_RE = /^\/products\/([^/]+)\/solo$/
|
||||
|
||||
export function SoloRealtimeBridge() {
|
||||
const pathname = usePathname()
|
||||
const match = pathname?.match(SOLO_PATH_RE)
|
||||
const productId = match?.[1] ?? null
|
||||
useSoloRealtime(productId)
|
||||
return null
|
||||
}
|
||||
|
|
@ -7,8 +7,8 @@ import {
|
|||
} from '@dnd-kit/core'
|
||||
import { toast } from 'sonner'
|
||||
import { useSoloStore } from '@/stores/solo-store'
|
||||
import { updateTaskStatusAction } from '@/actions/tasks'
|
||||
import { useSoloRealtime, type RealtimeStatus } from '@/lib/realtime/use-solo-realtime'
|
||||
import type { RealtimeStatus } from '@/stores/solo-store'
|
||||
import { taskStatusToApi } from '@/lib/task-status'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SoloColumn, type ColumnStatus } from './solo-column'
|
||||
|
|
@ -90,14 +90,14 @@ export function SoloBoard({
|
|||
productId, productName, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo,
|
||||
}: SoloBoardProps) {
|
||||
const { tasks, initTasks, optimisticMove, rollback, markPending, clearPending } = useSoloStore()
|
||||
const realtimeStatus = useSoloStore((s) => s.realtimeStatus)
|
||||
const showConnectingIndicator = useSoloStore((s) => s.showConnectingIndicator)
|
||||
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
||||
const [selectedTask, setSelectedTask] = useState<SoloTask | null>(null)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [unassignedStories, setUnassignedStories] = useState(initialUnassigned)
|
||||
const [, startTransition] = useTransition()
|
||||
|
||||
const { status: realtimeStatus, showConnectingIndicator } = useSoloRealtime(productId)
|
||||
|
||||
const taskKey = initialTasks.map(t => t.id).join(',')
|
||||
useEffect(() => {
|
||||
initTasks(initialTasks)
|
||||
|
|
@ -137,15 +137,29 @@ export function SoloBoard({
|
|||
// Onderdruk realtime-echo van onze eigen write — de Postgres-trigger
|
||||
// vuurt en die NOTIFY komt zo terug via SSE; zonder pending-marker
|
||||
// zou de store nogmaals een set() doen of de optimistic state
|
||||
// overschrijven. clearPending na de Server Action (succes of fail).
|
||||
// overschrijven. clearPending na de fetch (succes of fail).
|
||||
//
|
||||
// We gebruiken bewust een fetch-based Route Handler in plaats van
|
||||
// de updateTaskStatusAction Server Action — Server Actions
|
||||
// triggeren een full route-tree refresh die de open SSE-stream van
|
||||
// /api/realtime/solo zou afkappen, waardoor we elke 5s reconnecten
|
||||
// en realtime-events missen.
|
||||
markPending(taskId)
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await updateTaskStatusAction(taskId, toStatus)
|
||||
if (result && 'error' in result) {
|
||||
const res = await fetch(`/api/tasks/${taskId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ status: taskStatusToApi(toStatus) }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
rollback(taskId, prevStatus)
|
||||
toast.error('Status bijwerken mislukt — taak teruggeplaatst')
|
||||
}
|
||||
} catch {
|
||||
rollback(taskId, prevStatus)
|
||||
toast.error('Status bijwerken mislukt — taak teruggeplaatst')
|
||||
} finally {
|
||||
clearPending(taskId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import { Badge } from '@/components/ui/badge'
|
|||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||
import { useSoloStore } from '@/stores/solo-store'
|
||||
import { updateTaskPlanAction } from '@/actions/tasks'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { SoloTask } from './solo-board'
|
||||
|
||||
|
|
@ -56,16 +55,29 @@ function TaskDetailContent({ task, productId, isDemo, onClose }: TaskDetailConte
|
|||
setSaveState('saving')
|
||||
if (fadeTimer.current) clearTimeout(fadeTimer.current)
|
||||
|
||||
// fetch naar Route Handler i.p.v. Server Action — Server Actions
|
||||
// kappen anders de open SSE-stream van het Solo Paneel af. Zie
|
||||
// notitie in solo-board.tsx handleDragEnd.
|
||||
startTransition(async () => {
|
||||
const result = await updateTaskPlanAction(task.id, productId, localPlan)
|
||||
if (result && 'error' in result) {
|
||||
setSaveState('idle')
|
||||
toast.error(typeof result.error === 'string' ? result.error : 'Implementatieplan opslaan mislukt')
|
||||
} else {
|
||||
try {
|
||||
const res = await fetch(`/api/tasks/${task.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ implementation_plan: localPlan }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
setSaveState('idle')
|
||||
toast.error('Implementatieplan opslaan mislukt')
|
||||
return
|
||||
}
|
||||
savedPlanRef.current = localPlan
|
||||
updatePlan(task.id, localPlan || null)
|
||||
setSaveState('saved')
|
||||
fadeTimer.current = setTimeout(() => setSaveState('idle'), 2000)
|
||||
} catch {
|
||||
setSaveState('idle')
|
||||
toast.error('Implementatieplan opslaan mislukt')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,40 @@
|
|||
import { createHash } from 'crypto'
|
||||
import { cookies } from 'next/headers'
|
||||
import { getIronSession } from 'iron-session'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { sessionOptions, type SessionData } from '@/lib/session'
|
||||
|
||||
// Probeert eerst Bearer-token (REST/MCP), valt terug op iron-session
|
||||
// cookie (browser fetches vanuit ingelogde sessie). Cookie-pad is bewust
|
||||
// voor Solo Paneel-mutations die anders via Server Action zouden gaan —
|
||||
// maar Server Actions triggeren een page-refresh die SSE-streams sluit.
|
||||
export async function authenticateApiRequest(request: Request) {
|
||||
const authHeader = request.headers.get('Authorization')
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return { error: 'Unauthorized', status: 401 as const }
|
||||
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.slice(7)
|
||||
const tokenHash = createHash('sha256').update(token).digest('hex')
|
||||
|
||||
const apiToken = await prisma.apiToken.findUnique({
|
||||
where: { token_hash: tokenHash },
|
||||
include: { user: true },
|
||||
})
|
||||
|
||||
if (!apiToken || apiToken.revoked_at) {
|
||||
return { error: 'Unauthorized', status: 401 as const }
|
||||
}
|
||||
return { userId: apiToken.user_id, isDemo: apiToken.user.is_demo }
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7)
|
||||
const tokenHash = createHash('sha256').update(token).digest('hex')
|
||||
|
||||
const apiToken = await prisma.apiToken.findUnique({
|
||||
where: { token_hash: tokenHash },
|
||||
include: { user: true },
|
||||
})
|
||||
|
||||
if (!apiToken || apiToken.revoked_at) {
|
||||
return { error: 'Unauthorized', status: 401 as const }
|
||||
// Geen Bearer — probeer iron-session cookie
|
||||
try {
|
||||
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||
if (session.userId) {
|
||||
return { userId: session.userId, isDemo: session.isDemo ?? false }
|
||||
}
|
||||
} catch {
|
||||
// cookies() outside of request-scope kan throwen — laat door naar 401
|
||||
}
|
||||
|
||||
return { userId: apiToken.user_id, isDemo: apiToken.user.is_demo }
|
||||
return { error: 'Unauthorized', status: 401 as const }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +1,47 @@
|
|||
// ST-803: client-side hook voor de Solo Paneel realtime stream.
|
||||
// ST-803 + ST-805 (refactor in ST-806-acceptance): client-side hook die de
|
||||
// Solo Paneel realtime stream beheert.
|
||||
//
|
||||
// - Opent EventSource('/api/realtime/solo?product_id=...')
|
||||
// - Reconnect met exponential backoff (1s → 30s, reset bij ready)
|
||||
// - Pauseert bij document.visibilityState === 'hidden', resumes bij 'visible'
|
||||
// - Cleanup op unmount
|
||||
// - Dispatcht events naar de solo-store via handleRealtimeEvent
|
||||
//
|
||||
// State exposed:
|
||||
// status: 'connecting' | 'open' | 'disconnected'
|
||||
// showConnectingIndicator: true zodra status !== 'open' langer dan 2s duurt
|
||||
// (UI gebruikt dit zodat micro-disconnects geen flikker veroorzaken)
|
||||
// - Mount in de (app)-layout via SoloRealtimeBridge zodat hij Server Action-
|
||||
// refreshes overleeft (anders kapt Next.js' soft-navigation de SSE).
|
||||
// - Opent EventSource('/api/realtime/solo?product_id=...') wanneer
|
||||
// productId niet null is; sluit de stream als productId null wordt.
|
||||
// - Reconnect met exponential backoff (1s → 30s, reset bij ready).
|
||||
// - Pauseert bij document.visibilityState === 'hidden', resumes bij visible.
|
||||
// - Cleanup op unmount.
|
||||
// - Connection-status (status, showConnectingIndicator) wordt naar de
|
||||
// solo-store geschreven; UI-componenten lezen daar uit.
|
||||
// - Dispatcht events naar de solo-store via handleRealtimeEvent. Task-
|
||||
// updates worden in document.startViewTransition + flushSync gewikkeld
|
||||
// zodat het kanban-kaartje soepel naar zijn nieuwe kolom animeert
|
||||
// (animatie A — vereist view-transition-name op de cards).
|
||||
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { useSoloStore } from '@/stores/solo-store'
|
||||
import type { RealtimeEvent } from '@/stores/solo-store'
|
||||
|
||||
export type RealtimeStatus = 'connecting' | 'open' | 'disconnected'
|
||||
import type { RealtimeEvent, RealtimeStatus } from '@/stores/solo-store'
|
||||
|
||||
const BACKOFF_START_MS = 1_000
|
||||
const BACKOFF_MAX_MS = 30_000
|
||||
const CONNECTING_INDICATOR_DELAY_MS = 2_000
|
||||
|
||||
export function useSoloRealtime(productId: string) {
|
||||
const [status, setStatus] = useState<RealtimeStatus>('connecting')
|
||||
const [showConnectingIndicator, setShowConnectingIndicator] = useState(false)
|
||||
|
||||
// Refs voor lifecycle die ge-survival moeten zijn over re-renders
|
||||
export function useSoloRealtime(productId: string | null) {
|
||||
const sourceRef = useRef<EventSource | null>(null)
|
||||
const backoffRef = useRef<number>(BACKOFF_START_MS)
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const indicatorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const setStatus = useSoloStore.getState().setRealtimeStatus
|
||||
const handleEvent = useSoloStore.getState().handleRealtimeEvent
|
||||
|
||||
if (!productId) {
|
||||
// Geen actief product (gebruiker zit niet op /solo) — stream uit
|
||||
setStatus('disconnected', false)
|
||||
return
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
if (sourceRef.current) {
|
||||
sourceRef.current.close()
|
||||
|
|
@ -54,17 +59,19 @@ export function useSoloRealtime(productId: string) {
|
|||
indicatorTimerRef.current = null
|
||||
}
|
||||
if (next === 'open') {
|
||||
setShowConnectingIndicator(false)
|
||||
setStatus('open', false)
|
||||
} else {
|
||||
// Status meteen bijwerken, indicator pas na 2s — voorkomt flikker
|
||||
// bij microscopische disconnects.
|
||||
setStatus(next, false)
|
||||
indicatorTimerRef.current = setTimeout(() => {
|
||||
setShowConnectingIndicator(true)
|
||||
setStatus(useSoloStore.getState().realtimeStatus, true)
|
||||
}, CONNECTING_INDICATOR_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
const connect = () => {
|
||||
close()
|
||||
setStatus('connecting')
|
||||
scheduleIndicator('connecting')
|
||||
|
||||
const source = new EventSource(
|
||||
|
|
@ -74,7 +81,6 @@ export function useSoloRealtime(productId: string) {
|
|||
|
||||
source.addEventListener('ready', () => {
|
||||
backoffRef.current = BACKOFF_START_MS
|
||||
setStatus('open')
|
||||
scheduleIndicator('open')
|
||||
})
|
||||
|
||||
|
|
@ -82,13 +88,11 @@ export function useSoloRealtime(productId: string) {
|
|||
if (!e.data) return
|
||||
try {
|
||||
const payload = JSON.parse(e.data) as RealtimeEvent
|
||||
// ST-805 animatie A: kanban-move animeren via View Transitions API.
|
||||
// Voor task UPDATE-events wrap'en we de store-update in een view
|
||||
// transition. De browser snapshot't de DOM voor en na, en animeert
|
||||
// het verschil — vereist `view-transition-name` op de cards.
|
||||
// Andere events (task INSERT/DELETE, story-events) krijgen geen
|
||||
// animatie; die zijn niet zichtbaar als positie-shift in de
|
||||
// kanban-kolommen.
|
||||
// Animatie A: kanban-move animeren via View Transitions API. Voor
|
||||
// task UPDATE-events wrap'en we de store-update in een view
|
||||
// transition. flushSync forceert React om synchroon te renderen
|
||||
// tijdens de transition-callback zodat de nieuwe DOM-state wordt
|
||||
// gesnapshot voor de animatie.
|
||||
const animate =
|
||||
payload.entity === 'task' &&
|
||||
payload.op === 'U' &&
|
||||
|
|
@ -96,10 +100,6 @@ export function useSoloRealtime(productId: string) {
|
|||
typeof (document as Document & { startViewTransition?: unknown }).startViewTransition ===
|
||||
'function'
|
||||
if (animate) {
|
||||
// flushSync forceert React om de re-render synchroon te doen
|
||||
// tijdens de view-transition callback, zodat de nieuwe DOM-state
|
||||
// wordt gesnapshot voor de animatie. Zonder flushSync rendert
|
||||
// React asynchroon en captured de browser nog de oude state.
|
||||
;(
|
||||
document as Document & {
|
||||
startViewTransition: (cb: () => void) => unknown
|
||||
|
|
@ -118,17 +118,11 @@ export function useSoloRealtime(productId: string) {
|
|||
}
|
||||
|
||||
source.onerror = () => {
|
||||
// EventSource probeert standaard zelf te reconnecten, maar we willen
|
||||
// controle over backoff + skip-on-hidden. Dus close + plan zelf.
|
||||
if (sourceRef.current !== source) return
|
||||
close()
|
||||
setStatus('disconnected')
|
||||
scheduleIndicator('disconnected')
|
||||
|
||||
if (document.visibilityState === 'hidden') {
|
||||
// Niet retryen tot tab weer zichtbaar wordt
|
||||
return
|
||||
}
|
||||
if (document.visibilityState === 'hidden') return
|
||||
const delay = backoffRef.current
|
||||
backoffRef.current = Math.min(backoffRef.current * 2, BACKOFF_MAX_MS)
|
||||
reconnectTimerRef.current = setTimeout(connect, delay)
|
||||
|
|
@ -138,7 +132,6 @@ export function useSoloRealtime(productId: string) {
|
|||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
close()
|
||||
setStatus('disconnected')
|
||||
scheduleIndicator('disconnected')
|
||||
} else if (sourceRef.current === null) {
|
||||
backoffRef.current = BACKOFF_START_MS
|
||||
|
|
@ -157,6 +150,4 @@ export function useSoloRealtime(productId: string) {
|
|||
close()
|
||||
}
|
||||
}, [productId])
|
||||
|
||||
return { status, showConnectingIndicator }
|
||||
}
|
||||
|
|
|
|||
81
scripts/realtime-mutate.ts
Normal file
81
scripts/realtime-mutate.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// Test-helper voor M8 acceptatie. Muteert een task of story rechtstreeks
|
||||
// in de DB om realtime-events te triggeren — alsof MCP of een andere
|
||||
// schrijver het zou doen. Niet voor productiegebruik.
|
||||
//
|
||||
// Gebruik:
|
||||
// tsx scripts/realtime-mutate.ts move <taskId> <status>
|
||||
// tsx scripts/realtime-mutate.ts touch task <taskId>
|
||||
// tsx scripts/realtime-mutate.ts touch story <storyId>
|
||||
// tsx scripts/realtime-mutate.ts rename story <storyId> <new title>
|
||||
// tsx scripts/realtime-mutate.ts list-tasks # toont id + status van assigned tasks
|
||||
|
||||
import * as dotenv from 'dotenv'
|
||||
import * as path from 'path'
|
||||
import { Pool } from 'pg'
|
||||
|
||||
const root = path.resolve(__dirname, '..')
|
||||
dotenv.config({ path: path.join(root, '.env.local'), override: true })
|
||||
dotenv.config({ path: path.join(root, '.env') })
|
||||
|
||||
async function main() {
|
||||
const url = process.env.DATABASE_URL
|
||||
if (!url) throw new Error('DATABASE_URL is not set')
|
||||
const pool = new Pool({ connectionString: url })
|
||||
|
||||
const [, , cmd, ...rest] = process.argv
|
||||
|
||||
try {
|
||||
if (cmd === 'move') {
|
||||
const [taskId, status] = rest
|
||||
if (!taskId || !status) throw new Error('move requires <taskId> <status>')
|
||||
const r = await pool.query(
|
||||
'UPDATE tasks SET status = $1::"TaskStatus", updated_at = NOW() WHERE id = $2 RETURNING id, status',
|
||||
[status, taskId],
|
||||
)
|
||||
console.log('moved:', r.rows[0])
|
||||
} else if (cmd === 'touch') {
|
||||
const [entity, id] = rest
|
||||
if (entity !== 'task' && entity !== 'story') throw new Error('touch entity must be task or story')
|
||||
const table = entity === 'task' ? 'tasks' : 'stories'
|
||||
const r = await pool.query(
|
||||
`UPDATE ${table} SET updated_at = NOW() WHERE id = $1 RETURNING id`,
|
||||
[id],
|
||||
)
|
||||
console.log('touched:', r.rows[0])
|
||||
} else if (cmd === 'rename') {
|
||||
const [entity, id, ...titleParts] = rest
|
||||
const title = titleParts.join(' ')
|
||||
if (entity !== 'story') throw new Error('rename only supported for story for now')
|
||||
if (!id || !title) throw new Error('rename requires <id> <new title>')
|
||||
const r = await pool.query(
|
||||
'UPDATE stories SET title = $1, updated_at = NOW() WHERE id = $2 RETURNING id, title',
|
||||
[title, id],
|
||||
)
|
||||
console.log('renamed:', r.rows[0])
|
||||
} else if (cmd === 'list-tasks') {
|
||||
const r = await pool.query(`
|
||||
SELECT t.id, t.title, t.status, s.code AS story_code, s.title AS story_title
|
||||
FROM tasks t
|
||||
JOIN stories s ON t.story_id = s.id
|
||||
WHERE s.assignee_id IS NOT NULL
|
||||
ORDER BY s.sort_order, t.sort_order
|
||||
LIMIT 20
|
||||
`)
|
||||
console.table(r.rows)
|
||||
} else {
|
||||
console.error('Usage:')
|
||||
console.error(' tsx scripts/realtime-mutate.ts move <taskId> <TO_DO|IN_PROGRESS|REVIEW|DONE>')
|
||||
console.error(' tsx scripts/realtime-mutate.ts touch task|story <id>')
|
||||
console.error(' tsx scripts/realtime-mutate.ts rename story <id> <new title>')
|
||||
console.error(' tsx scripts/realtime-mutate.ts list-tasks')
|
||||
process.exit(1)
|
||||
}
|
||||
} finally {
|
||||
await pool.end()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err.message)
|
||||
process.exit(1)
|
||||
})
|
||||
|
|
@ -27,6 +27,8 @@ export interface RealtimeEvent {
|
|||
changed_fields?: string[]
|
||||
}
|
||||
|
||||
export type RealtimeStatus = 'connecting' | 'open' | 'disconnected'
|
||||
|
||||
interface SoloStore {
|
||||
tasks: Record<string, SoloTask>
|
||||
/** Task-ids die op dit moment een eigen optimistic write in de lucht hebben.
|
||||
|
|
@ -34,6 +36,12 @@ interface SoloStore {
|
|||
* twee keer toegepast wordt of door een latere echo overschreven. */
|
||||
pendingOps: Set<string>
|
||||
|
||||
/** Realtime-connection state, beheerd door useSoloRealtime in de
|
||||
* (app)-layout. Hier in de store omdat de UI-indicator in SoloBoard zit en
|
||||
* de hook niet direct in dezelfde subtree draait. */
|
||||
realtimeStatus: RealtimeStatus
|
||||
showConnectingIndicator: boolean
|
||||
|
||||
initTasks: (tasks: SoloTask[]) => void
|
||||
optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null
|
||||
rollback: (taskId: string, prevStatus: TaskStatus) => void
|
||||
|
|
@ -42,12 +50,16 @@ interface SoloStore {
|
|||
markPending: (taskId: string) => void
|
||||
clearPending: (taskId: string) => void
|
||||
|
||||
setRealtimeStatus: (status: RealtimeStatus, showConnectingIndicator: boolean) => void
|
||||
|
||||
handleRealtimeEvent: (event: RealtimeEvent) => void
|
||||
}
|
||||
|
||||
export const useSoloStore = create<SoloStore>((set, get) => ({
|
||||
tasks: {},
|
||||
pendingOps: new Set<string>(),
|
||||
realtimeStatus: 'connecting',
|
||||
showConnectingIndicator: false,
|
||||
|
||||
initTasks: (tasks) =>
|
||||
set({ tasks: Object.fromEntries(tasks.map(t => [t.id, t])) }),
|
||||
|
|
@ -81,6 +93,14 @@ export const useSoloStore = create<SoloStore>((set, get) => ({
|
|||
return { pendingOps: next }
|
||||
}),
|
||||
|
||||
setRealtimeStatus: (status, showConnectingIndicator) =>
|
||||
set((s) => {
|
||||
if (s.realtimeStatus === status && s.showConnectingIndicator === showConnectingIndicator) {
|
||||
return s
|
||||
}
|
||||
return { realtimeStatus: status, showConnectingIndicator }
|
||||
}),
|
||||
|
||||
handleRealtimeEvent: (event) => {
|
||||
if (event.entity === 'task') {
|
||||
const { id, op } = event
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue