Recurrence Handling
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.
Structural Model
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
Recurrence Categories
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
Update Scopes
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
How Series Mutations Work
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 Series Splits
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.
Someday And Provider Semantics
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.
Google Sync Boundary
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.
Transition Key And Plan Contract
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.
Recurrence Sync Triage Runbook
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
What To Verify When Changing Recurrence Logic
- 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