Scrum4Me/docs/plans/zustand-store-rearchitecture.md
Madhura68 0d126695db docs: add plans and recommendations
- docs/plans/Local github setup.md
- docs/plans/lees-de-readme-md-validated-book.md
- docs/plans/zustand-store-rearchitecture.md
- docs/recommendations/beelink-ubuntu-scrum4me-server-caveman-plan.md
- docs/recommendations/claude-vm-job-flow-git-strategy.md
- docs/INDEX.md updated

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:54:23 +02:00

24 KiB

title status audience language last_updated revision
Zustand store rearchitecture - active context, realtime en resync ready-to-execute
maintainer
contributor
ai-agent
nl 2026-05-09 3

Zustand store rearchitecture

Doel: de client-state van Scrum4Me voorspelbaar houden terwijl de app groeit. De database blijft de bron van waarheid. Zustand wordt de live client-projectie voor de actieve workflow: snel genoeg voor optimistic UI, robuust genoeg tegen gemiste SSE-events, hidden tabs en onbekende notify-vormen.

Kernkeuze

Geen app-brede megastore en ook geen pure splits per pagina. Stores per bounded context:

Store Scope Verantwoordelijkheid
product-workspace-store actief product active context, product backlog data, selectie, DnD-order, SSE, resync
sprint-workspace-store actieve sprint sprint stories, sprint tasks, assignment, sprint-DnD, SSE, resync
solo-store actieve user + product uitvoerbare taken, worker/job status, solo-kanban realtime
notifications-store user vragen, alerts, notification badge
idea-store idee/product grill/make-plan jobstate, open vragen, idea-status
jobs-store jobs pagina actieve/afgeronde jobs en jobs-page selectie
kleine UI stores app/client debug mode, lichte UI-voorkeuren

De huidige backlog-store, planner-store, selection-store en product-store worden samengevoegd tot product-workspace-store. Ze beschrijven dezelfde workflow en verdelen nu PBI/story/task waarheid, order-state en selectie over meerdere stores.

Source of truth

Data Waarheid Zustand rol Persistentie
activeProductId users.active_product_id in DB client mirror voor navigatie en actieve stream DB
activePbiId runtime selectie of URL active context optioneel localStorage restore hint
activeStoryId runtime selectie of URL active context optioneel localStorage restore hint
activeTaskId runtime selectie of URL dialog param active context + task detail optioneel localStorage restore hint
PBI/story/task data DB genormaliseerde live client-projectie geen localStorage
DnD-order DB sort_order, tijdelijk optimistic in store relatie-arrays met rollback DB na server action
filters/sort UI client preference component/store UI state localStorage mag

LocalStorage is dus geen waarheid voor actieve entiteiten. Het mag alleen helpen om een vorige selectie te herstellen nadat de server de actieve product-context heeft bepaald — én alleen als de hint-id na laden nog bestaat in de store.

Product workspace store

Vorm:

type ProductWorkspaceStore = {
  context: {
    activeProduct: { id: string; name: string } | null
    activePbiId: string | null
    activeStoryId: string | null
    activeTaskId: string | null
  }

  entities: {
    pbisById: Record<string, BacklogPbi>
    storiesById: Record<string, BacklogStory>
    tasksById: Record<string, BacklogTask | TaskDetail>
  }

  relations: {
    pbiIds: string[]
    storyIdsByPbi: Record<string, string[]>
    taskIdsByStory: Record<string, string[]>
  }

  loading: {
    loadedProductId: string | null
    loadingProductId: string | null
    loadedPbiIds: Record<string, true>
    loadedStoryIds: Record<string, true>
    loadedTaskIds: Record<string, true>
    activeRequestId: string | null
  }

  sync: {
    realtimeStatus: 'connecting' | 'open' | 'disconnected'
    lastEventAt: number | null
    lastResyncAt: number | null
    resyncReason: ResyncReason | null
  }

  hydrateSnapshot(snapshot: ProductBacklogSnapshot): void
  setActiveProduct(product: { id: string; name: string } | null): void
  setActivePbi(pbiId: string | null): void
  setActiveStory(storyId: string | null): void
  setActiveTask(taskId: string | null): void

  ensureProductLoaded(productId: string, requestId?: string): Promise<void>
  ensurePbiLoaded(pbiId: string, requestId?: string): Promise<void>
  ensureStoryLoaded(storyId: string, requestId?: string): Promise<void>
  ensureTaskLoaded(taskId: string, requestId?: string): Promise<void>

  applyRealtimeEvent(event: ProductRealtimeEvent): void
  resyncActiveScopes(reason: ResyncReason): Promise<void>
  resyncLoadedScopes(reason: ResyncReason): Promise<void>

  applyOptimisticMutation(mutation: OptimisticMutation): string
  rollbackMutation(mutationId: string): void
  settleMutation(mutationId: string): void
}

