# Cache-EX: Per-Item Cache Invalidation Architecture > Replaces the current "nuke everything" AppCache invalidation with surgical, per-item operations. ## Problem A single post title update currently triggers **~50+ cache invalidation events** and SSE broadcasts: - `flushPostsCache()` → `appCache.invalidate('posts')` → cascades to `feed` - `flushPicturesCache()` → `appCache.invalidate('pictures')` → cascades to `posts → feed`, `pages → feed` - Each cascade node emits an SSE event - Unknown source fires `flushPicturesCache` 9 times (once per picture row) - Client `StreamInvalidator` receives ~20 SSE events, most for types it doesn't know (`feed`, `system`) ## Design ### Core Principle: Separate Cache Clearing from Notification | Concern | Current | Cache-EX | |---|---|---| | Cache clearing | `invalidate(type)` → blast everything + SSE + cascade | `invalidate(type)` → clear cache silently | | SSE notification | Embedded in cache clearing (N events per cascade) | `notify(type, id, action)` → 1 explicit SSE per handler | | Client invalidation | Broad type-based (`invalidateQueries(['posts'])`) | Per-item (`invalidateQueries(['post', id])`) | ### Server Architecture ``` ┌──────────────────────────┐ │ Route Handler │ │ handleUpdatePost(id) │ ├──────────────────────────┤ │ 1. DB write │ │ 2. invalidate('posts') │ ← silent, no SSE │ 3. notify('post',id, │ ← exactly 1 SSE event │ 'update') │ └──────────────────────────┘ │ ▼ SSE ┌──────────────────────────┐ │ StreamInvalidator │ │ event: post:abc:update │ ├──────────────────────────┤ │ invalidateQueries( │ │ ['post', 'abc'] │ ← surgical │ ) │ └──────────────────────────┘ ``` ### AppCache Changes ```ts // BEFORE: invalidate() does cache clearing + SSE + cascade invalidate(type: string): Promise // AFTER: invalidate() is silent cache clearing only invalidate(type: string): Promise // clears cache + cascade, NO SSE notify(type: string, id: string | null, action: 'create' | 'update' | 'delete'): void // 1 SSE ``` ### Event Shape ```ts // BEFORE: { kind:'cache', type:'posts', action:'delete', data:{type:'posts'} } // AFTER: interface CacheEvent { kind: 'cache'; type: string; // 'post', 'page', 'category', 'picture' action: 'create' | 'update' | 'delete'; id: string | null; // entity ID (null = list-level change) data?: any; // optional payload for optimistic updates } ``` ### Client StreamInvalidator ```ts // Per-item invalidation with dependency awareness const INVALIDATION_RULES: Record void> = { 'post': (id, qc) => { if (id) qc.invalidateQueries({ queryKey: ['post', id] }); qc.invalidateQueries({ queryKey: ['posts'] }); qc.invalidateQueries({ queryKey: ['feed'] }); }, 'picture': (id, qc) => { if (id) qc.invalidateQueries({ queryKey: ['picture', id] }); qc.invalidateQueries({ queryKey: ['pictures'] }); }, 'page': (id, qc) => { if (id) qc.invalidateQueries({ queryKey: ['page', id] }); qc.invalidateQueries({ queryKey: ['pages'] }); }, 'category': (id, qc) => { qc.invalidateQueries({ queryKey: ['categories'] }); }, }; ``` --- ## Milestones ### M0: Revert & Baseline ✦ prerequisite - [ ] Revert debounce changes from `cache.ts` (return to pre-investigation state) - [ ] Revert `StreamInvalidator.tsx` changes (remove feed/system mappings, keep logging) ### M1: Silent Invalidation ✦ server-only - [ ] `AppCache.invalidate()` → remove all `appEvents.emitUpdate()` calls - [ ] `AppCache.flush()` → remove SSE emission - [ ] `AppCache.notify(type, id, action)` → new method, emits exactly 1 SSE - [ ] Remove `_isRoot` parameter (no longer needed — no cascade SSE) - [ ] Keep dependency cascade for cache clearing (still needed server-side) - [ ] Adjust SSE event shape to include `id` field ### M2: Handler-Level Notification ✦ server-only - [ ] `handleUpdatePost` → add `appCache.notify('post', postId, 'update')` - [ ] `handleCreatePost` → add `appCache.notify('post', newId, 'create')` - [ ] `handleDeletePost` → add `appCache.notify('post', postId, 'delete')` - [ ] `handleUpsertPictures` → add `appCache.notify('post', postId, 'update')` (pictures belong to a post) - [ ] `handleUnlinkPictures` → add `appCache.notify('pictures', null, 'update')` - [ ] `handleUpdatePicture` → add `appCache.notify('picture', picId, 'update')` - [ ] `handleCreatePicture` → add `appCache.notify('picture', picId, 'create')` - [ ] `handleDeletePicture` → add `appCache.notify('picture', picId, 'delete')` - [ ] Page handlers (`pages-crud.ts`) → add `appCache.notify('page', pageId, ...)` - [ ] Category handlers → add `appCache.notify('category', catId, ...)` - [ ] Type handlers → add `appCache.notify('type', typeId, ...)` - [ ] Layout handlers → add `appCache.notify('layout', layoutId, ...)` - [ ] Flush-all handler → add `appCache.notify('system', null, 'delete')` ### M3: Client StreamInvalidator ✦ client-only - [ ] Replace `EVENT_TO_QUERY_KEY` map with `INVALIDATION_RULES` (function-based) - [ ] Parse `event.id` from SSE payload - [ ] Per-item `invalidateQueries` when `id` is present - [ ] Fallback to list-level invalidation when `id` is null ### M4: E2E Test ✦ verification - [ ] Create `cache-ex.e2e.test.ts` following existing test patterns - [ ] Test: post update → SSE emits exactly 1 event (type='post', has id) - [ ] Test: picture create → SSE emits exactly 1 event (type='picture', has id) - [ ] Test: category update → SSE emits exactly 1 event (type='category', has id) - [ ] Test: no cascade SSE events (verify `feed`, `pages` events are NOT emitted) - [ ] Test: cache is still cleared correctly (type + dependents) - [ ] Test: flush-all → exactly 1 SSE event (type='system') - [ ] Add `test:cache-ex` script to `package.json` --- ## Files Changed | File | Change | |---|---| | `server/src/cache.ts` | Rewrite `invalidate()`, add `notify()`, remove debounce | | `server/src/events.ts` | Update `AppEvent` interface with `id` field | | `server/src/products/serving/db/db-posts.ts` | Add `notify()` calls to handlers | | `server/src/products/serving/db/db-pictures.ts` | Add `notify()` calls to handlers | | `server/src/products/serving/pages/pages-crud.ts` | Add `notify()` calls to handlers | | `server/src/products/serving/db/db-categories.ts` | Add `notify()` calls to handlers | | `server/src/products/serving/db/db-types.ts` | Add `notify()` calls to handlers | | `server/src/products/serving/index.ts` | Update flush-all handler | | `src/components/StreamInvalidator.tsx` | Replace map with function-based rules | | `server/src/products/serving/__tests__/cache-ex.e2e.test.ts` | New test file | | `server/package.json` | Add `test:cache-ex` script | ## Verification ### Automated (E2E) ```bash cd server npm run test:cache-ex ``` ### Manual 1. Start dev servers (`npm run dev` in both client/server) 2. Open browser console, filter for `[StreamInvalidator]` 3. Edit a post title → save 4. Expected: exactly **1-2 SSE log lines** (`post:xyz:update`), no cascade spam 5. Run `npm run build` in server to verify TypeScript compiles