Merge pull request #215 from Coffey-Labs/fix/ical-import-batching
Import an iCal file in batches the server will take
This commit is contained in:
@@ -6,9 +6,10 @@ import type { JmapSession, UploadResponse } from "@/jmap/types";
|
|||||||
/**
|
/**
|
||||||
* Importing a file is not importing an invitation, and the difference is the
|
* Importing a file is not importing an invitation, and the difference is the
|
||||||
* count: an emailed invite carries one event, an export carries a year of them.
|
* count: an emailed invite carries one event, an export carries a year of them.
|
||||||
* These pin the two things that follow from that -- one round trip rather than
|
* These pin the three things that follow from that -- as few round trips as the
|
||||||
* one per event, and nothing of where the events came from riding along into
|
* server will take, none of them over the ceiling it will refuse the whole call
|
||||||
* the calendar they land in.
|
* for, and nothing of where the events came from riding along into the calendar
|
||||||
|
* they land in.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** What the server hands back for a two-event file. Ids and the JMAP-only
|
/** What the server hands back for a two-event file. Ids and the JMAP-only
|
||||||
@@ -33,8 +34,11 @@ interface SetArgs { create?: Record<string, Record<string, unknown>>; sendSchedu
|
|||||||
* @param parsed what `CalendarEvent/parse` answers with; a bare object rather
|
* @param parsed what `CalendarEvent/parse` answers with; a bare object rather
|
||||||
* than an array is the single-event shape, which Stalwart also returns.
|
* than an array is the single-event shape, which Stalwart also returns.
|
||||||
* @param notCreated refusals to hand back instead of creations.
|
* @param notCreated refusals to hand back instead of creations.
|
||||||
|
* @param max the ceiling on objects in one call, refused the way Stalwart
|
||||||
|
* refuses it: the whole call, creating nothing.
|
||||||
|
* @param failOn which `/set` call (0-based) answers with an error instead.
|
||||||
*/
|
*/
|
||||||
function server(parsed: unknown, opts: { notCreated?: Record<string, unknown> } = {}) {
|
function server(parsed: unknown, opts: { notCreated?: Record<string, unknown>; max?: number; failOn?: number } = {}) {
|
||||||
const sets: SetArgs[] = [];
|
const sets: SetArgs[] = [];
|
||||||
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
|
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 body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||||
@@ -44,8 +48,14 @@ function server(parsed: unknown, opts: { notCreated?: Record<string, unknown> }
|
|||||||
return [name, { accountId: "a1", parsed: parsed === null ? {} : { [blobIds[0]!]: parsed }, notParsable: [] }, id];
|
return [name, { accountId: "a1", parsed: parsed === null ? {} : { [blobIds[0]!]: parsed }, notParsable: [] }, id];
|
||||||
}
|
}
|
||||||
if (name === "CalendarEvent/set") {
|
if (name === "CalendarEvent/set") {
|
||||||
|
const nth = sets.length;
|
||||||
sets.push({ create: args.create as Record<string, Record<string, unknown>>, sendSchedulingMessages: args.sendSchedulingMessages as boolean });
|
sets.push({ create: args.create as Record<string, Record<string, unknown>>, sendSchedulingMessages: args.sendSchedulingMessages as boolean });
|
||||||
const keys = Object.keys((args.create ?? {}) as object);
|
const keys = Object.keys((args.create ?? {}) as object);
|
||||||
|
// Whole-call refusals, both of them: nothing in this call is created.
|
||||||
|
if (opts.max != null && keys.length > opts.max) {
|
||||||
|
return ["error", { type: "requestTooLarge", description: "The number of ids requested by the client exceeds the maximum number the server is willing to process in a single method call." }, id];
|
||||||
|
}
|
||||||
|
if (opts.failOn === nth) return ["error", { type: "serverFail", description: "the roof fell in" }, id];
|
||||||
const notCreated = opts.notCreated ?? {};
|
const notCreated = opts.notCreated ?? {};
|
||||||
return [name, {
|
return [name, {
|
||||||
accountId: "a1", oldState: "1", newState: "2",
|
accountId: "a1", oldState: "1", newState: "2",
|
||||||
@@ -62,13 +72,15 @@ function server(parsed: unknown, opts: { notCreated?: Record<string, unknown> }
|
|||||||
}
|
}
|
||||||
|
|
||||||
let uploaded: { type?: string; text: string } | null = null;
|
let uploaded: { type?: string; text: string } | null = null;
|
||||||
|
/** Two tests stand a mock in for it; put the store's own back afterwards. */
|
||||||
|
const realInvalidate = useCalendar.getState().invalidate;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
client.session = {
|
client.session = {
|
||||||
capabilities: { [CAP.core]: { maxObjectsInGet: 500, maxObjectsInSet: 500 }, [CAP.calendars]: {} },
|
capabilities: { [CAP.core]: { maxObjectsInGet: 500, maxObjectsInSet: 500 }, [CAP.calendars]: {} },
|
||||||
accounts: {}, primaryAccounts: {}, state: "s1",
|
accounts: {}, primaryAccounts: {}, state: "s1",
|
||||||
} as unknown as JmapSession;
|
} as unknown as JmapSession;
|
||||||
useCalendar.setState({ accountId: "a1", available: true, calendars: {}, events: {}, ranges: {} });
|
useCalendar.setState({ accountId: "a1", available: true, calendars: {}, events: {}, ranges: {}, invalidate: realInvalidate });
|
||||||
uploaded = null;
|
uploaded = null;
|
||||||
// XHR, not fetch, so it is stubbed at the client rather than at the network.
|
// XHR, not fetch, so it is stubbed at the client rather than at the network.
|
||||||
// jsdom's Blob has no `text()`, hence the reader.
|
// jsdom's Blob has no `text()`, hence the reader.
|
||||||
@@ -96,7 +108,7 @@ describe("importing an .ics file", () => {
|
|||||||
expect(uploaded?.text).toContain("BEGIN:VCALENDAR");
|
expect(uploaded?.text).toContain("BEGIN:VCALENDAR");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates every event in one call, not one call each", async () => {
|
it("creates every event in one call when the file fits in one, not one call each", async () => {
|
||||||
const sets = server(PARSED);
|
const sets = server(PARSED);
|
||||||
const n = await useCalendar.getState().importIcs("x", "cal1");
|
const n = await useCalendar.getState().importIcs("x", "cal1");
|
||||||
expect(n).toBe(2);
|
expect(n).toBe(2);
|
||||||
@@ -159,3 +171,69 @@ describe("importing an .ics file", () => {
|
|||||||
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toBe(1);
|
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A real export, rather than the two-event file above.
|
||||||
|
*
|
||||||
|
* `CalendarEvent/set` is refused whole over `maxObjectsInSet` -- the server
|
||||||
|
* does not take the first 500 and drop the rest, it creates nothing and answers
|
||||||
|
* `requestTooLarge` -- so a file large enough to cross the ceiling used to
|
||||||
|
* import no events at all. The server here refuses the same way, which is what
|
||||||
|
* makes these more than an assertion about call counts.
|
||||||
|
*/
|
||||||
|
describe("importing a file bigger than the server will take at once", () => {
|
||||||
|
const MAX = 500;
|
||||||
|
const many = (n: number) =>
|
||||||
|
Array.from({ length: n }, (_, i) => ({
|
||||||
|
"@type": "Event", uid: `uid-${i}@example.org`, title: `Event ${i}`,
|
||||||
|
start: "2026-09-02T09:00:00", duration: "PT1H", timeZone: "Etc/UTC",
|
||||||
|
}));
|
||||||
|
|
||||||
|
it("splits it into calls the server will accept, and files all of it", async () => {
|
||||||
|
const sets = server(many(1200), { max: MAX });
|
||||||
|
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toBe(1200);
|
||||||
|
expect(sets.map((s) => Object.keys(s.create!).length)).toEqual([500, 500, 200]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits by what the session advertises, not by a number of its own", async () => {
|
||||||
|
client.session!.capabilities[CAP.core] = { maxObjectsInGet: 40, maxObjectsInSet: 40 };
|
||||||
|
const sets = server(many(100), { max: 40 });
|
||||||
|
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toBe(100);
|
||||||
|
expect(sets.map((s) => Object.keys(s.create!).length)).toEqual([40, 40, 20]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps every event distinct across the split", async () => {
|
||||||
|
const sets = server(many(600), { max: MAX });
|
||||||
|
await useCalendar.getState().importIcs("x", "cal1");
|
||||||
|
const uids = sets.flatMap((s) => Object.values(s.create!).map((e) => e.uid));
|
||||||
|
expect(new Set(uids).size).toBe(600);
|
||||||
|
expect(uids).toContain("[email protected]");
|
||||||
|
expect(uids).toContain("[email protected]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-reads the calendar once, not once per batch", async () => {
|
||||||
|
server(many(1200), { max: MAX });
|
||||||
|
const invalidate = vi.fn();
|
||||||
|
useCalendar.setState({ invalidate });
|
||||||
|
await useCalendar.getState().importIcs("x", "cal1");
|
||||||
|
expect(invalidate).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says how much got in when a later batch fails, rather than only that it failed", async () => {
|
||||||
|
server(many(1200), { max: MAX, failOn: 2 });
|
||||||
|
await expect(useCalendar.getState().importIcs("x", "cal1")).rejects.toThrow(/1000 of 1200/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves what did get in visible when a later batch fails", async () => {
|
||||||
|
server(many(1200), { max: MAX, failOn: 2 });
|
||||||
|
const invalidate = vi.fn();
|
||||||
|
useCalendar.setState({ invalidate });
|
||||||
|
await expect(useCalendar.getState().importIcs("x", "cal1")).rejects.toThrow();
|
||||||
|
expect(invalidate).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the server's own words through when the very first batch fails", async () => {
|
||||||
|
server(many(1200), { max: MAX, failOn: 0 });
|
||||||
|
await expect(useCalendar.getState().importIcs("x", "cal1")).rejects.toThrow(/roof fell in/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+33
-14
@@ -1,6 +1,6 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { CAP, client, setErrorMessage } from "@/jmap/client";
|
import { CAP, chunk, client, setErrorMessage } from "@/jmap/client";
|
||||||
import type { BusyPeriod, Calendar, CalendarEvent, EmailAddress, GetResponse, Id, JSCalendarParticipant, JSCalendarRecurrenceRule, ParticipantIdentity, QueryResponse, SetResponse } from "@/jmap/types";
|
import type { BusyPeriod, Calendar, CalendarEvent, EmailAddress, GetResponse, Id, JSCalendarParticipant, JSCalendarRecurrenceRule, ParticipantIdentity, QueryResponse, SetError, SetResponse } from "@/jmap/types";
|
||||||
import { toUTCDate, toLocalDateTime, zonedToDate, parseDuration, DAY_MS, browserTimeZone } from "@/lib/dates";
|
import { toUTCDate, toLocalDateTime, zonedToDate, parseDuration, DAY_MS, browserTimeZone } from "@/lib/dates";
|
||||||
import { t } from "@/lib/i18n";
|
import { t } from "@/lib/i18n";
|
||||||
import { useContacts } from "./contacts";
|
import { useContacts } from "./contacts";
|
||||||
@@ -808,11 +808,16 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
|||||||
* a browser, and the one already in Stalwart handles what a hand-rolled
|
* a browser, and the one already in Stalwart handles what a hand-rolled
|
||||||
* parser would not.
|
* parser would not.
|
||||||
*
|
*
|
||||||
* Every event goes out in one `CalendarEvent/set` rather than a call each.
|
* The events go out `maxObjectsInSet` at a time -- the ceiling the session
|
||||||
* The round trips are the smaller half of the reason: `createEvent`
|
* advertises, 500 where a server does not say. A call carrying more than that
|
||||||
* invalidates on the way out, and invalidating re-fetches every cached range,
|
* is refused whole with `requestTooLarge` and creates nothing, so a real
|
||||||
* so importing a year of events one at a time would refetch the calendar a
|
* export -- an 800 KB file is thousands of events -- imported nothing at all
|
||||||
* few hundred times.
|
* while this went out in a single call.
|
||||||
|
*
|
||||||
|
* Batches rather than a call per event, though: `createEvent` invalidates on
|
||||||
|
* the way out, and invalidating re-fetches every cached range, so importing a
|
||||||
|
* year of events one at a time would refetch the calendar a few hundred
|
||||||
|
* times. One invalidate here, after the last batch.
|
||||||
*
|
*
|
||||||
* No scheduling messages. Importing a file is filing something you already
|
* No scheduling messages. Importing a file is filing something you already
|
||||||
* have, and mailing its participants would be a surprise to everyone.
|
* have, and mailing its participants would be a surprise to everyone.
|
||||||
@@ -830,15 +835,29 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
|||||||
// invented, and an event with no UID is not one anything can match to.
|
// invented, and an event with no UID is not one anything can match to.
|
||||||
create[`e${i}`] = { "@type": "Event", ...rest, uid: rest.uid || crypto.randomUUID(), calendarIds: { [calendarId]: true } };
|
create[`e${i}`] = { "@type": "Event", ...rest, uid: rest.uid || crypto.randomUUID(), calendarIds: { [calendarId]: true } };
|
||||||
});
|
});
|
||||||
const res = await client.call<SetResponse<CalendarEvent>>("CalendarEvent/set", { accountId, create, sendSchedulingMessages: false });
|
const keys = Object.keys(create);
|
||||||
get().invalidate();
|
let created = 0;
|
||||||
const created = Object.keys(res.created ?? {}).length;
|
let refused: SetError | undefined;
|
||||||
|
try {
|
||||||
|
for (const part of chunk(keys, client.maxObjectsInSet)) {
|
||||||
|
const sub: Record<string, unknown> = {};
|
||||||
|
for (const k of part) sub[k] = create[k];
|
||||||
|
const res = await client.call<SetResponse<CalendarEvent>>("CalendarEvent/set", { accountId, create: sub, sendSchedulingMessages: false });
|
||||||
|
created += Object.keys(res.created ?? {}).length;
|
||||||
|
refused ??= Object.values(res.notCreated ?? {})[0];
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// A batch that failed with earlier ones already filed: those events are
|
||||||
|
// in the calendar, and an error saying only that the import failed sends
|
||||||
|
// someone looking for events that are already there.
|
||||||
|
if (!created) throw err;
|
||||||
|
throw new Error(`${created} of ${keys.length} events were imported before this happened: ${(err as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
if (created) get().invalidate();
|
||||||
|
}
|
||||||
// Nothing at all got in: say why rather than report importing zero events
|
// Nothing at all got in: say why rather than report importing zero events
|
||||||
// as though the file had been empty.
|
// as though the file had been empty.
|
||||||
if (!created) {
|
if (!created) throw new Error(refused ? setErrorMessage(refused) : "the server did not accept any of its events");
|
||||||
const first = Object.values(res.notCreated ?? {})[0];
|
|
||||||
throw new Error(first ? setErrorMessage(first) : "the server did not accept any of its events");
|
|
||||||
}
|
|
||||||
return created;
|
return created;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user