State blijft vlak en genormaliseerd. Componenten lezen via selectors:

selectVisiblePbis(state)
selectStoriesForActivePbi(state)
selectTasksForActiveStory(state)
selectActivePbi(state)
selectActiveStory(state)
selectActiveTask(state)

Een task zit in tasksById als BacklogTask (lite) zolang alleen de lijst geladen is, en wordt verrijkt naar TaskDetail zodra ensureTaskLoaded is gedraaid. Componenten gebruiken een isDetail()-typeguard voor de extra velden.

Active context flow

Product wisselen

setActiveProduct(product)
-> nieuw requestId, zet activeRequestId
-> zet activeProduct, reset activePbiId/activeStoryId/activeTaskId
-> reset entities + relations als product wisselt
-> SSE-stream wisselt mee met product.id
-> ;(async) await ensureProductLoaded(product.id, requestId)
-> nadat ensure resolved + activeRequestId nog == requestId:
   probeer restore hint (activePbiId) — alleen als hint-id in entities zit

activeProduct komt server-side uit de layout via users.active_product_id. De client-store spiegelt dit zodat client componenten niet overal props hoeven te ontvangen.

PBI selecteren

setActivePbi(pbiId)
-> nieuw requestId
-> zet activePbiId, reset activeStoryId en activeTaskId
-> schrijf lastActivePbiIdByProduct[productId] als restore hint
-> ;(async) await ensurePbiLoaded(pbiId, requestId)
-> nadat ensure resolved + activeRequestId nog == requestId:
   probeer restore hint (activeStoryId)

Story selecteren

setActiveStory(storyId)
-> nieuw requestId
-> zet activeStoryId, reset activeTaskId
-> ensureStoryLoaded(storyId, requestId)
-> schrijf lastActiveStoryIdByProduct[productId] als restore hint

Task selecteren

setActiveTask(taskId)
-> zet activeTaskId
-> ensureTaskLoaded(taskId)
-> schrijf lastActiveTaskIdByProduct[productId] als restore hint

Race-safe loaders

Setters mogen loaders starten, maar loaders moeten race-safe zijn.

setActivePbi(pbiId) {
  const requestId = crypto.randomUUID()

  set((s) => {
    s.context.activePbiId = pbiId
    s.context.activeStoryId = null
    s.context.activeTaskId = null
    s.loading.activeRequestId = requestId
  })

  void get().ensurePbiLoaded(pbiId, requestId)
}

Bij terugkomst:

if (get().loading.activeRequestId !== requestId) return

Een trage fetch van een oude selectie mag nooit de nieuwste selectie of data overschrijven.

Niet state.ensureXxx(...) aanroepen vanuit een gecaptured snapshot. Method-references zijn niet noodzakelijk identiek over verschillende immer-state-versies. Roep acties intern aan via get().ensureXxx(...) direct vóór gebruik. Zie §Implementation-gotchas G4.

Hydration-strategie

Er zijn twee patronen. Kies bewust per pagina.

Patroon A — Server snapshot (productpagina, sprintboard)

Voor pagina's die een specifieke product-route hebben en SSR/RSC kunnen benutten:

1. Server layout leest `users.active_product_id`.
2. Server-page fetcht initial backlog snapshot voor dat product.
3. Client krijgt snapshot via prop / `BacklogHydrationWrapper` → `hydrateSnapshot()`.
4. Vervolgens:
   - leest store optionele restore hints (activePbiId/Story/Task).
   - Herstelt alleen als id nog bestaat in entities en toegankelijk is.
   - Anders selectie leeg laten.
5. SSE-hook mount op activeProductId.

Patroon B — Cascading client-load (productpicker zonder server-context)

Voor pagina's zonder server-determined product (b.v. een dashboard met product-pulldown). De hydrateSnapshot blijft beschikbaar als API maar wordt niet gebruikt; loaders worden door de UI getriggerd via setActiveProduct, setActivePbi, etc.

