Google Sync And Server-Sent Events (SSE)
Product-wide provider behavior, capability gates, and connect flows are
specified in calendar-providers.md. This document
covers the Google sync path and SSE wiring.
Google Calendar sync is owned entirely by the standalone Sync service
(packages/sync). The backend has no Google API calls or sync logic of its
own. The backend's role is: proxy sync-related reads/writes to Sync, poll
Sync's change feed, and translate what it learns into browser SSE.
Realtime updates use Server-Sent Events (one HTTP connection per tab).
The browser EventSource connects to GET /api/events/stream with the
session cookie.
High-Level Architecture
Connection And First Events
Wire Format And Message Types
Source:
packages/core/src/constants/sse.constants.ts: the SSE transportpackages/core/src/types/server-message.contracts.ts: the message union
The server publishes one SSE event name, message (SSE_MESSAGE_EVENT).
Its data is a JSON-serialized ServerMessage union member; clients parse
once and switch on type. There is no longer a distinct SSE event name per
signal (EVENT_CHANGED, IMPORT_GCAL_START, etc. are retired).
type | Role |
|---|---|
eventsChanged | Calendar grid data should be refetched |
calendarsChanged | Calendar list should be refetched |
syncStatusChanged | Connection health changed (syncing/healthy/attention) |
importCompleted | A full/incremental/repair import finished |
userMetadataChanged | Replay / push SuperTokens + sync metadata |
SSEServer (packages/backend/src/servers/sse/sse.server.ts) exposes one
named publish* convenience method per message type, plus a generic
publish(). A completeness test
(packages/backend/src/servers/sse/sse.server.test.ts) enforces that every
publish* method emits a schema-valid frame. Keep it exhaustive when the
ServerMessage union grows.
Outbound Flow: User Changes An Event In Compass
- UI calls a mutation from
useEventMutations. - The mutation's
onMutateapplies an optimistic update to the TanStack Query cache. - The mutation's
mutationFnwrites through the selected repository. - Remote event writes hit backend event routes, which submit a command to the Sync service (see Backend).
- The backend does not publish
eventsChangedfor its own write. The client already applied the change optimistically. Confirmation and cross-tab/cross-device fan-out come from the change-feed poll below.
Primary files:
packages/web/src/events/mutations/useEventMutations.tspackages/web/src/events/repositoriespackages/backend/src/event/controllers/event.controller.tspackages/backend/src/common/services/sync-service/(Sync client)
Inbound Flow: Sync Notifies The Backend About Changes
Google's own webhook ingress, OAuth flow, and import/repair logic all live
inside packages/sync and never touch the backend directly. The backend
learns about changes by polling Sync's own change feed:
- While a user has at least one open SSE connection,
SyncChangeFeedBridge(packages/backend/src/servers/sse/sync-change-feed.bridge.ts) pollsGET /internal/changeson the Sync service every ~2s. - Each page of invalidations is translated to zero or more
ServerMessages bysyncInvalidationToServerMessages(packages/backend/src/servers/sse/sync-invalidation.to-server-message.ts). A Synceventinvalidation becomes aneventsChangedmessage. Acalendarorconnectioninvalidation becomescalendarsChangedpluseventsChanged. AnimportProgressinvalidation becomessyncStatusChanged/importCompleted. - The backend publishes each translated message over SSE.
- Revocation on the mutation path is HTTP 410
CONNECTION_REVOKED(event.controller.tsmaps SyncauthorizationRevoked). The SSE attention codes areCONNECTION_REVOKED(withconnectionId) and the one-release aliasCONNECTION_REVOKED. HelperrevokedConnectionServerMessagesinpackages/core/src/types/server-message.contracts.tsemits both. Milestone C dropsCONNECTION_REVOKEDafter every client readsCONNECTION_REVOKED.
An incrementalPull that applied without writing events (changed + deleted === 0) does not append a calendar invalidation, unless the resource's
cursor-expiry streak cleared or its generation was promoted during that pull.
Idle cursor-only advances would otherwise wake every open tab on the 5 s
coalesce cadence. initialImport, repair, and bootstrap catch-up still
append unconditionally so the client learns those finishes. Connection state
(importing -> catchingUp -> healthy) is a separate connection invalidation
from refreshConnectionStateAfterJob, so a first successful no-op pull after
import still flips the UI status.
Primary files:
packages/backend/src/servers/sse/sync-change-feed.bridge.tspackages/backend/src/servers/sse/sync-invalidation.to-server-message.tspackages/core/src/types/server-message.contracts.tspackages/core/src/types/sync/change-feed.contracts.ts(the Sync-side invalidation shapes)
SSE Server Responsibilities
Source:
packages/backend/src/servers/sse/sse.server.tspackages/backend/src/events/controllers/events.controller.ts
The SSE layer:
- accepts authenticated
GET /api/events/streamrequests (SuperTokens session) - registers each open
Responseper user for fan-out - sends periodic comment heartbeats (
: keepalive) so buffering proxies do not delay events - on connect, replays
userMetadataChangedafter subscribe so reconnects get current state
Web Client Responsibilities
Files:
packages/web/src/sse/client/sse.client.tspackages/web/src/sse/hooks/useSSEConnection.tspackages/web/src/sse/hooks/useEventSSE.tspackages/web/src/sse/hooks/useGcalSSE.ts(+useGcalSSE.factory.ts)packages/web/src/sse/hooks/useSyncFocusRefresh.tspackages/web/src/common/hooks/useVisibleAfterHidden.tspackages/web/src/sse/provider/SSEProvider.tsxpackages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.tspackages/web/src/auth/google/state/google.sync.refresh.ts
The client:
- opens
EventSourcewhen a session exists (SessionProvider+SSEProvider) - refetches events when
eventsChangedarrives (by invalidating the matching event query scopes) - tracks Google sync/import status from
syncStatusChanged/importCompletedanduserMetadataChanged - handles the
CONNECTION_REVOKEDsyncStatusChangedcode consistently with REST 410 payloads.CONNECTION_REVOKEDis the provider-neutral alias on the same wire; clients must treat both until milestone C dropsCONNECTION_REVOKED - auto-refreshes Google Calendar sync on app focus (below)
Refetches are driven by TanStack Query invalidation keyed to the message
type; userMetadataChanged payloads land in the userMetadata Zustand
store.
Focus Refresh
SSEProvider mounts useSyncFocusRefresh, which calls the same
useConnectGoogle().refresh path as the sidebar Refresh calendar button:
- once when a refreshable connection becomes available on mount
- again whenever the tab returns to visible after being hidden for at least
30 seconds (
useVisibleAfterHidden; same threshold as version checks)
Constraints:
- runs only for
HEALTHYorATTENTIONconnections (no-op while disconnected, reconnect-required, or still on the initial import) - passes
silent: trueso a transient background failure does not toast the way a manual click's failure does - shares the browser-wide refresh coordinator with manual clicks, so focus and CTA refreshes coalesce instead of racing
Without this, a user can stare at a stale “Updated …” label until they remember to click Refresh calendar.
Connect funnel observability
Every Google or Microsoft calendar connect round-trip emits two PostHog events
that share a short correlationId (cid on the redirect URL):
- Sync's public OAuth callback captures
oauth_callback(distinct idcompass-sync) withprovider,outcome(the redirect status),intent(connectorreconnect, recovered from whether the signed state named a connection), anderrorClass(the Error class name, never the message) when the outcome iserror. - The web app's
applyConnectRedirectcapturesoauth_returnwithprovider,status,intent, and the samecorrelationIdfor every parsed redirect, includingconnected. Existingcalendar_connectedandsignup_failedevents are unchanged.
The reconnect banner starts authorization with intent: "reconnect" so those
events split reconnects from first connects.
Revoked Token And Reconnect Lifecycle (CONNECTION_REVOKED)
- Sync classifies a dead grant as
authorizationRevoked, discards the credential, and derives connection stateactionRequired. Aconnectioninvalidation fans out ascalendarsChanged/eventsChanged. Event mutations against a revoked grant return HTTP 410CONNECTION_REVOKED. - The SSE attention payload may carry
CONNECTION_REVOKED(withconnectionId) and the aliasCONNECTION_REVOKED(seerevokedConnectionServerMessages). Clients that only readCONNECTION_REVOKEDstill work until milestone C. - Web app marks the connection as revoked in session memory.
- User initiates re-consent via the OAuth flow (
POST /api/auth/connections/begin, proxied to Sync). - Sync completes the OAuth exchange; the backend's next metadata fetch / change-feed poll picks up the reconnected state.
Failed Job Self-Heal (Sync Operator Path)
Sync workers mark a job failed after its per-attempt retry ladder is spent.
Nothing else requeues that row unless the failed-job self-heal sweep runs
(failedJobRequeue in packages/sync/src/app.ts, logic in
packages/sync/src/domain/failed-job-requeue.service.ts):
- After a ~30 minute cooldown, the sweep requeues cooled-down failed jobs with
a fresh attempt budget (up to
FAILED_JOB_MAX_REQUEUES, currently 3). - Jobs that keep failing past that budget are exhausted and need an operator. See manage-failed-jobs.
- Exhausted jobs whose connection already has a durable provider read-failure
marker (
lastReadFailureAt, for example GooglenotACalendarUser) are auto-cleared so their coalescing key no longer blocks rediscovery / reconnect enqueue. Health already surfaces those provider errors; keeping the failed row only adds log noise.
Watch sync logs for:
Sync self-heal sweep requeued N failed job(s)Sync self-heal sweep cleared N exhausted job(s) blocked by durable provider read failureSync self-heal sweep: N failed job(s) exhausted their requeue budget and need operator attention
Rules Of Thumb For Changes
- New realtime behavior usually needs changes in
core(server-message.contracts.ts),backend(sse.server+ whichever translator/caller publishes it), andweb(hooks listening viaEventSource). - If you add a new
ServerMessagemember, add a matchingpublish*method onSSEServerand a case insse.server.test.ts's completeness table. - If the UI is stale after edits, confirm a message is actually published
(either via
sync-change-feed.bridge.ts's translation or a directsseServer.publish*call) and that the web hook handles thattype.