Skip to main content

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.ts
  • packages/core/src/util/event/compass.event.rrule.ts
  • packages/backend/src/event/services/event.service.ts

Recurrence Categories

Compass-to-Google event propagation classifies event shape using Categories_Recurrence:

  • STANDALONE
  • RECURRENCE_BASE
  • RECURRENCE_INSTANCE

The Compass-to-Google path treats recurrence handling as a transition problem:

  1. build a transition context from the incoming Compass payload plus the current DB event
  2. analyze that transition into a plain CompassOperationPlan
  3. apply Compass persistence steps from the plan
  4. execute Google side effects separately if the plan calls for them

Primary files:

  • packages/backend/src/event/classes/compass.event.parser.ts
  • packages/backend/src/event/classes/compass.event.executor.ts
  • packages/backend/src/sync/services/event-propagation/compass-to-google/compass-to-google.event-propagation.ts

Update Scopes

Recurring edits start with RecurringEventUpdateScope:

  • This Event
  • This and Following Events
  • All Events

CompassEventFactory expands those user-facing scopes into one or more normalized CompassEvent payloads before sync processing runs.

Examples:

  • This Event on a recurring instance becomes a single instance update/delete
  • This and Following Events splits the existing series into:
    • a truncated old series
    • a new series starting at the edited instance
  • All Events resolves 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 series
  • UPDATE: update one stored event
  • DELETE: delete one stored event or one full series
  • UPDATE_SERIES: update base/instance shared fields without rebuilding the series
  • TRUNCATE_SERIES: delete instances after a new UNTIL date, then update the base series
  • RECREATE_SERIES: delete generated instances, then recreate the series from the new rule

Current split rule:

  • if only the RRULE UNTIL changed, use TRUNCATE_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 UNTIL usually 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.

Google Sync Boundary

The recurrence planner does not call Google directly.

Instead:

  • analyzeCompassTransition(...) describes the implied Google effect
  • applyCompassPlan(...) performs only Compass DB mutations
  • CompassToGoogleEventPropagation executes 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_CONFIRMED
  • STANDALONE->>STANDALONE_CANCELLED
  • RECURRENCE_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 clearRecurrenceBeforeGoogleUpdate guard 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
  • googleDeleteEventId resolved 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:

  1. Capture the transition key from backend logs:
    • Handle Compass event(<id>): <transitionKey>
  2. Look up the key in PLAN_BUILDERS in compass.event.parser.ts.
  3. Verify the planned steps order and googleEffect in unit tests:
    • compass.event.parser.test.ts
    • compass.event.executor.test.ts
    • compass-to-google.event-propagation.test.ts
  4. Map each step to persistence calls in executeStep(...):
    • create -> _createCompassEvent
    • update -> _updateCompassEvent
    • update_series -> _updateCompassSeries
    • delete_single -> _deleteSingleCompassEvent
    • delete_series -> _deleteSeries
    • delete_instances_after_until -> _deleteInstancesAfterUntil
  5. For unexpected Google deletes, confirm googleDeleteEventId came from persisted DB gEventId before payload fallback.
  6. For series -> standalone updates, verify the recurrence-clearing guard:
    • planner sets clearRecurrenceBeforeGoogleUpdate
    • executor clears persistedEvent.recurrence before _updateGcal(...)

What To Verify When Changing Recurrence Logic

  • transition classification for base, instance, and standalone shapes
  • RecurringEventUpdateScope expansion in CompassEventFactory
  • RRULE split behavior for:
    • no split
    • UNTIL-only truncation
    • full series recreation
  • Google side effects for recurrence transitions
  • SSE notifications for calendar changes

Good test anchors:

  • packages/backend/src/event/classes/compass.event.parser.test.ts
  • packages/backend/src/event/classes/compass.event.executor.test.ts
  • packages/backend/src/sync/services/event-propagation/__tests__/compass-to-google.all-event.test.ts
  • packages/backend/src/sync/services/event-propagation/__tests__/compass-to-google-this-event/*.test.ts