1. UI biedt productpicker; geen server-side activeProduct.
2. Op mount: lees `lastActiveProductId` uit localStorage (als hint).
3. setActiveProduct(restoredProduct) → trigger ensureProductLoaded.
4. Na elke ensure*Loaded: pas vervolg-restore-hint toe (zie restore-flow).
5. SSE-hook mount op activeProductId.

Restore-hint flow

setActiveProduct(p):
  ;(async () => {
    await ensureProductLoaded(p.id, requestId)
    if (loading.activeRequestId !== requestId) return
    const hint = hints.perProduct[p.id]?.lastActivePbiId
    if (hint && entities.pbisById[hint]) setActivePbi(hint)
  })()

setActivePbi(pbiId):
  ;(async () => {
    await ensurePbiLoaded(pbiId, requestId)
    if (loading.activeRequestId !== requestId) return
    const hint = hints.perProduct[productId]?.lastActiveStoryId
    if (hint && entities.storiesById[hint]) setActiveStory(hint)
  })()

Geen setTimeout(0) of microtask-trick. De fetch is dan nog niet klaar, dus de validatie entities.byId[hint] faalt altijd. Chain dus altijd await ensureXxxLoaded en valideer in dezelfde requestId-cycle.

Als een route een expliciete task in de URL heeft, wint de URL boven de restore hint. Voorbeeld: ?editTask=<id> of een toekomstige deep link.

SSE integratie

De SSE-hook beheert alleen transport:

useProductWorkspaceRealtime(activeProductId)
-> opent /api/realtime/backlog?product_id=...
-> parsed events
-> dispatcht naar store.applyRealtimeEvent(event)
-> beheert reconnect/backoff/status (via store.setRealtimeStatus)
-> op 'ready' na (re)connect: void store.resyncActiveScopes('reconnect')

De store beheert de betekenis:

PBI insert/update
-> upsert pbisById
-> voeg id toe aan pbiIds indien nodig
-> sorteer pbiIds op priority/sort_order

PBI delete
-> verwijder pbi
-> verwijder child stories en tasks
-> clear actieve selectie als die onder deze PBI viel

Story insert/update
-> upsert storiesById
-> verplaats id tussen storyIdsByPbi indien pbi_id wijzigt
-> sorteer alleen de betrokken parent-lijsten

Story delete
-> verwijder story
-> verwijder child tasks
-> clear activeStoryId/activeTaskId indien nodig

Task insert/update
-> upsert tasksById
-> verplaats id tussen taskIdsByStory indien story_id wijzigt
-> sorteer alleen de betrokken task-lijst

Task delete
-> verwijder task
-> clear activeTaskId indien nodig

SSE-events zijn idempotent. Een event dat al optimistisch is toegepast, mag geen dubbele insert of verkeerde rollback veroorzaken.

Reconciliation en resync

SSE is snel, maar niet voldoende als enige correctheidsmechanisme:

  • browsers kunnen hidden tabs throttlen of freezen;
  • Postgres NOTIFY heeft geen replay;
  • de tab kan offline zijn;
  • een event-router kan een relevant semantisch event niet herkennen;
  • sommige wijzigingen vereisen refetch in plaats van een kleine patch.

Daarom krijgt de store een expliciete resync-laag.

type ResyncReason =
  | 'visible'
  | 'reconnect'
  | 'manual'
  | 'unknown-event'
  | 'stale-scope'
  | 'mutation-settled'

Hidden tab beleid

Sluit de SSE-stream niet actief zodra de tab hidden wordt. Laat EventSource open zolang browser en netwerk dit toelaten.

Bij overgang hidden -> visible:

resyncActiveScopes('visible')

Bij reconnect of nieuw ready event na disconnect:

resyncActiveScopes('reconnect')

Het bestaande use-backlog-realtime.ts sluit de EventSource op hidden. Vervang dat gedrag in dezelfde PR als waarin resyncActiveScopes('visible') wordt toegevoegd; los gezien zou je oude gedrag kwijtraken zonder vangnet.

Active scopes

Minimale resync:

activeProductId -> refetch product/PBI snapshot
activePbiId     -> refetch stories voor PBI
activeStoryId   -> refetch tasks voor story
activeTaskId    -> refetch task detail

