--- title: "Workspace-store + realtime — bounded-context patroon" status: active audience: [ai-agent, contributor] language: nl last_updated: 2026-05-10 when_to_read: "When adding a new bounded-context client store backed by SSE, or when modifying product/sprint workspace state." --- # Patroon: workspace-store + realtime Sinds PBI-74 is `product-workspace-store` de blueprint voor client-state op een **bounded context** (één coherente workflow). Andere bounded contexts mogen hetzelfde patroon volgen — `sprint-workspace-store`, `solo-store`, `notifications-store`. Dit document beschrijft wanneer je een workspace-store opzet, hoe je 'm structureert, hoe SSE en de store samenwerken, en welke gotchas in code-comments hoort. Bron-ontwerp: [zustand-store-rearchitecture.md](../plans/zustand-store-rearchitecture.md). Referentie-implementatie: [stores/product-workspace/](../../stores/product-workspace/). --- ## Wanneer een workspace-store Eén store **per bounded context**, niet per pagina en niet één megastore. | Workflow | Store | |---|---| | Product backlog (PBI/story/task selectie + DnD) | `product-workspace-store` | | Sprint board | `sprint-workspace-store` (toekomstig, PBI > 74) | | Solo execution | `solo-store` | | Notifications/questions | `notifications-store` | | Idea grill/plan-flow | `idea-store` | | Lijst van producten | `products-store` (≠ active product) | Splits niet per panel; bundel niet over workflows. --- ## State-shape Vlak en **genormaliseerd**. Vijf slices: ```ts { context: { active*Id } // huidige selectie entities: { *ById } // entity-maps per kind relations: { ids[], idsByParent } // gesorteerde id-lijsten loading: { loaded*Ids, activeRequestId } // race-safe markers sync: { realtimeStatus, lastResyncAt, resyncReason } pendingMutations: { [id]: { mutation, createdAt } } } ``` **Acties** zijn in dezelfde store: `hydrateSnapshot`, `setActive*`, `ensure*Loaded`, `applyRealtimeEvent`, `resyncActiveScopes`, `resyncLoadedScopes`, `applyOptimisticMutation`/`rollbackMutation`/`settleMutation`. Gebruik `zustand/middleware/immer`. Mutation-style (G3 — return nooit een nieuwe state uit een immer-recipe; muteer de draft). --- ## Selectors Module-level **`EMPTY`**-refs (G1) en `useShallow` voor lijsten (G2). ```ts // stores/product-workspace/selectors.ts const EMPTY_PBIS: BacklogPbi[] = [] export function selectVisiblePbis(s: Store): BacklogPbi[] { if (s.relations.pbiIds.length === 0) return EMPTY_PBIS return s.relations.pbiIds.map((id) => s.entities.pbisById[id]).filter(Boolean) } ``` ```tsx // component import { useShallow } from 'zustand/react/shallow' import { selectVisiblePbis } from '@/stores/product-workspace/selectors' const pbis = useStore(useShallow(selectVisiblePbis)) const activePbiId = useStore((s) => s.context.activePbiId) // primitive — geen useShallow ``` Single-value selectors (`selectActivePbi`) hebben geen `useShallow` nodig. --- ## ensure*Loaded — race-safe loaders Elke setter genereert een nieuwe `requestId`, schrijft 'm in `loading.activeRequestId`, en triggert de loader. De loader checkt **na de fetch** of de guard nog matcht — anders bail-out. ```ts setActivePbi(pbiId) { const requestId = newRequestId() set((s) => { s.context.activePbiId = pbiId s.context.activeStoryId = null s.context.activeTaskId = null s.loading.activeRequestId = requestId }) if (pbiId) void get().ensurePbiLoaded(pbiId, requestId) } async ensurePbiLoaded(pbiId, requestId) { const stories = await fetchJson(`/api/pbis/${pbiId}/stories`) if (requestId && get().loading.activeRequestId !== requestId) return if (!Array.isArray(stories)) return set((s) => { /* apply */ }) } ``` **Belangrijke regels:** - Gebruik `get().method()` per call (G4) — nooit `state.method()` via een gecaptured snapshot. Method-refs zijn niet stabiel over immer state-versies. - `fetch(url, { cache: 'no-store' })` op alle client-fetches. - Server read-routes: `export const dynamic = 'force-dynamic'`. --- ## SSE-hook beheert transport, store beheert betekenis ```txt useXxxRealtime(activeId) -> opent /api/realtime/xxx?... -> parsed event -> dispatcht naar store.applyRealtimeEvent(event) -> beheert reconnect/backoff/status -> op 'ready' na (re)connect: telt cycles; latere ready triggert resync('reconnect') ``` ```ts // applyRealtimeEvent regels known pbi/story/task event → upsert + sort, parent-move bij wijziging parent_id → idempotent: bestaande id bij INSERT → return → DELETE → ruim child entities op + clear actieve selectie als die viel unknown entity met matching product_id, geen 'type' veld → resyncActiveScopes('unknown-event') job/worker/heartbeat (heeft 'type' veld) → negeer ``` **Idempotent:** een event dat al via een optimistic mutation is toegepast, mag geen dubbele insert of verkeerde rollback veroorzaken. INSERTs checken `if (entity exists) return`. UPDATEs zijn altijd merge-into-existing. Payload-contract: zie [realtime-notify-payload.md](./realtime-notify-payload.md). --- ## Hidden tab + reconnect resync EventSource blijft open als de tab `hidden` wordt — gemiste events worden opgehaald via een expliciete resync-laag. ```ts // In de realtime-hook const onVisibility = () => { if (document.visibilityState === 'visible' && sourceRef.current === null) { connect() // alleen als de stream weg is (b.v. server hard-close na 240s) } } // Geen close() bij hidden. source.addEventListener('ready', () => { readyCountRef.current += 1 if (readyCountRef.current > 1) { void store.resyncActiveScopes('reconnect') } }) ``` ```ts // useWorkspaceResync — visibility + online useEffect(() => { const onVisibility = () => { if (document.visibilityState === 'visible') { void store.resyncActiveScopes('visible') } } const onOnline = () => void store.resyncActiveScopes('reconnect') document.addEventListener('visibilitychange', onVisibility) window.addEventListener('online', onOnline) return () => { /* remove */ } }, []) ``` **Mount in dezelfde wrapper als de realtime-hook.** Doe nooit alleen het sluiten-op-hidden weghalen zonder de resync-laag erbij — dan verlies je het vangnet. `resyncActiveScopes` triggert alleen de loaders die gekoppeld zijn aan de huidige selectie: ```ts async resyncActiveScopes(reason) { const ctx = get().context const tasks: Promise[] = [] if (ctx.activeProduct?.id) tasks.push(get().ensureProductLoaded(ctx.activeProduct.id)) if (ctx.activePbiId) tasks.push(get().ensurePbiLoaded(ctx.activePbiId)) if (ctx.activeStoryId) tasks.push(get().ensureStoryLoaded(ctx.activeStoryId)) if (ctx.activeTaskId) tasks.push(get().ensureTaskLoaded(ctx.activeTaskId)) set((s) => { s.sync.lastResyncAt = Date.now(); s.sync.resyncReason = reason }) await Promise.allSettled(tasks) } ``` --- ## LocalStorage = restore-hint, niet waarheid Selectie-id's worden gepersisteerd om bij cold reload de vorige selectie te herstellen, **maar de hint wordt pas toegepast nadat ensure-load is gelukt en de hint-id bevestigd is in `entities.byId`**. ```ts setActiveProduct(product) { set((s) => { s.context.activeProduct = product; ... }) writeProductHint(product?.id ?? null) if (product) { void (async () => { await get().ensureProductLoaded(product.id, requestId) if (get().loading.activeRequestId !== requestId) return const hint = readHints().perProduct[product.id]?.lastActivePbiId if (hint && get().entities.pbisById[hint]) { get().setActivePbi(hint) // cascade — die doet zelfde voor story } })() } } ``` **Geen `setTimeout(0)` of microtask-trick.** De fetch is dan nog niet klaar, de validatie `entities.byId[hint]` faalt altijd. Chain altijd `await ensureXxxLoaded` en valideer in dezelfde `requestId`-cycle. **URL wint van hint.** Maak een client-component (b.v. [`UrlTaskSync`](../../components/backlog/url-task-sync.tsx)) die op mount `useSearchParams().get('editTask')` leest, de hint overschrijft via `writeTaskHint`, en `setActiveTask` aanroept. De restore-flow leest de task-hint pas na drie ensure-awaits, dus de URL-write komt altijd eerder. --- ## Optimistic mutations Voor DnD en status-toggles. De store registreert alleen het rollback-snapshot; de component muteert state direct én roept de server aan. ```tsx function handleDragEnd(event) { const store = useStore.getState() const prevOrder = [...store.relations.pbiIds] const newOrder = arrayMove(prevOrder, oldIndex, newIndex) // 1. Snapshot voor rollback const mutationId = store.applyOptimisticMutation({ kind: 'pbi-order', prevPbiIds: prevOrder, }) // 2. Optimistisch toepassen useStore.setState((s) => { s.relations.pbiIds = newOrder }) // 3. Server bevestigt (of niet) startTransition(async () => { const result = await reorderPbisAction(productId, newOrder) const st = useStore.getState() if (result.success) { st.settleMutation(mutationId) } else { st.rollbackMutation(mutationId) toast.error('Volgorde opslaan mislukt') } }) } ``` **Cross-priority drag** vereist twee mutaties: een `pbi-order` voor de lijst plus een `entity-patch` voor de priority op de PBI zelf. Beide settle/rollback samen. **SSE-echo van een net optimistisch toegepaste wijziging** moet idempotent zijn — INSERT → bestaat al → return; UPDATE → merge into existing. --- ## API endpoints Voor elke `ensure*Loaded` een GET-route met: - Auth via `authenticateApiRequest` (Bearer-token of iron-session cookie). - Access-control via `productAccessFilter(userId)` voor product-context; `getAccessibleProduct` voor explicit guards. - `export const dynamic = 'force-dynamic'`. - Status-vertaling via `taskStatusToApi` / `storyStatusToApi` / `pbiStatusToApi` (DB UPPER_SNAKE → API lowercase). Referentie: [GET /api/products/:id/backlog](../../app/api/products/[id]/backlog/route.ts), [GET /api/pbis/:id/stories](../../app/api/pbis/[id]/stories/route.ts), [GET /api/stories/:id/tasks](../../app/api/stories/[id]/tasks/route.ts), [GET /api/tasks/:id](../../app/api/tasks/[id]/route.ts). `TaskDetail` shape extends `BacklogTask` met `_detail: true` plus extra velden (`implementation_plan`, `acceptance_criteria`, `requires_opus`, `verify_only`, `verify_required`). Gebruik de `isDetail()` typeguard om de extra velden te tonen. --- ## Tests Vitest + jsdom; setup in [`tests/setup.ts`](../../tests/setup.ts): - `MemoryStorage` shim voor localStorage (G6 — vitest 4 + jsdom 29 mist 'm als configurable global). - `globalThis.fetch` herconfigureerbaar gemaakt zodat `vi.spyOn` werkt (anders krijg je `Cannot redefine property: fetch`). - Default fetch-stub die `null` JSON returnt — voorkomt unhandled rejections uit fire-and-forget `ensure*Loaded` calls die in tests niet expliciet gemockt zijn. Tests overrulen met `vi.spyOn(globalThis, 'fetch')` per case. - `mockImplementation` (G8) — niet `mockResolvedValue` — anders is de Response-body na de eerste `.json()` weg. ```ts // G5: snapshot original actions module-level, restore in beforeEach const originalActions = (() => { const s = useStore.getState() return { /* alle action-refs */ } })() function resetStore() { useStore.setState((s) => { Object.assign(s, initialDataSlices) Object.assign(s, originalActions) }) } beforeEach(resetStore) ``` **Acties mocken:** gebruik `setState((s) => { s.method = vi.fn() })`. Niet `vi.spyOn(state, 'method')` — de immer-frozen state is niet redefinable. **Verplichte test-cases per workspace-store:** - `hydrateSnapshot` vult entities + relations met sortering. - Selection cascade: `setActivePbi` reset story+task; `setActiveStory` reset task. - `setActiveProduct(null)` ruimt entities en relations op. - `applyRealtimeEvent` pbi/story/task `I|U|D` met sortering en parent-move. - Event voor ander `product_id` wordt genegeerd. - Unknown entity met matching product → `resyncActiveScopes('unknown-event')` trigger. - Job/worker/heartbeat/question events met `type`-veld → geen resync. - Delete-cleanup van actieve selectie. - Race-safe `ensure*Loaded` met requestId-guard (oude in-flight mag niet nieuwere selectie overschrijven). - `ensureTaskLoaded` zet `_detail: true`. - `resyncActiveScopes` triggert ensure-keten met juiste URLs en zet `lastResyncAt` + `resyncReason`. - localStorage restore-hints per setter. - Hint die niet (meer) in entities zit wordt genegeerd. - Optimistic mutation rollback/settle/SSE-echo idempotent. --- ## Gotchas — comment-template voor in code Documenteer deze in code via comments boven de fix. | # | Symptoom | Fix | |---|---|---| | **G1** | "Maximum update depth exceeded" — `s.byId[x] ?? []` levert nieuwe array per render | Module-level `EMPTY` const als fallback | | **G2** | Component re-rendert op iedere store-mutatie ondanks dat z'n data niet wijzigt | `useShallow(selectXxx)` voor lijsten | | **G3** | Hele state lijkt gewist na een `setState((s) => ({ context: ... }))` | Gebruik mutation-style: `setState((s) => { s.context.x = y })` (immer recipe muteert draft) | | **G4** | "method is not a function" in async context, of inconsistente state-mutaties | `get().method()` per call; nooit `const m = state.method` cachen | | **G5** | Tests beïnvloeden elkaar via `setState({ resyncActiveScopes: vi.fn() })` | `originalActions` snapshot op module-load + restore in `beforeEach` | | **G6** | `localStorage.clear is not a function` in vitest | `MemoryStorage` shim in `tests/setup.ts` | | **G7** | "Failed to parse URL from /api/..." in test-fetch | Mock fetch via `vi.spyOn(globalThis, 'fetch')` of stub in setup | | **G8** | "Body is unusable: Body has already been read" | `vi.fn().mockImplementation(() => Promise.resolve(new Response(...)))` — niet `mockResolvedValue` met een vooraf-gemaakte Response | --- ## Migratiepad voor een nieuwe workspace-store Volg dezelfde 8 stappen als PBI-74 (zie [zustand-workspace-store-implementation.md](../plans/zustand-workspace-store-implementation.md)): 1. Skelet — types, store, selectors, restore + tests; geen UI-impact. 2. Hydratie overstappen (parallel naast bestaande store). 3. Componenten omzetten — `useShallow` voor lijsten, `setActiveX` setters. 4. Race-safe loaders + restore-hints + URL-prioriteit. 5. Hidden-tab + reconnect-resync (één PR — anders verlies je vangnet). 6. Unknown-event filter (`isUnknownEntityEvent`). 7. Cache-headers + LIST-endpoints (`force-dynamic`, `cache: 'no-store'`). 8. Oude store opruimen. Stap 9 ("sprint-workspace-store") is de toepassing van dit patroon op de sprint-flow — kan starten zodra `product-workspace-store` enkele weken stabiel in productie staat.