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

Closes #133.

`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, and every one of them happened to be right.

That was backstopped by the server until now. Through 0.16.19 a synthetic
id reaching `destroy` came back as "Deleting synthetic ids is not yet
supported" and the user saw a toast. 0.16.20 accepts it and removes one
date instead, reporting success under a dialog that said "Delete all
occurrences?" - so a forgotten `??` became silent data loss rather than
an error.

The three methods now take the event and a required `scope`, and there is
exactly one place that turns an event into an id. A caller that wants the
series cannot get an occurrence by forgetting anything; a caller that
wants one occurrence has to say so.

`rsvp` takes the event rather than an id for the same reason, and no
longer looks it up: 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 mean "only that day".

`findByUid` says in a comment that its query omits `expandRecurrences` on
purpose, since InviteCard hands the result straight to `destroyEvent`.
This commit is contained in:
2026-08-30 21:06:56 -07:00
parent 12157f47bf
commit 373822c2ae
6 changed files with 235 additions and 20 deletions
+163
View File
@@ -0,0 +1,163 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CAP, client } from "@/jmap/client";
import { eventIdForScope, isOccurrence, useCalendar } from "@/store/calendar";
import type { CalendarEvent, JmapSession } from "@/jmap/types";
/**
* Through 0.16.19 the server caught a synthetic id for us: `CalendarEvent/set`
* refused one outright, so a mutation aimed at the wrong id of an expanded
* occurrence arrived as a toast rather than as data loss.
*
* 0.16.20 accepts it and writes a `recurrenceOverrides` entry instead — a
* destroy that meant the series removes one date and reports success, under a
* dialog that said "Delete all occurrences?". The resolution therefore lives in
* the store behind a required `scope`, and these tests are what stops it
* drifting back out to the callers.
*/
/** The shape a live 0.16.19 returns for one occurrence of a weekly series. */
const OCCURRENCE: CalendarEvent = {
id: "iaaaaas",
baseEventId: "i",
"@type": "Event",
uid: "u1",
calendarIds: { c1: true },
start: "2026-09-02T09:00:00",
duration: "PT30M",
recurrenceId: "2026-09-02T09:00:00",
participants: {
me: { "@type": "Participant", calendarAddress: "mailto:[email protected]", participationStatus: "needs-action", roles: { attendee: true } },
},
} as unknown as CalendarEvent;
/** A one-off, which an expanded query still hands back with a base of its own. */
const ONE_OFF: CalendarEvent = { ...OCCURRENCE, id: "eaaaaai", baseEventId: "i", recurrenceId: undefined } as unknown as CalendarEvent;
/** A master, fetched by id rather than expanded. */
const MASTER: CalendarEvent = { ...OCCURRENCE, id: "i", baseEventId: undefined, recurrenceId: undefined } as unknown as CalendarEvent;
interface SetCall { update?: Record<string, unknown>; destroy?: string[] }
function server() {
const calls: SetCall[] = [];
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
const methodResponses = body.methodCalls.map(([name, args, id]) => {
if (name === "CalendarEvent/set") {
calls.push({ update: args.update as Record<string, unknown>, destroy: args.destroy as string[] });
return [name, {
accountId: "a1", oldState: "1", newState: "2",
updated: Object.fromEntries(Object.keys((args.update ?? {}) as object).map((k) => [k, null])),
destroyed: (args.destroy ?? []) as string[],
notUpdated: {}, notDestroyed: {},
}, id];
}
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false }, id];
});
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
return calls;
}
beforeEach(() => {
client.session = {
capabilities: { [CAP.core]: { maxObjectsInGet: 500, maxObjectsInSet: 500 }, [CAP.calendars]: {} },
accounts: {},
primaryAccounts: {},
state: "s1",
} as unknown as JmapSession;
useCalendar.setState({
accountId: "a1",
available: true,
calendars: {},
events: { [OCCURRENCE.id]: OCCURRENCE },
ranges: {},
identities: [{ id: "id1", name: "Me", calendarAddress: "mailto:[email protected]", sendTo: {}, isDefault: true }],
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("eventIdForScope", () => {
it("walks an occurrence up to its master for the series", () => {
expect(eventIdForScope(OCCURRENCE, "series")).toBe("i");
});
it("sends the instance as it came for a single occurrence", () => {
expect(eventIdForScope(OCCURRENCE, "occurrence")).toBe("iaaaaas");
});
it("resolves a master to itself under either scope", () => {
expect(eventIdForScope(MASTER, "series")).toBe("i");
expect(eventIdForScope(MASTER, "occurrence")).toBe("i");
});
it("treats a one-off's synthetic id as a series id, because its base is real", () => {
// An expanded query gives a one-off an instance id over a different base.
// Stalwart resolves a synthetic id on a component that is neither recurrent
// nor an override back to the base event, so both scopes are safe here —
// but only `series` sends the id that is unambiguously the event.
expect(eventIdForScope(ONE_OFF, "series")).toBe("i");
expect(isOccurrence(ONE_OFF)).toBe(true);
});
it("does not call a master an occurrence", () => {
expect(isOccurrence(MASTER)).toBe(false);
expect(isOccurrence({ ...MASTER, baseEventId: "i" } as CalendarEvent)).toBe(false);
});
});
describe("destroyEvent", () => {
it("sends the master id for a series, never the synthetic one", async () => {
const calls = server();
await useCalendar.getState().destroyEvent(OCCURRENCE, false, "series");
expect(calls[0]!.destroy).toEqual(["i"]);
expect(calls[0]!.destroy).not.toContain("iaaaaas");
});
it("sends the synthetic id for a single occurrence", async () => {
const calls = server();
await useCalendar.getState().destroyEvent(OCCURRENCE, false, "occurrence");
expect(calls[0]!.destroy).toEqual(["iaaaaas"]);
});
it("drops the occurrence from the cache without evicting the master", async () => {
server();
useCalendar.setState({ events: { i: MASTER, iaaaaas: OCCURRENCE } });
await useCalendar.getState().destroyEvent(OCCURRENCE, false, "occurrence");
expect(useCalendar.getState().events.iaaaaas).toBeUndefined();
expect(useCalendar.getState().events.i).toBeDefined();
});
});
describe("updateEvent", () => {
it("patches the master for a series", async () => {
const calls = server();
await useCalendar.getState().updateEvent(OCCURRENCE, { color: "#f00" }, false, "series");
expect(Object.keys(calls[0]!.update!)).toEqual(["i"]);
});
it("patches the instance for a single occurrence", async () => {
const calls = server();
await useCalendar.getState().updateEvent(OCCURRENCE, { color: "#f00" }, false, "occurrence");
expect(Object.keys(calls[0]!.update!)).toEqual(["iaaaaas"]);
});
});
describe("rsvp", () => {
it("answers for the series even when handed an occurrence", async () => {
// The patch itself survives either scope: `participationStatus` is one of
// the pointers 0.16.20 allows on an occurrence, so an RSVP aimed at an
// instance would quietly mean "only that day" and nothing would say so.
const calls = server();
await useCalendar.getState().rsvp(OCCURRENCE, "accepted");
expect(Object.keys(calls[0]!.update!)).toEqual(["i"]);
expect(calls[0]!.update!.i).toEqual({ "participants/me/participationStatus": "accepted" });
});
it("refuses when the signed-in identity is not a participant", async () => {
server();
useCalendar.setState({ identities: [{ id: "id2", name: "Someone", calendarAddress: "mailto:[email protected]", sendTo: {}, isDefault: true }] });
await expect(useCalendar.getState().rsvp(OCCURRENCE, "accepted")).rejects.toThrow(/not a participant/i);
});
});
+63 -11
View File
@@ -46,6 +46,43 @@ export const CALENDAR_PROPS = [
"myRights",
];
/**
* Which of an event's two ids a mutation means.
*
* `CalendarEvent/query` runs with `expandRecurrences`, so an occurrence arrives
* carrying a synthetic `id` of its own *and* a `baseEventId` pointing at the
* master it was expanded from. Sending one where the other was meant is not a
* distinction the server will make for us:
*
* - Through 0.16.19 a synthetic id was refused outright — *"Updating synthetic
* ids is not yet supported"* — so a slip was loud and arrived as a toast.
* - 0.16.20 accepts it, and writes a `recurrenceOverrides` entry instead. A
* destroy that meant the series now removes one date and reports success,
* under a dialog that said "Delete all occurrences?".
*
* So the choice is named and required rather than left to each caller to
* remember a `??`. There is exactly one place that turns an event into an id,
* and it is below.
*/
export type EventScope = "series" | "occurrence";
/**
* The id to send for `scope`.
*
* `series` walks up to the master; `occurrence` sends the instance as it came.
* A one-off is safe either way — it has a synthetic id like everything an
* expanded query returns, and Stalwart resolves a synthetic id on a component
* that is neither recurrent nor an override back to the base event itself.
*/
export function eventIdForScope(event: CalendarEvent, scope: EventScope): Id {
return scope === "series" ? (event.baseEventId ?? event.id) : event.id;
}
/** Whether this object is an expanded occurrence rather than a master. */
export function isOccurrence(event: CalendarEvent): boolean {
return event.baseEventId != null && event.baseEventId !== event.id;
}
/** A calendar somebody else shared, and the account it lives in. */
export interface SharedCalendar {
accountId: Id;
@@ -85,9 +122,9 @@ interface CalendarState {
instancesIn(start: Date, end: Date): EventInstance[];
getEvent(id: Id): Promise<CalendarEvent | null>;
createEvent(event: Partial<CalendarEvent>, calendarId: Id, sendInvites: boolean): Promise<Id>;
updateEvent(id: Id, patch: Record<string, unknown>, sendInvites: boolean): Promise<void>;
destroyEvent(id: Id, sendInvites: boolean): Promise<void>;
rsvp(id: Id, status: "accepted" | "tentative" | "declined", comment?: string): Promise<void>;
updateEvent(event: CalendarEvent, patch: Record<string, unknown>, sendInvites: boolean, scope: EventScope): Promise<void>;
destroyEvent(event: CalendarEvent, sendInvites: boolean, scope: EventScope): Promise<void>;
rsvp(event: CalendarEvent, status: "accepted" | "tentative" | "declined", comment?: string): Promise<void>;
createCalendar(data: Partial<Calendar>): Promise<Id>;
updateCalendar(id: Id, patch: Partial<Calendar>): Promise<void>;
destroyCalendar(id: Id): Promise<void>;
@@ -359,39 +396,46 @@ export const useCalendar = create<CalendarState>((set, get) => ({
return res.created!.e!.id;
},
async updateEvent(id, patch, sendInvites) {
async updateEvent(event, patch, sendInvites, scope) {
const accountId = get().accountId!;
const id = eventIdForScope(event, scope);
const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, update: { [id]: patch }, sendSchedulingMessages: sendInvites });
const err = res.notUpdated?.[id];
if (err) throw new Error(setErrorMessage(err));
get().invalidate();
},
async destroyEvent(id, sendInvites) {
async destroyEvent(event, sendInvites, scope) {
const accountId = get().accountId!;
const id = eventIdForScope(event, scope);
const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, destroy: [id], sendSchedulingMessages: sendInvites });
const err = res.notDestroyed?.[id];
if (err) throw new Error(setErrorMessage(err));
set((s) => {
const events = { ...s.events };
// Drop both ids: the one that was sent, and the object as the caller
// held it. An occurrence destroy leaves the master alone on purpose.
delete events[id];
if (scope === "occurrence") delete events[event.id];
return { events };
});
get().invalidate();
},
async rsvp(id, status, comment) {
const ev = get().events[id] ?? (await get().getEvent(id));
if (!ev) throw new Error("Event not found");
id = ev.baseEventId ?? id;
const mine = myParticipantKeys(ev, get().identities);
async rsvp(event, status, comment) {
const mine = myParticipantKeys(event, get().identities);
if (!mine.length) throw new Error("You are not a participant of this event");
const patch: Record<string, unknown> = {};
for (const k of mine) {
patch[`participants/${k}/participationStatus`] = status;
if (comment) patch[`participants/${k}/participationComment`] = comment;
}
await get().updateEvent(id, patch, true);
// Answering for the series, not for one date. The patch itself survives
// either scope -- `participants/{key}/participationStatus` is one of the
// pointers 0.16.20 allows on an occurrence -- so this would silently mean
// "only that day" if it were aimed at an instance. Accepting an invitation
// means accepting the series.
await get().updateEvent(event, patch, true, "series");
},
async createCalendar(data) {
@@ -436,6 +480,14 @@ export const useCalendar = create<CalendarState>((set, get) => ({
return res.list ?? [];
},
/**
* The event with this uid, as a master rather than an occurrence.
*
* The query deliberately omits `expandRecurrences`, so what comes back is the
* stored event and `id` is a real id. Callers rely on that — `InviteCard`
* removes a cancelled event by handing this straight to `destroyEvent` — so
* it is a property of this method, not an accident of the default.
*/
async findByUid(uid) {
const accountId = get().accountId;
if (!accountId) return null;
@@ -60,14 +60,13 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
const { inst } = ctx;
const ev = inst.event;
const baseId = ev.baseEventId ?? ev.id;
const canEdit = inst.calendar?.myRights.mayWriteAll || inst.calendar?.myRights.mayWriteOwn || !inst.calendar;
const currentCat = categoryOf(ev, categories);
const participants = Object.keys(ev.participants ?? {}).length;
const patch = async (p: Record<string, unknown>, msg: string) => {
try {
await cal.updateEvent(baseId, p, false);
await cal.updateEvent(ev, p, false, "series");
toast.success(msg);
} catch (err) {
toast.error((err as Error).message);
@@ -92,7 +91,7 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
const recurring = isRecurring(ev);
if (!(await confirmDialog({ title: recurring ? "Delete all occurrences?" : "Delete this event?", confirmLabel: "Delete", danger: true }))) return;
try {
await cal.destroyEvent(baseId, participants > 1);
await cal.destroyEvent(ev, participants > 1, "series");
toast.success("Event deleted");
} catch (err) {
toast.error((err as Error).message);
+3 -1
View File
@@ -178,7 +178,9 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
const patch: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) patch[k] = v === undefined ? null : v;
if (Object.keys(ev.calendarIds)[0] !== calendarId) patch.calendarIds = { [calendarId]: true };
await cal.updateEvent(ev.id, patch, invites);
// `ev` is the master: EventEditor resolves `baseEventId` when it opens
// on an occurrence, so the whole series is what this form edits.
await cal.updateEvent(ev, patch, invites, "series");
toast.success("Event updated");
} else {
const clean: Record<string, unknown> = {};
+2 -3
View File
@@ -22,7 +22,6 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
const myStatus = myKeys.length ? ev.participants?.[myKeys[0]!]?.participationStatus : undefined;
const isOrganizer = ev.isOrigin !== false && (!participants.length || participants.some(([k, p]) => p.roles?.owner && myKeys.includes(k)));
const canEdit = inst.calendar?.myRights.mayWriteAll || (inst.calendar?.myRights.mayWriteOwn && isOrganizer) || !inst.calendar;
const baseId = ev.baseEventId ?? ev.id;
const location = Object.values(ev.locations ?? {})[0];
const vloc = Object.values(ev.virtualLocations ?? {})[0];
const alerts = Object.values(ev.alerts ?? {});
@@ -34,7 +33,7 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
if (!ok) return;
setBusy(true);
try {
await cal.destroyEvent(baseId, participants.length > 1);
await cal.destroyEvent(ev, participants.length > 1, "series");
toast.success("Event deleted");
onClose();
} catch (err) {
@@ -47,7 +46,7 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
const rsvp = async (status: "accepted" | "tentative" | "declined") => {
setBusy(true);
try {
await cal.rsvp(baseId, status);
await cal.rsvp(ev, status);
toast.success("Response sent");
onClose();
} catch (err) {
+2 -2
View File
@@ -54,7 +54,7 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart
target = await cal.getEvent(id);
}
if (!target) throw new Error("Could not add the event to your calendar");
await cal.rsvp(target.id, status);
await cal.rsvp(target, status);
setExisting(await cal.getEvent(target.id));
toast.success(status === "accepted" ? "Invitation accepted" : status === "declined" ? "Invitation declined" : "Marked as tentative");
} catch (err) {
@@ -115,7 +115,7 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart
)}
{method === "CANCEL" && existing && (
<div className="rsvp">
<button className="btn btn-sm btn-danger" disabled={Boolean(busy)} onClick={async () => { try { await cal.destroyEvent(existing.id, false); setExisting(null); toast.success("Removed from calendar"); } catch (err) { toast.error((err as Error).message); } }}>Remove from calendar</button>
<button className="btn btn-sm btn-danger" disabled={Boolean(busy)} onClick={async () => { try { await cal.destroyEvent(existing, false, "series"); setExisting(null); toast.success("Removed from calendar"); } catch (err) { toast.error((err as Error).message); } }}>Remove from calendar</button>
</div>
)}
<span className="sr-only">{email.id}</span>