Implementeer resyncActiveScopes zonder gecaptured snapshot:

async resyncActiveScopes(reason) {
  const ctx = get().context
  const tasks: Promise<void>[] = []
  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)
}

Als de UX merkt dat eerder bezochte panels stale blijven, breid dit uit naar resyncLoadedScopes, dat alle scopes in loadedPbiIds, loadedStoryIds en loadedTaskIds parallel herlaadt.

Unknown relevant events

Een SSE-route mag onbekende product-events niet stil negeren — maar ook niet elk geluid blind als refetch-trigger interpreteren.

known pbi/story/task event
-> applyRealtimeEvent(event)

known semantic event, zoals story_log of claude_job_status
-> patch specifieke slice of markeer scope stale

unknown event met product_id == activeProductId EN entity-shape
-> resyncActiveScopes('unknown-event')

worker_*, claude_job_*, heartbeat, question-events
-> negeer voor de product-workspace, behoort op andere bounded contexts
   (solo-store, notifications-store, jobs-store)

Concrete filter:

function isUnknownEntityEvent(p: Record<string, unknown>): boolean {
  if (typeof p.entity !== 'string') return false
  if (['pbi', 'story', 'task'].includes(p.entity)) return false
  if ('type' in p) return false // job/worker hebben `type`
  return true
}

Fetch en cache regels

Read-routes die store-data voeden:

export const dynamic = 'force-dynamic'

Client fetches vanuit ensure...Loaded en resync...:

fetch(url, { cache: 'no-store' })

SSE-routes blijven ook force-dynamic. SSE-routes zelf veranderen niet door deze rearchitecture: auth (getSession()) en getAccessibleProduct() blijven leidend. De rearchitecture raakt alleen wat de client met de events doet.

Waar nuttig stuurt de SSE-route na LISTEN een initial state event. Dat voorkomt de race waarbij de status wijzigt tussen de eerste DB-read en het moment dat LISTEN actief is.

Optimistic updates

Voor DnD en status toggles:

1. Maak mutationId.
2. Bewaar rollback snapshot van alleen de betrokken relatie/entity.
3. Patch store direct.
4. Start server action.
5. Success: settle mutation.
6. Error: rollback mutation.
7. SSE echo: idempotent toepassen of markeren als bevestigd.

Voor create/update/delete dialogs is optimisme optioneel. Standaard mag:

server action -> resultaat in store verwerken -> SSE echo idempotent negeren

Sprint workspace

Na stabilisatie van product-workspace volgt dezelfde vorm voor sprint:

sprint-workspace-store
  activeSprintId
  selectedStoryId
  selectedTaskId
  storiesById
  tasksById
  pbisById
  storyIdsByPbi
  sprintStoryIds
  taskIdsByStory
  loaded scopes
  applyRealtimeEvent()
  resyncActiveScopes()

Dit vervangt op termijn de combinatie van lokale state in de sprint board en de huidige sprint-store order maps. Pak het pas op nadat product-workspace in productie staat en de eerste paar weken stabiel draait.

Implementation-gotchas

Deze pitfalls zijn subtiel genoeg om opnieuw te maken. Documenteer ze in code via comments boven de fix.

G1. s.byId[x] ?? [] triggert React-loop

// FOUT: nieuwe array per render → "Maximum update depth exceeded"
const stories = useStore((s) => s.storiesByPbi[pbiId] ?? [])

// GOED: stable empty const op module-level
const EMPTY: BacklogStory[] = []
const stories = useStore((s) => s.storiesByPbi[pbiId] ?? EMPTY)

G2. List-selectors materialiseren → useShallow verplicht

selectVisiblePbis(state) en vrienden bouwen ids.map(id => byId[id]) per call. Zonder useShallow re-rendert het component op elke onafhankelijke store-mutatie.

import { useShallow } from 'zustand/react/shallow'
const list = useStore(useShallow(selectVisiblePbis))

Single-value selectors (b.v. selectActivePbi) hebben dit niet nodig — die retourneren een stable entity-reference.

G3. immer + setState((s) => ({...})) REPLACES de state

Met zustand/middleware/immer interpreteert produce een return-waarde als de nieuwe state. Dat lijkt op de pre-immer Zustand-API maar wist alle andere slices én alle action-properties.

