- 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>
24 KiB
| title | status | audience | language | last_updated | revision | |||
|---|---|---|---|---|---|---|---|---|
| Zustand store rearchitecture - active context, realtime en resync | ready-to-execute |
|
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 viaget().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 altijdawait ensureXxxLoadeden 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.tssluit de EventSource ophidden. Vervang dat gedrag in dezelfde PR als waarinresyncActiveScopes('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:
hydrateSnapshotvult entities + relations.- Selection cascade:
setActivePbireset story+task;setActiveStoryreset task. setActiveProduct(null)ruimt entities en relations op.applyRealtimeEvent: pbi/story/taskI|U|Dmet sortering en parent-move.applyRealtimeEvent: event voor ander product wordt genegeerd.applyRealtimeEvent: unknown entity met matching product → resync trigger.- Delete-cleanup van actieve selectie.
ensureProductLoadedfetch + sortering.- Race-safe
ensure*Loadedmet requestId-guard (oude in-flight mag niet nieuwere selectie overschrijven). ensureTaskLoadedzet detail-flag.resyncActiveScopestriggert ensure-keten met juiste URLs en zetlastResyncAt+resyncReason.- localStorage restore-hints:
setActiveProductensetActivePbischrijven 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)
- 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).
- Voeg
applyRealtimeEvent,resyncActiveScopes,resyncLoadedScopes,ensure*Loadedtoe — eerst zonder integraties; getest via Vitest. - Acceptatie: alle test-cases uit §Testing setup groen. Geen UI-impact nog.
Stap 2 — Hydration overstappen
BacklogHydrationWrapper(of equivalent) roepthydrateSnapshotaan op de nieuwe store i.p.v.useBacklogStore.setInitialData.lib/realtime/use-backlog-realtime.tsdispatcht naaruseProductWorkspaceStore.applyRealtimeEventi.p.v.applyChangeop de oude store.- 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
- Per panel/component: vervang
useBacklogStore/useSelectionStore/useProductStorereads door selectors uit de workspace-store +useShallowwaar nodig. - Setters (
selectPbi,selectStory,setCurrentProduct) vervangen door de workspace-acties (setActivePbi,setActiveStory,setActiveProduct). - Handle G1 en G2 expliciet: stable empty refs, useShallow voor lijsten.
Stap 4 — Race-safe en restore-hints
ensure*LoadedmetactiveRequestId-guard implementeren conform §Race-safe loaders.- localStorage hints introduceren met
await ensure*Loaded-chain (zie §Restore-hint flow). - Verifieer in een staging-omgeving: cold reload met persisted hint herstelt selectie zonder fouten.
Stap 5 — Hidden-tab + reconnect-resync
use-backlog-realtimeaanpassen: niet meer sluiten ophidden, blijven luisteren. Opreadyna reconnect:resyncActiveScopes('reconnect').- Aparte
useWorkspaceResync()-hook: triggerresyncActiveScopes('visible')bijvisibilitychangevan hidden→visible, en bijonline-event. - Doe deze twee in één PR — los gezien zou je oude gedrag kwijtraken zonder vangnet.
Stap 6 — Unknown-event fallback
applyRealtimeEventhandelt onbekende entity-events af volgens §Unknown relevant events. Filter opisUnknownEntityEvent(payload).- Verifieer dat job-/worker-/heartbeat-events GEEN refetch triggeren.
Stap 7 — Cache-headers
- Zet
cache: 'no-store'op alle client fetches uitensure*Loadedenresync.... - Bevestig
force-dynamicop alle read-routes die store-data leveren.
Stap 8 — Oude stores opruimen
- Verwijder
stores/backlog-store.ts,stores/planner-store.ts,stores/selection-store.ts,stores/product-store.tszodra geen component er nog op leest. Grep over de hele codebase om verrassingen te voorkomen. stores/products-store.tsblijft (lijst van producten ≠ active product).
Stap 9 — Sprint workspace
- 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.