Event Caching
How Compass caches events.
The one-sentence model
TanStack Query is the cache and the single owner of persisted events. Each
cache entry holds one window of events — a (source, scope, date range) slice —
stored in a normalized { ids, entities } shape. Views render from these entries;
mutations edit them optimistically and then let the server reconcile.
The Zustand stores do not hold persisted events (only transient drafts/interaction). See State Systems.
Cache-key anatomy
Every event cache entry is keyed by three parts (event.query.keys.ts):
["events", scope, { source, startDate, endDate }]
│ │
│ └─ "local" (IndexedDB) or "remote" (API)
└─ "day" | "week"
QueryClient cache
├─ ["events","week", {source:"local", 2026-07-01 … 07-08}] → { ids:[…], entities:{…} }
├─ ["events","day", {source:"local", 2026-07-03 … 07-04}] → { ids:[…], entities:{…} }
Two consequences fall straight out of the key:
- Source is part of the key, so
localandremoteevents never collide. A mutation reads and writes only its captured source (no cross-source drift). - Range is part of the key, so navigating to a new week fetches; navigating
back to a recent one renders instantly from cache (within
staleTime, 2 min).
Reads: view → cache → screen
useWeekEventsQuery(range)
│ key = ["events","week",{ source, range }]
▼
TanStack Query ── cache hit? ──► return cached { ids, entities }
│ miss / stale
▼
fetchWeekEvents → repository.get(source) → filter to range → normalize → cache
│
▼
deriveCalendarEventViewModel(query.data) // pure; memoized on the data reference
▼
timedEvents / allDayEvents / rowCount → components
- The active source comes from
event.repository.source.store.ts(local vs remote — see Repository Selection). - The view model is derived by a pure function memoized on the
query.datareference, so all consumers of a week's data share one computation.
Writes: optimistic, then reconcile
Mutations go through the narrow EventMutations interface
(useEventMutations.ts). Each one follows the same lifecycle:
mutate(payload)
│
├─ onMutate: cancel in-flight reads
│ → apply optimistic edit to matching cache entries (instant UI)
│
├─ mutationFn: persist via repository (captured source)
│
├─ onError: report the error (no cache rollback)
│
└─ onSettled: once NO event mutation remains in flight (checked on a
deferred macrotask), invalidate ["events"] → refetch to
get canonical data
- Optimistic edits insert/patch/remove events across exactly the entries they belong to, so a created or dragged-in event shows immediately — before the server responds.
- No per-mutation rollback. Rapid successive edits are allowed (events stay
interactive while pending), so a failed mutation must not restore a snapshot —
that would clobber a newer edit's optimistic write. Instead, failures leave the
optimistic value in place and the settle-time refetch converges the cache to
server truth. Invalidation is deferred to a macrotask and gated on
queryClient.isMutating(...) === 0so a refetch never overwrites another mutation's live optimistic update (the TanStack Query recipe for concurrent optimistic updates, deferred so simultaneous settles cannot all skip). - Writes racing their own create wait. Editing or deleting a just-created
event defers its repository call via
waitForPendingEventCreateuntil the create settles: the id doesn't exist server-side before then, and a skipped delete would resurrect the event once the create landed. When the create fails, the dependent write is skipped entirely. - Pending state is derived from TanStack Query's mutation state via
usePendingEventIds, not stored separately. It never blocks interaction; its only UI is the sync shimmer on the account email inCalendarListHeader.
One membership rule for reads and writes
Reads and optimistic writes must agree on "does this event belong in this window", or an edit could render in a spot a refetch would disagree with. Both sides call the same predicate:
eventMatchesRange(event.query.normalize.ts) — timed events by containment, all-day by overlap. Used by the read filter and byeventBelongsToEntry(event.query.cache.ts), which layers on the source + scope check for writes.
What refreshes the cache
- Mutations — invalidate
["events"]on settle (see above). - SSE — background
EVENT_CHANGEDinvalidates the relevant scope so it refetches. See SSE Runtime. - Auth / source transitions — refresh the repository source store and drop
stale entries (e.g. Google revoked → fall back to
local).
Navigation: placeholder data + prefetch
A small TanStack feature makes prev/next navigation feel instant:
- The adjacent day/week is prefetched as soon as the current one renders
(
usePrefetchAdjacentEvents, wired intouseWeek/useDayEvents). It callsqueryClient.prefetchQuerywith the same options builder and date formatting the real read hook uses, so the warmed entry lands under the exact key a subsequent read looks up.prefetchQueryis a no-op for entries that are already cached and fresh, so this adds no extra fetches on repeat renders of the same range — only the next click resolves from cache instead of paying a fetch.
What the cache is not
- Not client stores. The draft Zustand store owns only the in-progress
draft and calendar interaction state (
draft.store.ts). Do not mirror persisted events into client stores. - Not IndexedDB directly. IndexedDB is the offline store behind the
localrepository; components never touch it — they read through the query cache.
Testing note
Tests seed the query cache directly (there is no store→query bridge): pass an
events array to the render/store harnesses, which calls seedEventQueries
(__tests__/utils/event-query-test-data.ts). See the
Testing Playbook.