// FOUT (in immer-middleware): vervangt hele state met { context: {...} }
useStore.setState((s) => ({ context: { ...s.context, activePbiId: 'x' } }))

// GOED: mutation-style (immer recipe muteert draft)
useStore.setState((s) => { s.context.activePbiId = 'x' })

G4. Method-refs zijn niet stabiel over state-versies

// FOUT: state-snapshot kan andere method-ref hebben dan de huidige
async resyncActiveScopes(reason) {
  const state = get()
  // ...
  state.ensureProductLoaded(...) // niet betrouwbaar
}

// GOED: per call fresh ophalen via get()
async resyncActiveScopes(reason) {
  const ctx = get().context
  // ...
  get().ensureProductLoaded(...)
}

G5. Tests die acties mocken via setState lekken naar volgende tests

useStore.setState({ resyncActiveScopes: vi.fn() }) blijft staan na de test. beforeEach reset alleen data-velden. Snapshot originele acties op module-load + restore in beforeEach:

const originalActions = (() => {
  const s = useStore.getState()
  return { resyncActiveScopes: s.resyncActiveScopes, /* ... */ }
})()

function resetStore() {
  useStore.setState({ ...initialData, ...originalActions })
}

G6. localStorage in vitest 4 + jsdom 29

In deze combinatie is localStorage.clear/getItem/setItem niet aanwezig op het globale localStorage-object. Bind in tests/setup.ts een eigen MemoryStorage:

class MemoryStorage implements Storage { /* ... */ }
const memory = new MemoryStorage()
Object.defineProperty(globalThis, 'localStorage', { value: memory, configurable: true })
Object.defineProperty(window, 'localStorage', { value: memory, configurable: true })

G7. fetch in node-test omgeving accepteert geen relative URLs

/api/products/... faalt met Invalid URL. Mock fetch in elke test die indirect een ensure*Loaded aanroept, of stub de implementatie.

G8. Response-body wordt één keer geconsumeerd

vi.spyOn(fetch).mockResolvedValue(response) levert dezelfde Response aan elke fetch — eerste .json() werkt, daarna error. Gebruik mockImplementation() voor een fresh body per call:

vi.spyOn(globalThis, 'fetch').mockImplementation(() =>
  Promise.resolve(new Response(JSON.stringify([]), { status: 200 })),
)

Testing setup (Vitest)

Minimale config:

// vitest.config.ts
import { defineConfig } from 'vitest/config'
import path from 'node:path'
export default defineConfig({
  test: {
    environment: 'jsdom',
    include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'],
    setupFiles: ['tests/setup.ts'],
  },
  resolve: { alias: { '@': path.resolve(__dirname, '.') } },
})

tests/setup.ts bevat MemoryStorage-binding (zie G6) en vi.restoreAllMocks() in beforeEach.

Verplichte test-cases per workspace-store:

  • hydrateSnapshot vult entities + relations.
  • 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.
  • applyRealtimeEvent: event voor ander product wordt genegeerd.
  • applyRealtimeEvent: unknown entity met matching product → resync trigger.
  • Delete-cleanup van actieve selectie.
  • ensureProductLoaded fetch + sortering.
  • Race-safe ensure*Loaded met requestId-guard (oude in-flight mag niet nieuwere selectie overschrijven).
  • ensureTaskLoaded zet detail-flag.
  • resyncActiveScopes triggert ensure-keten met juiste URLs en zet lastResyncAt + resyncReason.
  • localStorage restore-hints: setActiveProduct en setActivePbi schrijven de juiste keys.
  • Optimistic mutation: rollback herstelt vorige state; settle ruimt pending op; SSE-echo wordt idempotent verwerkt.

Implementatiepad

Migratie van het bestaande systeem (backlog-store + planner-store + selection-store + product-store) naar product-workspace-store. Doe dit in opeenvolgende PRs zodat elke stap in productie te verifiëren is.

Stap 1 — Skelet opzetten (nieuwe store, nog niet gebruikt)

  1. Maak stores/product-workspace/ met:
    • types.ts (entity types, snapshot, realtime event union, ResyncReason).
    • store.ts (factory met immer-middleware, alle slices, alle acties).
    • selectors.ts (pure functies, useShallow-vriendelijk).
    • restore.ts (localStorage hints met validatie).
  2. Voeg applyRealtimeEvent, resyncActiveScopes, resyncLoadedScopes, ensure*Loaded toe — eerst zonder integraties; getest via Vitest.
  3. Acceptatie: alle test-cases uit §Testing setup groen. Geen UI-impact nog.

