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.
Multi-day timed events in the all-day row
Timed events that cross midnight are not cloned into per-day timed segments.
deriveCalendarEventViewModel promotes them into allDayEvents with
isTimedMultiDayDisplay: true (Google-style span bars).
- Detection / date mapping:
isTimedEventMultiDay,timedMultiDayToAllDayDatesinpackages/web/src/common/utils/event/event-nudge.util.ts - Promotion:
packages/web/src/events/queries/event.view-model.ts - Span coverage is half-open
[start, end): an end exactly at midnight does not include that calendar day - Those bars are read-only on the grid (no timed drag/resize); edit via the form
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
│ → snapshot matching cache entries
│ → apply optimistic edit to matching cache entries (instant UI)
│
├─ mutationFn: persist via repository (captured source)
│
├─ onError: report the error (toast)
│ → restore snapshot when no other event mutation is pending
│
└─ 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.
- Conditional rollback on failure. Each mutation snapshots matching cache
entries in
onMutate. On error it reports a toast and restores that snapshot when it is alone in flight; if other mutations are pending for a different write key it restores only this key's entities; if another mutation shares the same key it leaves the newer optimistic write alone. Settle-time invalidation still converges to server truth once no event mutation remains in flight. Invalidation is deferred to a macrotask and gated onqueryClient.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
eventsChangedinvalidates the relevant scope so it refetches. Native EventSource reconnect (open) and window focus also invalidate/refetch so a laptop-sleep gap is not silent. Separately,useSyncFocusRefreshasks Sync for a silent calendar catch-up after mount / long hide — that complements cache invalidation; it does not replace it. 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
Two TanStack features make week navigation feel instant:
-
Stable fetch window.
useWeekalways reads events for a 7-day window from the anchor (weekProps.query), while the visible columns still clip toweekProps.component. Resize-driven column changes no longer re-key the query, so cached data survives layout changes. -
Overlap-based placeholder data.
useWeekEventsQuerysuppliesplaceholderDatafromderiveOverlappingEventQueryData, merging events from any cached week entries whose stored range overlaps the requested range. Shift+J/K can render overlapping events immediately while the background fetch completes. -
Adjacent prefetch. The previous and next paged windows are 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.