fix: keep a calendar write that outlasts an account switch out of the next account

This commit is contained in:
Linus Rath
2026-10-29 02:57:07 +01:00
parent e993540ae6
commit 41e8f00509
6 changed files with 442 additions and 70 deletions
+1
View File
@@ -6,6 +6,7 @@
"not_starred": "Not starred"
},
"calendar": {
"account_changed": "The account changed while the calendar was loading or saving.",
"alerts": {
"minutes_after": "{count, plural, one {# minute after} other {# minutes after}}"
},
@@ -0,0 +1,261 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Writes still under way when the account changes. Event and calendar ids
// repeat across accounts (two accounts can both have calendar "b" with event
// "c"), so a write that finishes after the switch must not send its next
// step, which would reach the next account, nor change the store, which
// shows that account by then.
vi.mock('../../api/calendar', () => ({
getCalendars: vi.fn(),
queryEvents: vi.fn(async () => []),
getEvents: vi.fn(async () => []),
getEventIdentities: vi.fn(async () => []),
scanCalendarObjects: vi.fn(async () => []),
isCalendarAccessDenied: vi.fn(() => false),
noteCalendarAccessError: vi.fn(() => false),
resetCalendarAccessDenied: vi.fn(),
createEvent: vi.fn(),
updateEvent: vi.fn(async () => undefined),
deleteEvents: vi.fn(async () => undefined),
batchCreateEvents: vi.fn(async (events: unknown[]) => ({ created: events.length, refused: [] })),
rsvpEvent: vi.fn(async () => undefined),
createCalendar: vi.fn(),
updateCalendar: vi.fn(async () => undefined),
deleteCalendar: vi.fn(async () => undefined),
setDefaultCalendar: vi.fn(async () => undefined),
supportsSyntheticCalendarIds: vi.fn(async () => false),
queryExpandedEvents: vi.fn(async () => []),
hydrateExpandedOccurrences: vi.fn(async (events: unknown[]) => events),
resetSyntheticIdSupport: vi.fn(),
resetTimeZoneChecks: vi.fn(),
}));
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn().mockResolvedValue(null),
setItem: vi.fn().mockResolvedValue(undefined),
removeItem: vi.fn().mockResolvedValue(undefined),
},
}));
vi.mock('../../api/jmap-client', () => ({
jmapClient: { accountId: 'acc-1', isConnected: true },
}));
vi.mock('../../device-sync/app/request', () => ({ requestDeviceSync: vi.fn() }));
import * as calendarApi from '../../api/calendar';
import type { CalendarEvent } from '../../api/types';
import { CalendarAccountChangedError, useCalendarStore } from '../calendar-store';
const mockUpdate = vi.mocked(calendarApi.updateEvent);
const mockDelete = vi.mocked(calendarApi.deleteEvents);
const mockCreate = vi.mocked(calendarApi.createEvent);
const mockGetEvents = vi.mocked(calendarApi.getEvents);
const mockQueryEvents = vi.mocked(calendarApi.queryEvents);
const mockQueryExpanded = vi.mocked(calendarApi.queryExpandedEvents);
const mockBatchCreate = vi.mocked(calendarApi.batchCreateEvents);
const mockUpdateCalendar = vi.mocked(calendarApi.updateCalendar);
const mockCreateCalendar = vi.mocked(calendarApi.createCalendar);
const RID = '2026-03-02T09:00:00';
const participants = {
p0: { calendarAddress: 'mailto:me@example.com', roles: { owner: true }, participationStatus: 'accepted' },
p1: { calendarAddress: 'mailto:bob@example.com', roles: { attendee: true }, participationStatus: 'accepted' },
};
const event = (id: string, title: string): CalendarEvent => ({
id, uid: `${id}@example.com`, title, start: '2026-03-03T10:00:00', duration: 'PT1H', calendarIds: { b: true },
} as unknown as CalendarEvent);
// The next account: its calendar "b" holds an event "c" too.
const THEIRS = event('c', 'Their event');
/** What an account switch does to the store while a request is under way. */
function switchAccount(): void {
useCalendarStore.getState().reset();
useCalendarStore.setState({
calendars: [{ id: 'b', name: 'Their calendar', sortOrder: 0 }] as never,
events: [THEIRS],
tasks: [{ ...THEIRS, '@type': 'Task', progress: 'needs-action' } as CalendarEvent],
loadedRange: { after: '2026-03-01T00:00:00Z', before: '2026-04-01T00:00:00Z' },
});
}
beforeEach(() => {
vi.clearAllMocks();
useCalendarStore.getState().reset();
useCalendarStore.setState({
calendars: [{ id: 'b', name: 'Mine', sortOrder: 0 }, { id: 'e', name: 'Other', sortOrder: 1 }] as never,
events: [event('c', 'My event')],
tasks: [{ ...event('c', 'My task'), '@type': 'Task', progress: 'needs-action' } as CalendarEvent],
loadedRange: { after: '2026-03-01T00:00:00Z', before: '2026-04-01T00:00:00Z' },
});
});
describe('a write the account switch overtook', () => {
it('names the account it started in and leaves the next account\'s event alone (updateEvent)', async () => {
mockUpdate.mockImplementationOnce(async () => switchAccount());
await useCalendarStore.getState().updateEvent('c', { title: 'Renamed' });
expect(mockUpdate).toHaveBeenCalledWith('c', { title: 'Renamed' }, undefined, 'acc-1');
expect(useCalendarStore.getState().events).toEqual([THEIRS]);
});
it('leaves the next account\'s event of the same id in the store (deleteEvent)', async () => {
mockDelete.mockImplementationOnce(async () => switchAccount());
await useCalendarStore.getState().deleteEvent('c');
expect(mockDelete).toHaveBeenCalledWith(['c'], undefined, 'acc-1');
expect(useCalendarStore.getState().events).toEqual([THEIRS]);
});
it('neither re-reads nor shows the event it made (createEvent)', async () => {
mockCreate.mockImplementationOnce(async () => {
switchAccount();
return event('n', 'New');
});
await useCalendarStore.getState().createEvent({ title: 'New' }, 'b');
expect(mockCreate).toHaveBeenCalledWith({ title: 'New' }, 'b', undefined, 'acc-1');
expect(mockGetEvents).not.toHaveBeenCalled();
expect(useCalendarStore.getState().events).toEqual([THEIRS]);
});
it('leaves the answer off the next account\'s event (rsvpEvent)', async () => {
useCalendarStore.setState({ events: [{ ...event('c', 'Invite'), participants } as unknown as CalendarEvent] });
const theirs = { ...THEIRS, participants };
vi.mocked(calendarApi.rsvpEvent).mockImplementationOnce(async () => {
switchAccount();
useCalendarStore.setState({ events: [theirs as unknown as CalendarEvent] });
});
await useCalendarStore.getState().rsvpEvent('c', 'p1', 'declined');
expect(calendarApi.rsvpEvent).toHaveBeenCalledWith('c', 'p1', 'declined', undefined, 'acc-1');
expect(useCalendarStore.getState().events[0].participants?.p1.participationStatus).toBe('accepted');
});
it('sends no second step of a first change to one server occurrence', async () => {
useCalendarStore.setState({
events: [{
id: 's1', baseEventId: 'base', uid: 'series@example.com', recurrenceId: RID, start: RID, duration: 'PT1H',
title: 'Standup', recurrenceRules: [{ frequency: 'weekly' }], recurrenceOverrides: null,
calendarIds: { b: true }, organizerCalendarAddress: 'mailto:me@example.com', participants, sequence: 1,
} as unknown as CalendarEvent],
});
mockUpdate.mockImplementationOnce(async () => switchAccount());
await expect(useCalendarStore.getState().updateEvent('s1', { title: 'Standup, room 2', participants } as Partial<CalendarEvent>, {
sendSchedulingMessages: true,
})).rejects.toBeInstanceOf(CalendarAccountChangedError);
// The quiet override only: the change itself would go to the next account.
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockUpdate.mock.calls[0][2]).toBe(false);
expect(mockQueryExpanded).not.toHaveBeenCalled();
});
it('sends no second step of a first change to one occurrence expanded on the device', async () => {
useCalendarStore.setState({
events: [{
id: `base:${RID}`, originalId: 'base', uid: 'series@example.com', recurrenceId: RID, start: RID, duration: 'PT1H',
title: 'Standup', recurrenceRules: [{ frequency: 'weekly' }], recurrenceOverrides: null,
calendarIds: { b: true }, organizerCalendarAddress: 'mailto:me@example.com', participants, sequence: 1,
} as unknown as CalendarEvent],
});
mockUpdate.mockImplementationOnce(async () => switchAccount());
await expect(useCalendarStore.getState().updateEvent(`base:${RID}`, { title: 'Standup, room 2', participants } as Partial<CalendarEvent>, {
sendSchedulingMessages: true,
})).rejects.toBeInstanceOf(CalendarAccountChangedError);
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockUpdate.mock.calls[0][2]).toBe(false);
});
it('imports no further batch into the next account\'s calendar of the same id', async () => {
mockBatchCreate.mockImplementationOnce(async (events: unknown[]) => {
switchAccount();
return { created: events.length, refused: [] };
});
const events = Array.from({ length: 60 }, (_, i) => ({ uid: `u${i}`, title: `E${i}`, start: '2026-03-03T10:00:00' }));
await expect(useCalendarStore.getState().importEvents(events, 'b')).rejects.toBeInstanceOf(CalendarAccountChangedError);
expect(mockQueryEvents).toHaveBeenCalledWith([], '', '', 'acc-1');
expect(mockBatchCreate).toHaveBeenCalledTimes(1);
expect(mockBatchCreate.mock.calls[0][2]).toBe('acc-1');
});
it('links no further UID once the account changed (importEvents)', async () => {
vi.mocked(calendarApi.getEventIdentities).mockResolvedValueOnce([
{ id: 'x1', uid: 'u1', calendarIds: { e: true } },
{ id: 'x2', uid: 'u2', calendarIds: { e: true } },
] as never);
mockUpdate.mockImplementationOnce(async () => switchAccount());
await expect(useCalendarStore.getState().importEvents([{ uid: 'u1' }, { uid: 'u2' }], 'b'))
.rejects.toBeInstanceOf(CalendarAccountChangedError);
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockBatchCreate).not.toHaveBeenCalled();
});
it('renumbers none of the next account\'s calendars (reorderCalendars)', async () => {
useCalendarStore.setState({ calendars: [{ id: 'b', name: 'Mine', sortOrder: 5 }, { id: 'e', name: 'Other', sortOrder: 6 }] as never });
mockUpdateCalendar.mockImplementationOnce(async () => switchAccount());
await expect(useCalendarStore.getState().reorderCalendars(['e', 'b'])).rejects.toBeInstanceOf(CalendarAccountChangedError);
expect(mockUpdateCalendar).toHaveBeenCalledTimes(1);
expect(mockUpdateCalendar.mock.calls[0][2]).toBe('acc-1');
expect(useCalendarStore.getState().calendars).toEqual([{ id: 'b', name: 'Their calendar', sortOrder: 0 }]);
});
it('adds the calendar it made to no other account\'s list (createCalendar)', async () => {
mockCreateCalendar.mockImplementationOnce(async () => {
switchAccount();
return { id: 'n', name: 'New' } as never;
});
await useCalendarStore.getState().createCalendar('New');
expect(mockCreateCalendar).toHaveBeenCalledWith('New', undefined, undefined, 'acc-1');
expect(useCalendarStore.getState().calendars.map((c) => c.id)).toEqual(['b']);
});
it('removes none of the next account\'s calendar or events (removeCalendar)', async () => {
vi.mocked(calendarApi.deleteCalendar).mockImplementationOnce(async () => switchAccount());
await useCalendarStore.getState().removeCalendar('b');
expect(calendarApi.deleteCalendar).toHaveBeenCalledWith('b', 'acc-1');
expect(useCalendarStore.getState().calendars.map((c) => c.id)).toEqual(['b']);
expect(useCalendarStore.getState().events).toEqual([THEIRS]);
});
it('changes none of the next account\'s tasks', async () => {
mockUpdate.mockImplementationOnce(async () => switchAccount());
await useCalendarStore.getState().updateTask('c', { title: 'Renamed' });
expect(useCalendarStore.getState().tasks[0].title).toBe('Their event');
mockDelete.mockImplementationOnce(async () => switchAccount());
await useCalendarStore.getState().deleteTask('c');
expect(useCalendarStore.getState().tasks).toHaveLength(1);
// A refused tick is put back only on the task it was made on.
useCalendarStore.setState({ tasks: [{ ...event('c', 'Mine'), '@type': 'Task', progress: 'needs-action' } as CalendarEvent] });
mockUpdate.mockImplementationOnce(async () => {
switchAccount();
useCalendarStore.setState({ tasks: [{ ...THEIRS, '@type': 'Task', progress: 'completed' } as CalendarEvent] });
throw new Error('Network request failed');
});
await expect(useCalendarStore.getState().toggleTaskComplete('c')).rejects.toThrow('Network request failed');
expect(useCalendarStore.getState().tasks[0].progress).toBe('completed');
});
});
@@ -111,7 +111,7 @@ describe.each(['0.16.23', '1.0'])('a first change to one server occurrence on St
['s9', { title: 'Standup, room 2', participants }, true],
]);
// Looked up around the occurrence, expanded.
expect(mockGetEvents).toHaveBeenCalledWith(['s9', 'other'], undefined, { expanded: true });
expect(mockGetEvents).toHaveBeenCalledWith(['s9', 'other'], 'acc-1', { expanded: true });
});
it('sends the change to the same id when the override keeps it (recurrence-keyed ids, live)', async () => {
@@ -134,7 +134,7 @@ describe.each(['0.16.23', '1.0'])('a first change to one server occurrence on St
} as Partial<CalendarEvent>, { sendSchedulingMessages: true });
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockUpdate.mock.calls[0]).toEqual(['s1', expect.objectContaining({ participants: askedAgain }), true, undefined]);
expect(mockUpdate.mock.calls[0]).toEqual(['s1', expect.objectContaining({ participants: askedAgain }), true, 'acc-1']);
});
it('sends the change once to an occurrence that has its override already', async () => {
@@ -248,7 +248,7 @@ describe('an occurrence\'s alerts on a series that uses the calendar\'s default
await useCalendarStore.getState().updateEvent(`base:${RID}`, edit);
expect(mockUpdate.mock.calls[0]).toEqual(['base', { useDefaultAlerts: false, alerts: null }, false, undefined]);
expect(mockUpdate.mock.calls[0]).toEqual(['base', { useDefaultAlerts: false, alerts: null }, false, 'acc-1']);
expect(mockUpdate.mock.calls[1][0]).toBe('base');
expect(mockUpdate.mock.calls[1][1]).toHaveProperty('recurrenceOverrides');
});
@@ -60,8 +60,8 @@ describe('calendar order', () => {
await done;
expect(mockUpdateCalendar.mock.calls).toEqual([
['b', { sortOrder: 1 }, undefined],
['a', { sortOrder: 2 }, undefined],
['b', { sortOrder: 1 }, 'acc-1'],
['a', { sortOrder: 2 }, 'acc-1'],
]);
});
+21 -21
View File
@@ -560,7 +560,7 @@ describe('calendar-store', () => {
'cal-1',
);
expect(mockGetEvents).toHaveBeenCalledWith(['ev-new'], undefined);
expect(mockGetEvents).toHaveBeenCalledWith(['ev-new'], 'acc-1');
expect(result.utcStart).toBe('2026-03-02T08:00:00Z');
const events = useCalendarStore.getState().events;
expect(events).toHaveLength(3);
@@ -594,7 +594,7 @@ describe('calendar-store', () => {
// The third arg is the iMIP scheduling flag — undefined here since the
// event has no participants to notify. The fourth is the owning
// account — undefined for primary-account events.
expect(mockUpdateEvent).toHaveBeenCalledWith('master', { title: 'New' }, undefined, undefined);
expect(mockUpdateEvent).toHaveBeenCalledWith('master', { title: 'New' }, undefined, 'acc-1');
});
});
@@ -611,7 +611,7 @@ describe('calendar-store', () => {
await useCalendarStore.getState().setDefaultCalendar('cal-2');
expect(mockSetDefaultCalendar).toHaveBeenCalledWith('cal-2', undefined);
expect(mockSetDefaultCalendar).toHaveBeenCalledWith('cal-2', 'acc-1');
const calendars = useCalendarStore.getState().calendars;
expect(calendars.find((c) => c.id === 'cal-1')?.isDefault).toBe(false);
expect(calendars.find((c) => c.id === 'cal-2')?.isDefault).toBe(true);
@@ -655,7 +655,7 @@ describe('calendar-store', () => {
);
await useCalendarStore.getState().deleteEvent(master!.id);
expect(mockDeleteEvents).toHaveBeenCalledWith(['ev1'], undefined, undefined);
expect(mockDeleteEvents).toHaveBeenCalledWith(['ev1'], undefined, 'acc-1');
// The visible range was reloaded (empty here).
expect(mockQueryEvents).toHaveBeenCalled();
});
@@ -685,10 +685,10 @@ describe('calendar-store', () => {
// No overrides yet: a pointer into the missing map would fail, so the
// whole map is sent.
expect(mockUpdateEvent).toHaveBeenNthCalledWith(
1, 'ev1', { recurrenceOverrides: { '2026-03-02T09:00:00': { excluded: true } } }, undefined, undefined,
1, 'ev1', { recurrenceOverrides: { '2026-03-02T09:00:00': { excluded: true } } }, undefined, 'acc-1',
);
expect(mockUpdateEvent).toHaveBeenNthCalledWith(
2, 'ev1', { 'recurrenceOverrides/2026-03-03T09:00:00': { excluded: true } }, undefined, undefined,
2, 'ev1', { 'recurrenceOverrides/2026-03-03T09:00:00': { excluded: true } }, undefined, 'acc-1',
);
});
});
@@ -722,7 +722,7 @@ describe('calendar-store', () => {
);
// The UID in cal-1 is linked into cal-2; the one already in cal-2 is skipped.
expect(mockUpdateEvent).toHaveBeenCalledWith('e1', { calendarIds: { 'cal-1': true, 'cal-2': true } }, undefined, undefined);
expect(mockUpdateEvent).toHaveBeenCalledWith('e1', { calendarIds: { 'cal-1': true, 'cal-2': true } }, undefined, 'acc-1');
expect(count).toBe(2);
expect(refused).toEqual([]);
const created = batch.mock.calls[0][0][0];
@@ -907,7 +907,7 @@ describe('calendar-store', () => {
start: '2026-03-02T10:00:00',
locations: { l: { name: 'Room 1' } },
sequence: 2,
}, undefined, undefined);
}, undefined, 'acc-1');
expect(mockQueryExpanded).toHaveBeenCalled();
});
@@ -932,14 +932,14 @@ describe('calendar-store', () => {
sequence: 2,
},
},
}, undefined, undefined);
}, undefined, 'acc-1');
// Remembered: a delete goes straight to the exclusion.
await useCalendarStore.getState().deleteEvent('s2');
expect(mockDeleteEvents).not.toHaveBeenCalled();
expect(mockUpdateEvent).toHaveBeenLastCalledWith('base', {
recurrenceOverrides: { '2026-03-09T09:00:00': { excluded: true } },
}, undefined, undefined);
}, undefined, 'acc-1');
} finally {
useCalendarStore.getState().reset();
}
@@ -954,7 +954,7 @@ describe('calendar-store', () => {
await useCalendarStore.getState().deleteEvent('s1');
expect(mockDeleteEvents).toHaveBeenCalledWith(['s1'], undefined, undefined);
expect(mockDeleteEvents).toHaveBeenCalledWith(['s1'], undefined, 'acc-1');
expect(useCalendarStore.getState().events.map((e) => e.id)).toEqual(['s2']);
});
@@ -967,7 +967,7 @@ describe('calendar-store', () => {
await useCalendarStore.getState().deleteEvent('s5');
expect(mockDeleteEvents).toHaveBeenCalledWith(['plain'], undefined, undefined);
expect(mockDeleteEvents).toHaveBeenCalledWith(['plain'], undefined, 'acc-1');
expect(useCalendarStore.getState().events).toEqual([]);
});
@@ -1010,7 +1010,7 @@ describe('calendar-store', () => {
await useCalendarStore.getState().rsvpEvent('s1', 'me', 'accepted');
expect(mockRsvpEvent).toHaveBeenCalledWith('base', 'me', 'accepted', undefined, undefined);
expect(mockRsvpEvent).toHaveBeenCalledWith('base', 'me', 'accepted', undefined, 'acc-1');
});
it('answers just one server occurrence through its synthetic id', async () => {
@@ -1036,7 +1036,7 @@ describe('calendar-store', () => {
expect(mockUpdateEvent).toHaveBeenCalledWith('s1', {
sequence: 4,
participants: { ...participants, me: { ...participants.me, participationStatus: 'declined' } },
}, true, undefined);
}, true, 'acc-1');
expect(useCalendarStore.getState().events[0].participants?.me.participationStatus).toBe('declined');
});
});
@@ -1067,7 +1067,7 @@ describe('calendar-store', () => {
participants: { me: { calendarAddress: 'mailto:me@x', participationStatus: 'tentative' } },
},
},
}, true, undefined);
}, true, 'acc-1');
});
});
@@ -1099,7 +1099,7 @@ describe('calendar-store', () => {
// Never the series itself: the change becomes this occurrence's override.
expect(mockUpdateEvent).toHaveBeenCalledWith('ev1', {
'recurrenceOverrides/a': { start: '2026-03-02T09:00:00', duration: 'PT30M', title: 'New' },
}, undefined, undefined);
}, undefined, 'acc-1');
// Refetched: siblings now carry the server's state.
const events = useCalendarStore.getState().events;
expect(events.length).toBeGreaterThan(0);
@@ -1118,7 +1118,7 @@ describe('calendar-store', () => {
await useCalendarStore.getState().updateEvent('ev1', { calendarIds: { 'acc-2:cal-9': true } });
expect(mockUpdateEvent).toHaveBeenCalledWith('ev1', { calendarIds: { 'cal-9': true } }, undefined, undefined);
expect(mockUpdateEvent).toHaveBeenCalledWith('ev1', { calendarIds: { 'cal-9': true } }, undefined, 'acc-1');
});
it('getMasterEvent fetches the master an occurrence points at', async () => {
@@ -1161,7 +1161,7 @@ describe('calendar-store', () => {
// No organizer on the event: repaired from the invitation's replyTo.
expect(mockRsvpEvent).toHaveBeenCalledWith(
'ev9', 'p1', 'accepted', 'mailto:boss@example.com', undefined,
'ev9', 'p1', 'accepted', 'mailto:boss@example.com', 'acc-1',
);
});
@@ -1195,7 +1195,7 @@ describe('calendar-store', () => {
't1',
{ progress: 'completed', percentComplete: 100 },
undefined,
undefined,
'acc-1',
);
expect(useCalendarStore.getState().tasks[0].progress).toBe('completed');
});
@@ -1229,7 +1229,7 @@ describe('calendar-store', () => {
't1',
{ progress: 'needs-action', percentComplete: 0 },
undefined,
undefined,
'acc-1',
);
});
@@ -1381,7 +1381,7 @@ describe('editing one occurrence of a series', () => {
participants: { ...participants, me: { ...participants.me, participationStatus: 'tentative' } },
},
},
}, true, undefined);
}, true, 'acc-1');
});
it('keeps the 0.16.19 fallback payload for a server that rejects synthetic ids', async () => {
+154 -44
View File
@@ -315,14 +315,53 @@ export function calendarStoreGeneration(): number {
return storeGeneration;
}
/** A range load the store's reset overtook: what it read belongs to the account before. */
/**
* A load or write the store's reset overtook: what it read belongs to the
* account before, and what it had still to write would reach the next one.
*/
export class CalendarAccountChangedError extends Error {
constructor() {
super('The calendar was reset while its events were loading');
super(t('calendar.account_changed', 'The account changed while the calendar was loading or saving.'));
this.name = 'CalendarAccountChangedError';
}
}
/**
* The account a write is made for, taken when it starts: the store
* generation and the primary JMAP account the client serves. Calendar and
* event ids repeat across accounts, so a write still under way after an
* account switch must not send its next step, which would reach the next
* account, nor change the store, which shows that account by then.
*/
interface WriteScope {
generation: number;
primaryAccountId: string | null;
}
function connectedAccountId(): string | null {
return jmapClient.isConnected ? jmapClient.accountId : null;
}
function writeScope(): WriteScope {
return { generation: storeGeneration, primaryAccountId: connectedAccountId() };
}
function inScope(scope: WriteScope): boolean {
return scope.generation === storeGeneration && connectedAccountId() === scope.primaryAccountId;
}
function assertInScope(scope: WriteScope): void {
if (!inScope(scope)) throw new CalendarAccountChangedError();
}
/**
* The JMAP account a write goes to, named on every request: a shared
* calendar's owner, else the primary account the write started in.
*/
function writeAccount(scope: WriteScope, accountId: string | undefined): string | undefined {
return accountId ?? scope.primaryAccountId ?? undefined;
}
/**
* fetchCalendars: load the calendar list into the store. True when it did;
* false without a session, when the request failed, or when a reset overtook
@@ -540,6 +579,7 @@ async function updateBaseEvent(
updates: Record<string, unknown>,
sendSchedulingMessages: boolean | undefined,
accountId: string | undefined,
scope: WriteScope,
): Promise<void> {
const writesOverrides = Object.keys(updates).some((key) => key.split('/', 1)[0] === 'recurrenceOverrides');
if (writesOverrides && overrideSeriesKeysRejected) {
@@ -551,6 +591,7 @@ async function updateBaseEvent(
} catch (err) {
if (!writesOverrides || !isOverrideSeriesKeyRejection(err)) throw err;
overrideSeriesKeysRejected = true;
assertInScope(scope);
await apiUpdateEvent(id, withoutOverrideSeriesKeys(updates), sendSchedulingMessages, accountId);
}
}
@@ -569,6 +610,7 @@ async function updateOccurrence(
patchFor: (options: OverridePatchOptions) => Partial<CalendarEvent>,
sendSchedulingMessages: boolean | undefined,
accountId: string | undefined,
scope: WriteScope,
): Promise<void> {
if (!syntheticIdRejected) {
try {
@@ -578,10 +620,11 @@ async function updateOccurrence(
if (!isSyntheticIdMutationUnsupported(err)) throw err;
syntheticIdRejected = true;
}
assertInScope(scope);
}
const fallback = buildFallbackOverridePatch(instance, patchFor({ seriesKeys: true }));
if (!fallback || !instance.baseEventId) throw new Error('Cannot resolve the occurrence to override');
await updateBaseEvent(instance.baseEventId, fallback, sendSchedulingMessages, accountId);
await updateBaseEvent(instance.baseEventId, fallback, sendSchedulingMessages, accountId, scope);
}
/**
@@ -639,11 +682,14 @@ async function updateOccurrenceInTwoSteps(
patch: Partial<CalendarEvent>,
sendSchedulingMessages: boolean | undefined,
accountId: string | undefined,
scope: WriteScope,
): Promise<void> {
await updateOccurrence(instance, syntheticId, (options) => withNewOverrideDetails(instance, {}, options), false, accountId);
await updateOccurrence(instance, syntheticId, (options) => withNewOverrideDetails(instance, {}, options), false, accountId, scope);
assertInScope(scope);
const current = await findServerOccurrence(instance, accountId).catch(() => null);
assertInScope(scope);
if (!current) throw new OccurrenceNotFoundAgainError();
await updateOccurrence(current, current.id, () => patch, sendSchedulingMessages, accountId);
await updateOccurrence(current, current.id, () => patch, sendSchedulingMessages, accountId, scope);
}
/**
@@ -656,8 +702,9 @@ async function updateOccurrenceInTwoSteps(
* never shown. 0.16 stores and shows it either way; its series keeps the
* same alerts.
*/
async function stopSeriesDefaultAlerts(baseId: string, accountId: string | undefined): Promise<void> {
async function stopSeriesDefaultAlerts(baseId: string, accountId: string | undefined, scope: WriteScope): Promise<void> {
const [base] = await fetchEvents([baseId], accountId);
assertInScope(scope);
if (!base?.useDefaultAlerts) return;
const shown = base.alerts ?? {};
await apiUpdateEvent(baseId, {
@@ -673,6 +720,7 @@ async function destroyOccurrence(
syntheticId: string,
sendSchedulingMessages: boolean | undefined,
accountId: string | undefined,
scope: WriteScope,
): Promise<void> {
if (!syntheticIdRejected) {
try {
@@ -682,6 +730,7 @@ async function destroyOccurrence(
if (!isSyntheticIdMutationUnsupported(err)) throw err;
syntheticIdRejected = true;
}
assertInScope(scope);
}
const fallback = buildFallbackExcludePatch(instance);
if (!fallback || !instance.baseEventId) throw new Error('Cannot resolve the occurrence to exclude');
@@ -697,11 +746,12 @@ async function updateBrowserOccurrence(
target: MutationTarget,
updates: Partial<CalendarEvent>,
sendSchedulingMessages: boolean | undefined,
scope: WriteScope,
): Promise<void> {
const occurrence = target.storeEvent!;
const patch = buildFallbackOverridePatch(occurrence, withNewOverrideDetails(occurrence, updates, { seriesKeys: true }));
if (!patch) throw new Error('Cannot resolve the occurrence to override');
await updateBaseEvent(target.realId, patch, sendSchedulingMessages, target.accountId);
await updateBaseEvent(target.realId, patch, sendSchedulingMessages, writeAccount(scope, target.accountId), scope);
}
/**
@@ -714,21 +764,25 @@ async function updateBrowserOccurrenceInTwoSteps(
target: MutationTarget,
updates: Partial<CalendarEvent>,
sendSchedulingMessages: boolean | undefined,
scope: WriteScope,
): Promise<void> {
const steps = buildOverrideInTwoSteps(target.storeEvent!, updates);
if (!steps) throw new Error('Cannot resolve the occurrence to override');
await updateBaseEvent(target.realId, steps.quiet, false, target.accountId);
await updateBaseEvent(target.realId, steps.change, sendSchedulingMessages, target.accountId);
const accountId = writeAccount(scope, target.accountId);
await updateBaseEvent(target.realId, steps.quiet, false, accountId, scope);
assertInScope(scope);
await updateBaseEvent(target.realId, steps.change, sendSchedulingMessages, accountId, scope);
}
/** Delete one occurrence the device expanded by excluding it on its base event. */
async function destroyBrowserOccurrence(
target: MutationTarget,
sendSchedulingMessages: boolean | undefined,
scope: WriteScope,
): Promise<void> {
const patch = buildFallbackExcludePatch(target.storeEvent!);
if (!patch) throw new Error('Cannot resolve the occurrence to exclude');
await apiUpdateEvent(target.realId, patch, sendSchedulingMessages, target.accountId);
await apiUpdateEvent(target.realId, patch, sendSchedulingMessages, writeAccount(scope, target.accountId));
}
/**
@@ -964,6 +1018,7 @@ export const useCalendarStore = create<CalendarState>()(
// Shared calendars live in the owner's account — route the create there,
// and against the calendar's raw server id (calendarId is the namespaced
// store id for shared calendars).
const scope = writeScope();
const calendars = get().calendars;
const cal = calendars.find((c) => c.id === calendarId);
const accountId = cal?.accountId;
@@ -973,19 +1028,22 @@ export const useCalendarStore = create<CalendarState>()(
event,
cal?.originalId || calendarId,
schedule,
accountId,
writeAccount(scope, accountId),
);
// Made; the store shows another account by now.
if (!inScope(scope)) return created;
// The /set echo lacks server-computed properties (utcStart/utcEnd, the
// normalised recurrence rule); re-read the event so it renders at the
// right instant, then expand a recurring series across the loaded range
// instead of showing a single instance until the next refresh.
let full = created;
try {
const fetched = (await fetchEvents([created.id], accountId)) ?? [];
const fetched = (await fetchEvents([created.id], writeAccount(scope, accountId))) ?? [];
if (fetched[0]) full = fetched[0];
} catch {
// Keep the echo; the next refresh reconciles.
}
if (!inScope(scope)) return full;
// Map the same way fetchEvents does so the optimistic insert doesn't
// collide with the user's own events and stays visible under the right
// calendar filter.
@@ -1004,8 +1062,10 @@ export const useCalendarStore = create<CalendarState>()(
// A change to an occurrence stays on that occurrence: through its
// synthetic id, or as an override on the base event it was expanded
// from. Everything else goes to the stored event.
const scope = writeScope();
const target = resolveMutationTarget(get().events, id, 'occurrence');
const { storeEvent, realId, accountId } = target;
const account = writeAccount(scope, accountId);
// Remap namespaced (shared-calendar) store ids in calendarIds back to the
// raw server ids the owning account knows.
const patch: Record<string, unknown> = { ...changes };
@@ -1042,15 +1102,16 @@ export const useCalendarStore = create<CalendarState>()(
: storeEvent.participants);
if (target.isOccurrence && storeEvent) {
if (stopsDefaultAlerts && storeEvent.baseEventId) {
await stopSeriesDefaultAlerts(storeEvent.baseEventId, accountId);
await stopSeriesDefaultAlerts(storeEvent.baseEventId, account, scope);
assertInScope(scope);
}
if (twoSteps) {
try {
await updateOccurrenceInTwoSteps(storeEvent, realId, patch as Partial<CalendarEvent>, schedule, accountId);
await updateOccurrenceInTwoSteps(storeEvent, realId, patch as Partial<CalendarEvent>, schedule, account, scope);
} catch (err) {
// Its quiet override is on the server, and the series' occurrences
// may have other ids now: a retry must not go to the ones in view.
if (err instanceof OccurrenceNotFoundAgainError) await get().refresh().catch(() => undefined);
if (err instanceof OccurrenceNotFoundAgainError && inScope(scope)) await get().refresh().catch(() => undefined);
throw err;
}
} else {
@@ -1059,19 +1120,25 @@ export const useCalendarStore = create<CalendarState>()(
realId,
(options) => withNewOverrideDetails(storeEvent, patch as Partial<CalendarEvent>, options),
schedule,
accountId,
account,
scope,
);
}
} else if (target.isBrowserOccurrence) {
if (stopsDefaultAlerts) await stopSeriesDefaultAlerts(realId, accountId);
if (stopsDefaultAlerts) {
await stopSeriesDefaultAlerts(realId, account, scope);
assertInScope(scope);
}
if (twoSteps) {
await updateBrowserOccurrenceInTwoSteps(target, patch as Partial<CalendarEvent>, schedule);
await updateBrowserOccurrenceInTwoSteps(target, patch as Partial<CalendarEvent>, schedule, scope);
} else {
await updateBrowserOccurrence(target, patch as Partial<CalendarEvent>, schedule);
await updateBrowserOccurrence(target, patch as Partial<CalendarEvent>, schedule, scope);
}
} else {
await updateBaseEvent(realId, patch, schedule, accountId);
await updateBaseEvent(realId, patch, schedule, account, scope);
}
// Saved; the store shows another account by now, whose events can carry the same ids.
if (!inScope(scope)) return;
set({
events: get().events.map((e) => (e.id === id ? { ...e, ...(changes as Partial<CalendarEvent>) } : e)),
});
@@ -1089,19 +1156,24 @@ export const useCalendarStore = create<CalendarState>()(
deleteEvent: async (id) => {
// Deleting an occurrence removes just that one: through its synthetic
// id, or by excluding it on the base event it was expanded from.
const scope = writeScope();
const target = resolveMutationTarget(get().events, id, 'occurrence');
const { storeEvent, realId, accountId } = target;
const touchesSeries = (!!storeEvent && isRecurringSeriesMember(storeEvent))
|| hasServerOccurrencesOf(get().events, realId, accountId);
const schedule = hasSchedulingParticipants(storeEvent) ? true : undefined;
if (target.isOccurrence && storeEvent) {
await destroyOccurrence(storeEvent, realId, schedule, accountId);
await destroyOccurrence(storeEvent, realId, schedule, writeAccount(scope, accountId), scope);
if (!inScope(scope)) return;
set({ events: get().events.filter((e) => e.id !== id) });
} else if (target.isBrowserOccurrence) {
await destroyBrowserOccurrence(target, schedule);
await destroyBrowserOccurrence(target, schedule, scope);
if (!inScope(scope)) return;
set({ events: get().events.filter((e) => e.id !== id) });
} else {
await apiDeleteEvents([realId], schedule, accountId);
await apiDeleteEvents([realId], schedule, writeAccount(scope, accountId));
// Deleted; the store shows another account by now, whose events can carry the same ids.
if (!inScope(scope)) return;
knownMasters.delete(id);
// Destroying a master removes every expanded occurrence of it, not
// just the tapped one.
@@ -1127,7 +1199,10 @@ export const useCalendarStore = create<CalendarState>()(
// and its SEQUENCE - a change that raises it from a stale one goes out
// lower than the attendees' copies, which 0.16 refuses.
const realId = seriesIdOf(event);
const generation = storeGeneration;
const fetched = (await fetchEvents([realId], event.accountId)) ?? [];
// Read from the account before a reset: not the next account's master.
if (generation !== storeGeneration) throw new CalendarAccountChangedError();
const master = fetched[0];
if (!master) return null;
// Known from here on, so a change to it is sent to its own account under
@@ -1147,10 +1222,12 @@ export const useCalendarStore = create<CalendarState>()(
// isn't in the store; the caller hands it over instead. With scope
// 'series' an occurrence answers for its whole series: the stored event
// is updated.
const write = writeScope();
const target = resolveMutationTarget(get().events, eventId, scope);
const storeEvent = target.storeEvent ?? event;
const realId = target.storeEvent || !event ? target.realId : seriesIdOf(event);
const accountId = target.storeEvent ? target.accountId : event?.accountId ?? target.accountId;
const account = writeAccount(write, accountId);
const occurrence = scope === 'occurrence' && (target.isOccurrence || target.isBrowserOccurrence)
? target.storeEvent ?? null
: null;
@@ -1164,9 +1241,9 @@ export const useCalendarStore = create<CalendarState>()(
const patch = patchFor({ seriesKeys: true });
if (!patch) throw new Error('Participant not found on this occurrence');
if (target.isOccurrence) {
await updateOccurrence(occurrence, target.realId, (options) => patchFor(options) ?? {}, true, accountId);
await updateOccurrence(occurrence, target.realId, (options) => patchFor(options) ?? {}, true, account, write);
} else {
await updateBrowserOccurrence(target, patch, true);
await updateBrowserOccurrence(target, patch, true, write);
}
} else {
// Repair events that are missing the organizer (e.g. imported ones) so
@@ -1175,8 +1252,10 @@ export const useCalendarStore = create<CalendarState>()(
replyTo?.imip && storeEvent && !storeEvent.organizerCalendarAddress
? replyTo.imip
: undefined;
await apiRsvpEvent(realId, participantId, status, repair, accountId);
await apiRsvpEvent(realId, participantId, status, repair, account);
}
// Answered; the store shows another account by now, whose events can carry the same ids.
if (!inScope(write)) return;
requestDeviceSync(CALENDAR_AUTHORITY);
set({
events: get().events.map((e) => {
@@ -1196,11 +1275,12 @@ export const useCalendarStore = create<CalendarState>()(
importEvents: async (events, calendarId) => {
if (events.length === 0) return { imported: 0, refused: [] };
const scope = writeScope();
// Shared calendars live in the owner's account and carry a namespaced
// store id — resolve the raw server id + owning account so dedup and
// create target the right place.
const cal = get().calendars.find((c) => c.id === calendarId);
const accountId = cal?.accountId;
const accountId = writeAccount(scope, cal?.accountId);
const serverCalendarId = cal?.originalId || calendarId;
const refused: RefusedImport[] = [];
// Stalwart enforces UID uniqueness across calendars (#113):
@@ -1211,7 +1291,9 @@ export const useCalendarStore = create<CalendarState>()(
let linked = 0;
try {
const existingIds = await queryEvents([], '', '', accountId);
assertInScope(scope);
const existing = await getEventIdentities(existingIds, accountId);
assertInScope(scope);
const byUid = new Map<string, EventIdentityFields>();
for (const e of existing) if (e.uid) byUid.set(e.uid, e);
const fresh: Partial<CalendarEvent>[] = [];
@@ -1222,6 +1304,7 @@ export const useCalendarStore = create<CalendarState>()(
continue;
}
if (found.calendarIds?.[serverCalendarId]) continue;
assertInScope(scope);
try {
await apiUpdateEvent(
found.id,
@@ -1236,7 +1319,8 @@ export const useCalendarStore = create<CalendarState>()(
}
}
toCreate = fresh;
} catch {
} catch (err) {
if (err instanceof CalendarAccountChangedError) throw err;
// Couldn't dedupe — proceed and let the server reject genuine dupes.
}
const prepared = toCreate.map(prepareImportedEvent);
@@ -1244,6 +1328,8 @@ export const useCalendarStore = create<CalendarState>()(
try {
// Batch in chunks of 50 to avoid oversized requests.
for (let i = 0; i < prepared.length; i += 50) {
// The rest would go to the next account's calendar of the same id.
assertInScope(scope);
try {
const result = await apiBatchCreateEvents(prepared.slice(i, i + 50), serverCalendarId, accountId);
count += result.created;
@@ -1259,7 +1345,7 @@ export const useCalendarStore = create<CalendarState>()(
}
} finally {
// Also when a chunk was refused: show what did get in.
if (count > 0 || linked > 0) {
if ((count > 0 || linked > 0) && inScope(scope)) {
requestDeviceSync(CALENDAR_AUTHORITY);
await get().refresh();
}
@@ -1269,15 +1355,20 @@ export const useCalendarStore = create<CalendarState>()(
},
createCalendar: async (name, color, description) => {
const created = await apiCreateCalendar(name, color, description);
const scope = writeScope();
const created = await apiCreateCalendar(name, color, description, writeAccount(scope, undefined));
// Made in the account before a switch: not one of the next account's.
if (!inScope(scope)) return created;
set({ calendars: sortCalendars([...get().calendars, created]) });
requestDeviceSync(CALENDAR_AUTHORITY);
return created;
},
updateCalendar: async (id, updates) => {
const scope = writeScope();
const cal = get().calendars.find((c) => c.id === id);
await apiUpdateCalendar(cal?.originalId || id, updates, cal?.accountId);
await apiUpdateCalendar(cal?.originalId || id, updates, writeAccount(scope, cal?.accountId));
if (!inScope(scope)) return;
set({
calendars: sortCalendars(get().calendars.map((c) => (c.id === id ? { ...c, ...updates } as Calendar : c))),
});
@@ -1287,6 +1378,7 @@ export const useCalendarStore = create<CalendarState>()(
reorderCalendars: async (orderedIds) => {
const updates = sortOrderUpdates(get().calendars, orderedIds);
if (updates.length === 0) return;
const scope = writeScope();
const before = get().calendars;
const withSortOrders = (sortOrders: Map<string, number>) => sortCalendars(before.map((c) =>
sortOrders.has(c.id) ? { ...c, sortOrder: sortOrders.get(c.id)! } : c));
@@ -1295,23 +1387,28 @@ export const useCalendarStore = create<CalendarState>()(
const saved = new Map<string, number>();
try {
for (const { id, sortOrder } of updates) {
// The rest would renumber the next account's calendars of the same ids.
assertInScope(scope);
const cal = before.find((c) => c.id === id);
await apiUpdateCalendar(cal?.originalId || id, { sortOrder }, cal?.accountId);
await apiUpdateCalendar(cal?.originalId || id, { sortOrder }, writeAccount(scope, cal?.accountId));
saved.set(id, sortOrder);
}
} catch (err) {
// Show the order the server now holds: the moves saved before the
// failure stay, the rest are undone.
set({ calendars: withSortOrders(saved) });
// failure stay, the rest are undone. Not over the next account's list.
if (inScope(scope)) set({ calendars: withSortOrders(saved) });
throw err;
} finally {
if (saved.size > 0) requestDeviceSync(CALENDAR_AUTHORITY);
if (saved.size > 0 && inScope(scope)) requestDeviceSync(CALENDAR_AUTHORITY);
}
},
removeCalendar: async (id) => {
const scope = writeScope();
const cal = get().calendars.find((c) => c.id === id);
await apiDeleteCalendar(cal?.originalId || id, cal?.accountId);
await apiDeleteCalendar(cal?.originalId || id, writeAccount(scope, cal?.accountId));
// Removed; the next account may have a calendar of the same id.
if (!inScope(scope)) return;
set({
calendars: get().calendars.filter((c) => c.id !== id),
events: get().events.filter((e) => !e.calendarIds?.[id]),
@@ -1322,9 +1419,10 @@ export const useCalendarStore = create<CalendarState>()(
},
clearCalendarEvents: async (id) => {
const scope = writeScope();
const cal = get().calendars.find((c) => c.id === id);
try {
const cleared = await apiClearCalendarEvents(cal?.originalId || id, cal?.accountId);
const cleared = await apiClearCalendarEvents(cal?.originalId || id, writeAccount(scope, cal?.accountId));
requestDeviceSync(CALENDAR_AUTHORITY);
return cleared;
} finally {
@@ -1334,8 +1432,10 @@ export const useCalendarStore = create<CalendarState>()(
},
shareCalendar: async (id, principalId, rights) => {
const scope = writeScope();
const cal = get().calendars.find((c) => c.id === id);
await apiSetCalendarShare(cal?.originalId || id, principalId, rights, cal?.accountId);
await apiSetCalendarShare(cal?.originalId || id, principalId, rights, writeAccount(scope, cal?.accountId));
if (!inScope(scope)) return;
set({
calendars: get().calendars.map((c) => {
if (c.id !== id) return c;
@@ -1348,8 +1448,10 @@ export const useCalendarStore = create<CalendarState>()(
},
setDefaultCalendar: async (id) => {
const scope = writeScope();
const cal = get().calendars.find((c) => c.id === id);
await apiSetDefaultCalendar(cal?.originalId || id, cal?.accountId);
await apiSetDefaultCalendar(cal?.originalId || id, writeAccount(scope, cal?.accountId));
if (!inScope(scope)) return;
set({
calendars: get().calendars.map((c) => {
if (c.id === id) return { ...c, isDefault: true };
@@ -1364,20 +1466,24 @@ export const useCalendarStore = create<CalendarState>()(
},
createTask: async (task, calendarId) => {
const scope = writeScope();
const cal = get().calendars.find((c) => c.id === calendarId);
await apiCreateEvent(
{ ...task, '@type': 'Task' },
cal?.originalId || calendarId,
undefined,
cal?.accountId,
writeAccount(scope, cal?.accountId),
);
await get().fetchTasks();
},
updateTask: async (id, changes) => {
const scope = writeScope();
const task = get().tasks.find((t) => t.id === id);
const realId = task?.originalId || id;
await apiUpdateEvent(realId, changes, undefined, task?.accountId);
await apiUpdateEvent(realId, changes, undefined, writeAccount(scope, task?.accountId));
// Saved; the next account's tasks can carry the same ids.
if (!inScope(scope)) return;
set({ tasks: get().tasks.map((t) => (t.id === id ? { ...t, ...changes } : t)) });
},
@@ -1395,19 +1501,23 @@ export const useCalendarStore = create<CalendarState>()(
// Flip the checkbox right away and put it back if the server refuses.
const patchTask = (changes: Partial<CalendarEvent>) =>
set({ tasks: get().tasks.map((t) => (t.id === id ? { ...t, ...changes } : t)) });
const scope = writeScope();
patchTask(next);
try {
await apiUpdateEvent(task.originalId || id, next, undefined, task.accountId);
await apiUpdateEvent(task.originalId || id, next, undefined, writeAccount(scope, task.accountId));
} catch (err) {
patchTask({ progress: task.progress, percentComplete: task.percentComplete });
// Not on the next account's task of the same id.
if (inScope(scope)) patchTask({ progress: task.progress, percentComplete: task.percentComplete });
throw err;
}
},
deleteTask: async (id) => {
const scope = writeScope();
const task = get().tasks.find((t) => t.id === id);
const realId = task?.originalId || id;
await apiDeleteEvents([realId], undefined, task?.accountId);
await apiDeleteEvents([realId], undefined, writeAccount(scope, task?.accountId));
if (!inScope(scope)) return;
set({ tasks: get().tasks.filter((t) => t.id !== id) });
},