Stap 2 — Hydration overstappen

  1. BacklogHydrationWrapper (of equivalent) roept hydrateSnapshot aan op de nieuwe store i.p.v. useBacklogStore.setInitialData.
  2. lib/realtime/use-backlog-realtime.ts dispatcht naar useProductWorkspaceStore.applyRealtimeEvent i.p.v. applyChange op de oude store.
  3. Componenten lezen voorlopig nog van de oude stores; nieuwe store loopt parallel mee als read-only schaduwkopie. Vergelijk in dev-tools dat de inhoud klopt.

Stap 3 — Componenten omzetten

  1. Per panel/component: vervang useBacklogStore/useSelectionStore/ useProductStore reads door selectors uit de workspace-store + useShallow waar nodig.
  2. Setters (selectPbi, selectStory, setCurrentProduct) vervangen door de workspace-acties (setActivePbi, setActiveStory, setActiveProduct).
  3. Handle G1 en G2 expliciet: stable empty refs, useShallow voor lijsten.

Stap 4 — Race-safe en restore-hints

  1. ensure*Loaded met activeRequestId-guard implementeren conform §Race-safe loaders.
  2. localStorage hints introduceren met await ensure*Loaded-chain (zie §Restore-hint flow).
  3. Verifieer in een staging-omgeving: cold reload met persisted hint herstelt selectie zonder fouten.

Stap 5 — Hidden-tab + reconnect-resync

  1. use-backlog-realtime aanpassen: niet meer sluiten op hidden, blijven luisteren. Op ready na reconnect: resyncActiveScopes('reconnect').
  2. Aparte useWorkspaceResync()-hook: trigger resyncActiveScopes('visible') bij visibilitychange van hidden→visible, en bij online-event.
  3. Doe deze twee in één PR — los gezien zou je oude gedrag kwijtraken zonder vangnet.

Stap 6 — Unknown-event fallback

  1. applyRealtimeEvent handelt onbekende entity-events af volgens §Unknown relevant events. Filter op isUnknownEntityEvent(payload).
  2. Verifieer dat job-/worker-/heartbeat-events GEEN refetch triggeren.

Stap 7 — Cache-headers

  1. Zet cache: 'no-store' op alle client fetches uit ensure*Loaded en resync....
  2. Bevestig force-dynamic op alle read-routes die store-data leveren.

Stap 8 — Oude stores opruimen

  1. Verwijder stores/backlog-store.ts, stores/planner-store.ts, stores/selection-store.ts, stores/product-store.ts zodra geen component er nog op leest. Grep over de hele codebase om verrassingen te voorkomen.
  2. stores/products-store.ts blijft (lijst van producten ≠ active product).

Stap 9 — Sprint workspace

  1. Herhaal het patroon voor sprint-workspace-store. Eerste een paar weken de product-workspace stabiel laten draaien.

Acceptatiecriteria

  • Een PBI/story/task bestaat als waarheid maar op één plek in de client-store.
  • Product backlog panels lezen via selectors uit dezelfde workspace-store.
  • PBI/story/task SSE-events patchen de store zonder full page refresh.
  • Hidden -> visible herstelt gemiste wijzigingen binnen één resync-cyclus.
  • Reconnect herstelt gemiste wijzigingen zonder afhankelijkheid van NOTIFY replay.
  • Directe entity-edits zonder herkenbare delta worden via resync zichtbaar (unknown-event filter staat aan, job/worker noise niet meegerekend).
  • LocalStorage kan een vorige selectie herstellen, maar nooit ontoegankelijke of verwijderde entiteiten forceren; valideer altijd na ensure-load.
  • Optimistic DnD heeft rollback en wordt niet dubbel toegepast door SSE echoes.
  • Read-routes en client fetches leveren geen stale browser/Next cache data.
  • Test-suite dekt §Testing setup checklist en draait groen in CI.
  • Geen "Maximum update depth exceeded" of "result of getServerSnapshot should be cached" warnings in de console (zie §G1 en §G2).
  • Auth en getAccessibleProduct() blijven ongewijzigd op SSE/read-routes; deze rearchitecture raakt alleen client-state, geen serverlaag-security.