7.7 KiB
7.7 KiB
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 tofeedflushPicturesCache()→appCache.invalidate('pictures')→ cascades toposts → feed,pages → feed- Each cascade node emits an SSE event
- Unknown source fires
flushPicturesCache9 times (once per picture row) - Client
StreamInvalidatorreceives ~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
// BEFORE: invalidate() does cache clearing + SSE + cascade
invalidate(type: string): Promise<void>
// AFTER: invalidate() is silent cache clearing only
invalidate(type: string): Promise<void> // clears cache + cascade, NO SSE
notify(type: string, id: string | null, action: 'create' | 'update' | 'delete'): void // 1 SSE
Event Shape
// 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
// Per-item invalidation with dependency awareness
const INVALIDATION_RULES: Record<string, (id: string | null, qc: QueryClient) => 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.tsxchanges (remove feed/system mappings, keep logging)
M1: Silent Invalidation ✦ server-only
AppCache.invalidate()→ remove allappEvents.emitUpdate()callsAppCache.flush()→ remove SSE emissionAppCache.notify(type, id, action)→ new method, emits exactly 1 SSE- Remove
_isRootparameter (no longer needed — no cascade SSE) - Keep dependency cascade for cache clearing (still needed server-side)
- Adjust SSE event shape to include
idfield
M2: Handler-Level Notification ✦ server-only
handleUpdatePost→ addappCache.notify('post', postId, 'update')handleCreatePost→ addappCache.notify('post', newId, 'create')handleDeletePost→ addappCache.notify('post', postId, 'delete')handleUpsertPictures→ addappCache.notify('post', postId, 'update')(pictures belong to a post)handleUnlinkPictures→ addappCache.notify('pictures', null, 'update')handleUpdatePicture→ addappCache.notify('picture', picId, 'update')handleCreatePicture→ addappCache.notify('picture', picId, 'create')handleDeletePicture→ addappCache.notify('picture', picId, 'delete')- Page handlers (
pages-crud.ts) → addappCache.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_KEYmap withINVALIDATION_RULES(function-based) - Parse
event.idfrom SSE payload - Per-item
invalidateQuerieswhenidis present - Fallback to list-level invalidation when
idis null
M4: E2E Test ✦ verification
- Create
cache-ex.e2e.test.tsfollowing 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,pagesevents are NOT emitted) - Test: cache is still cleared correctly (type + dependents)
- Test: flush-all → exactly 1 SSE event (type='system')
- Add
test:cache-exscript topackage.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)
cd server
npm run test:cache-ex
Manual
- Start dev servers (
npm run devin both client/server) - Open browser console, filter for
[StreamInvalidator] - Edit a post title → save
- Expected: exactly 1-2 SSE log lines (
post:xyz:update), no cascade spam - Run
npm run buildin server to verify TypeScript compiles