mono/packages/ui/docs/cache-ex.md
2026-02-25 10:11:54 +01:00

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 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

// 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.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)

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