Skip to content

fix(outlook): hydrate recurring occurrences from series master during ingestion#409

Open
valentindiehl wants to merge 1 commit into
ridafkih:mainfrom
valentindiehl:fix/outlook-recurring-occurrences
Open

fix(outlook): hydrate recurring occurrences from series master during ingestion#409
valentindiehl wants to merge 1 commit into
ridafkih:mainfrom
valentindiehl:fix/outlook-recurring-occurrences

Conversation

@valentindiehl

Copy link
Copy Markdown

Fixes #407

Problem

Recurring M365 events only ever synced as a single event. Graph's calendarView/delta expands recurring series server-side, but each expanded occurrence item carries only id, type, seriesMasterId, start, and end — no iCalUId, subject, or anything else. parseOutlookEvents dropped them for the missing iCalUId, so the only row that survived was the series master itself, which Graph lists with the first occurrence's start/end. (This is also where the ancient ghost events come from — e.g. a yearly series created in 2012 shows up once, on its 2012 date.)

Fix

  • Request type and seriesMasterId in $select and allow them in the event schema (string | null, Graph sends explicit nulls).
  • After fetching all pages, hydrate each bare occurrence from its series master: metadata from the master, times from the occurrence.
  • Drop the master's own row — its start/end just duplicates the first occurrence.
  • If an occurrence's master isn't in the payload (delta syncs, or series that started before the sync window), fetch it via GET /me/events/{id}. A 404 means the series was deleted in the meantime; the orphaned occurrence is skipped.
  • Bump OUTLOOK_SYNC_TOKEN_VERSION so existing calendars do a full re-sync and backfill occurrences that were previously dropped.

The hydration gate is seriesMasterId && !iCalUId rather than type === "occurrence", so exception items (modified occurrences) pass through untouched when Graph delivers them with full properties, and still get hydrated with their own (correct) times if a stub ever shows up.

Storage-wise this is safe: all occurrences of a series share the master's iCalUId, but event states are keyed on (calendarId, sourceEventUid, startTime, endTime), so they coexist and series edits update in place.

Known limitation

Delta @removed entries carry only the Graph id, while stored rows are keyed by iCalUId — so cancelling a single occurrence mid-series doesn't remove its row until the next full re-sync (delta token expiry / 410). That mismatch predates this PR and affects non-recurring events too (looks like #137); fixing it properly needs the Graph id persisted alongside the uid, which felt like scope creep here.

Verification

  • Ran against a real M365 tenant: a daily series that previously synced as one event produced 75 occurrences within the sync window; 197 blockers pushed to destination calendars with 0 failures. The 2012-dated ghosts disappeared after the version-bump re-sync.
  • New tests cover: hydration from an in-payload master, fetching a master missing from a delta payload, skipping occurrences whose master 404s, and exceptions with full properties passing through unchanged.
  • bun run types, bun run lint, bun run unused, and bun run test all pass.

… ingestion

Graph's calendarView/delta expands recurring series, but each expanded
occurrence item carries only id, type, seriesMasterId, start, and end.
parseOutlookEvents dropped them for missing iCalUId, so only the series
master (listed with the first occurrence's start/end) was ever stored —
recurring events synced as a single event.

Hydrate occurrences from their series master (metadata from the master,
times from the occurrence), drop the master's own row, and fetch masters
that are absent from a delta payload via /me/events/{id}. Bump the
outlook sync token version to force a full re-sync that backfills
existing calendars.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

M365: Recurring entries only get the first entry synced

1 participant