176 lines
7.7 KiB
Markdown
176 lines
7.7 KiB
Markdown
# 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<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
|
|
|
|
```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<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.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
|