Resolve the base event id in the calendar store, not at the call sites #135

Closed
opened 2026-08-31 04:07:11 +00:00 by jcoffey-dev · 0 comments
Owner

Closes #133. Groundwork for #132.

The problem

updateEvent, destroyEvent and rsvp took an id and sent it. The baseEventId ?? id that made them hit the series lived at four call sites instead — EventPopover, CalendarContextMenu, EventEditor, and InviteCard (which was correct only because findByUid queries without expandRecurrences).

All four were right. The point is what happened when one stopped being.

Through 0.16.19 the server caught it: a synthetic id reaching destroy came back as "Deleting synthetic ids is not yet supported" and the user got a toast. 0.16.20 accepts it and writes recurrenceOverrides[recurrenceId] = { excluded: true } instead — one date removed, success reported, under a dialog that had just said "Delete all occurrences?". A forgotten ?? stopped being an error and became silent data loss.

The change

updateEvent and destroyEvent now take the event object and a required scope: "series" | "occurrence". There is exactly one function that turns an event into an id:

export function eventIdForScope(event: CalendarEvent, scope: EventScope): Id {
  return scope === "series" ? (event.baseEventId ?? event.id) : event.id;
}

A caller that wants the series cannot get an occurrence by forgetting anything, and a caller that wants one occurrence has to say so. Every existing call site passes "series", so behaviour is unchanged — this is the plumbing #132 needs, landed on its own so that the change introducing the ambiguity is not also the one resolving it.

rsvp takes the event too, and no longer looks it up by id. Its patch is participants/{key}/participationStatus, which is one of the pointers 0.16.20 allows on an occurrence — so aimed at an instance it would quietly answer for that day alone and nothing would report it. Accepting an invitation means accepting the series.

findByUid gained a comment saying its query omits expandRecurrences deliberately, since InviteCard hands the result straight to destroyEvent. That was load-bearing by accident; now it says so.

Tests

New web/src/store/__tests__/event-scope.test.ts, 12 cases: id resolution for an occurrence, a master and a one-off (which an expanded query gives a synthetic id over a different base — so isOccurrence has to be about the ids, not about recurrence); that a series destroy sends the master id and never the synthetic one; that an occurrence destroy leaves the master in the cache; and that RSVP answers for the series even when handed an occurrence.

Full suite green: 378 web, 90 server.

Merged 2026-08-30 as coffey-labs/ihasmail@6ec2304fc2

Rebuilt from: git history, session transcript.

Closes #133. Groundwork for #132. ## The problem `updateEvent`, `destroyEvent` and `rsvp` took an id and sent it. The `baseEventId ?? id` that made them hit the series lived at four call sites instead — `EventPopover`, `CalendarContextMenu`, `EventEditor`, and `InviteCard` (which was correct only because `findByUid` queries without `expandRecurrences`). All four were right. The point is what happened when one stopped being. Through 0.16.19 the server caught it: a synthetic id reaching `destroy` came back as *"Deleting synthetic ids is not yet supported"* and the user got a toast. **0.16.20 accepts it** and writes `recurrenceOverrides[recurrenceId] = { excluded: true }` instead — one date removed, success reported, under a dialog that had just said *"Delete all occurrences?"*. A forgotten `??` stopped being an error and became silent data loss. ## The change `updateEvent` and `destroyEvent` now take the event object and a **required** `scope: "series" | "occurrence"`. There is exactly one function that turns an event into an id: ```ts export function eventIdForScope(event: CalendarEvent, scope: EventScope): Id { return scope === "series" ? (event.baseEventId ?? event.id) : event.id; } ``` A caller that wants the series cannot get an occurrence by forgetting anything, and a caller that wants one occurrence has to say so. Every existing call site passes `"series"`, so behaviour is unchanged — this is the plumbing #132 needs, landed on its own so that the change introducing the ambiguity is not also the one resolving it. `rsvp` takes the event too, and no longer looks it up by id. Its patch is `participants/{key}/participationStatus`, which is one of the pointers 0.16.20 *allows* on an occurrence — so aimed at an instance it would quietly answer for that day alone and nothing would report it. Accepting an invitation means accepting the series. `findByUid` gained a comment saying its query omits `expandRecurrences` deliberately, since `InviteCard` hands the result straight to `destroyEvent`. That was load-bearing by accident; now it says so. ## Tests New `web/src/store/__tests__/event-scope.test.ts`, 12 cases: id resolution for an occurrence, a master and a one-off (which an expanded query gives a synthetic id over a *different* base — so `isOccurrence` has to be about the ids, not about recurrence); that a series destroy sends the master id and never the synthetic one; that an occurrence destroy leaves the master in the cache; and that RSVP answers for the series even when handed an occurrence. Full suite green: 378 web, 90 server. **Merged** 2026-08-30 as coffey-labs/ihasmail@6ec2304fc28c <sub>Rebuilt from: git history, session transcript.</sub>
This repo is archived. You cannot comment on issues.