This document explains how Compass models recurring events, how recurring edits are expanded, and how Compass and Google stay in sync after a recurrence change.
Compass stores recurring events as:
- one base event with
recurrence.rule - zero or more generated instances with
recurrence.eventId
The base event owns the recurrence rule. Instances do not carry their own independent rule in storage; they point back to the base. When the backend returns an instance through normal event reads, it rehydrates recurrence information from the base.
Primary files:
packages/core/src/types/event.types.tspackages/core/src/util/event/compass.event.rrule.tspackages/backend/src/event/services/event.service.ts
Compass-to-Google event propagation classifies event shape using Categories_Recurrence:
STANDALONERECURRENCE_BASERECURRENCE_INSTANCESTANDALONE_SOMEDAYRECURRENCE_BASE_SOMEDAYRECURRENCE_INSTANCE_SOMEDAY
The Compass-to-Google path treats recurrence handling as a transition problem:
- build a transition context from the incoming Compass payload plus the current DB event
- analyze that transition into a plain
CompassOperationPlan - apply Compass persistence steps from the plan
- execute Google side effects separately if the plan calls for them
Primary files:
packages/backend/src/event/classes/compass.event.parser.tspackages/backend/src/event/classes/compass.event.executor.tspackages/backend/src/sync/services/event-propagation/compass-to-google/compass-to-google.event-propagation.ts
Recurring edits start with RecurringEventUpdateScope:
This EventThis and Following EventsAll Events
CompassEventFactory expands those user-facing scopes into one or more normalized CompassEvent payloads before sync processing runs.
Examples:
This Eventon a recurring instance becomes a single instance update/deleteThis and Following Eventssplits the existing series into:- a truncated old series
- a new series starting at the edited instance
All Eventsresolves to a base-series mutation
Primary file:
packages/backend/src/event/classes/compass.event.generator.ts
The recurrence planner distinguishes several Compass mutation shapes:
CREATE: create a standalone event or a new seriesUPDATE: update one stored eventDELETE: delete one stored event or one full seriesUPDATE_SERIES: update base/instance shared fields without rebuilding the seriesTRUNCATE_SERIES: delete instances after a newUNTILdate, then update the base seriesRECREATE_SERIES: delete generated instances, then recreate the series from the new rule
Current split rule:
- if only the RRULE
UNTILchanged, useTRUNCATE_SERIES - if other recurrence options changed, use
RECREATE_SERIES - if no recurrence split is needed, use
UPDATE_SERIES
This keeps the recurrence interpretation in the planner and the DB mutations in the executor.
Google "this and following" edits and deletes can split a series into multiple changes across incremental sync payloads.
Treat these as independent updates derived from event shape, not as one ordered bundle of related payloads.
Useful heuristics during Google sync:
- base event with a shortened
UNTILusually means the original series was truncated - a new recurring base may represent the follow-on series
- cancelled instances should be handled as instance-level deletions
- payload ordering is not reliable enough to infer user intent by itself
This is why Compass-to-Google event propagation keys off persisted state plus event properties instead of trying to reconstruct a single high-level Google UI action.
isSomeday changes who is treated as the provider of record:
- normal events usually persist with Google provider data and may mirror to Google
- someday events persist as Compass-owned events and skip Google side effects
Transitions between someday and non-someday states are still analyzed as recurrence transitions. The plan decides whether Google should receive create, update, delete, or none.
The recurrence planner does not call Google directly.
Instead:
analyzeCompassTransition(...)describes the implied Google effectapplyCompassPlan(...)performs only Compass DB mutationsCompassToGoogleEventPropagationexecutes Google create/update/delete after Compass persistence succeeds
Delete-oriented Google effects should prefer the persisted DB gEventId when available, then fall back to the incoming payload gEventId.
The planner dispatch key is:
${dbCategory ?? "NIL"}->>${eventCategory}_${status}
Concrete examples from current tests:
NIL->>RECURRENCE_BASE_CONFIRMEDSTANDALONE->>STANDALONE_CANCELLEDRECURRENCE_BASE->>STANDALONE_CONFIRMED
analyzeCompassTransition(...) returns a CompassOperationPlan with:
- transition metadata (
summary,operation,transitionKey) - Compass persistence intent (
compassMutation,steps,provider) - Google side-effect intent (
googleEffect) - optional
clearRecurrenceBeforeGoogleUpdateguard for series -> standalone updates
applyCompassPlan(...) executes the steps in order, then returns:
- transition summary (
Event_Transition) - last persisted Compass event when a step returns one
googleDeleteEventIdresolved from persisted event first, otherwise planner fallback
Compass-to-Google event propagation executes Google effects only after Compass persistence succeeds.
Use this sequence when recurring edits behave unexpectedly:
- Capture the transition key from backend logs:
Handle Compass event(<id>): <transitionKey>
- Look up the key in
PLAN_BUILDERSincompass.event.parser.ts. - Verify the planned
stepsorder andgoogleEffectin unit tests:compass.event.parser.test.tscompass.event.executor.test.tscompass-to-google.event-propagation.test.ts
- Map each step to persistence calls in
executeStep(...):create->_createCompassEventupdate->_updateCompassEventupdate_series->_updateCompassSeriesdelete_single->_deleteSingleCompassEventdelete_series->_deleteSeriesdelete_instances_after_until->_deleteInstancesAfterUntil
- For unexpected Google deletes, confirm
googleDeleteEventIdcame from persisted DBgEventIdbefore payload fallback. - For series -> standalone updates, verify the recurrence-clearing guard:
- planner sets
clearRecurrenceBeforeGoogleUpdate - executor clears
persistedEvent.recurrencebefore_updateGcal(...)
- planner sets
- transition classification for base, instance, standalone, and someday shapes
RecurringEventUpdateScopeexpansion inCompassEventFactory- RRULE split behavior for:
- no split
UNTIL-only truncation- full series recreation
- Google side effects for someday/non-someday transitions
- SSE notifications for calendar vs someday changes
Good test anchors:
packages/backend/src/event/classes/compass.event.parser.test.tspackages/backend/src/event/classes/compass.event.executor.test.tspackages/backend/src/sync/services/event-propagation/__tests__/compass-to-google.all-event.test.tspackages/backend/src/sync/services/event-propagation/__tests__/compass-to-google-this-event/*.test.ts