Skip to content

Commit 0184ea1

Browse files
authored
fix(ics): don't wipe existing events when remote fetch fails (#383)
1 parent 00428d4 commit 0184ea1

2 files changed

Lines changed: 99 additions & 27 deletions

File tree

packages/calendar/src/ics/utils/fetch-adapter.ts

Lines changed: 18 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -19,46 +19,38 @@ interface IcsSourceFetcher {
1919
fetchEvents: () => Promise<FetchEventsResult>;
2020
}
2121

22-
const createIcsSourceFetcher = (
23-
config: IcsSourceFetcherConfig,
24-
): IcsSourceFetcher => {
25-
const fetchRemoteIcal = async (): Promise<string | null> => {
26-
try {
27-
const { ical } = await pullRemoteCalendar(
28-
"ical",
29-
config.url,
30-
config.safeFetchOptions,
31-
);
32-
return ical;
33-
} catch {
34-
return null;
35-
}
22+
const createIcsSourceFetcher = (config: IcsSourceFetcherConfig): IcsSourceFetcher => {
23+
/**
24+
* Lets pullRemoteCalendar errors propagate. The previous behavior swallowed
25+
* them and returned null, which caused ingestSource to treat the empty result
26+
* as "the source authoritatively has zero events" and delete every existing
27+
* event_state for the calendar on the next tick. Surfacing the error lets the
28+
* cron mark the run as failed and leave existing events intact for retry.
29+
*/
30+
const fetchRemoteIcal = async (): Promise<string> => {
31+
const { ical } = await pullRemoteCalendar("ical", config.url, config.safeFetchOptions);
32+
return ical;
3633
};
3734

3835
const fetchEvents = async (): Promise<FetchEventsResult> => {
3936
const ical = await fetchRemoteIcal();
40-
4137
if (!ical) {
42-
return { events: [] };
38+
/*
39+
* Defensive: pullRemoteCalendar already throws on invalid/empty bodies,
40+
* but if a future change ever returns an empty string here, treat it as
41+
* unchanged rather than authoritative-empty to keep the no-wipe invariant.
42+
*/
43+
return { events: [], unchanged: true };
4344
}
44-
45-
const { changed } = await createSnapshot(
46-
config.database,
47-
config.calendarId,
48-
ical,
49-
);
50-
45+
const { changed } = await createSnapshot(config.database, config.calendarId, ical);
5146
if (!changed) {
5247
return { events: [], unchanged: true };
5348
}
54-
5549
const calendar = parseIcsCalendarLenient({
5650
icsString: ical,
5751
patches: [coerceCompliantDate],
5852
});
59-
6053
const parsed = parseIcsEvents(calendar);
61-
6254
const events: SourceEvent[] = parsed.map((event) => ({
6355
availability: event.availability,
6456
description: event.description,
@@ -73,7 +65,6 @@ const createIcsSourceFetcher = (
7365
title: event.title,
7466
uid: event.uid,
7567
}));
76-
7768
return { events };
7869
};
7970

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { describe, expect, it, vi, beforeEach } from "vitest";
2+
3+
const { mockPullRemoteCalendar } = vi.hoisted(() => ({
4+
mockPullRemoteCalendar: vi.fn<(...args: unknown[]) => Promise<{ ical: string }>>(),
5+
}));
6+
const { mockCreateSnapshot } = vi.hoisted(() => ({
7+
mockCreateSnapshot: vi.fn<(...args: unknown[]) => Promise<{ changed: boolean }>>(),
8+
}));
9+
10+
vi.mock("../../../src/ics/utils/pull-remote-calendar", () => ({
11+
pullRemoteCalendar: mockPullRemoteCalendar,
12+
}));
13+
vi.mock("../../../src/ics/utils/create-snapshot", () => ({
14+
createSnapshot: mockCreateSnapshot,
15+
}));
16+
17+
const MINIMAL_ICS = [
18+
"BEGIN:VCALENDAR",
19+
"VERSION:2.0",
20+
"PRODID:-//test//test//EN",
21+
"BEGIN:VEVENT",
22+
"UID:event-1@test",
23+
"DTSTAMP:20260517T000000Z",
24+
"DTSTART:20260517T120000Z",
25+
"DTEND:20260517T130000Z",
26+
"SUMMARY:Test",
27+
"END:VEVENT",
28+
"END:VCALENDAR",
29+
].join("\r\n");
30+
31+
const buildConfig = () => ({
32+
calendarId: "calendar-1",
33+
url: "https://example.com/calendar.ics",
34+
database: {} as never,
35+
});
36+
37+
describe("createIcsSourceFetcher", () => {
38+
beforeEach(() => {
39+
mockPullRemoteCalendar.mockReset();
40+
mockCreateSnapshot.mockReset();
41+
});
42+
43+
it("propagates fetch errors instead of returning empty events", async () => {
44+
/*
45+
* Regression: previously this path returned {events: []}, which caused
46+
* ingestSource to delete every existing event_state on a transient hiccup.
47+
*/
48+
const { createIcsSourceFetcher } = await import("../../../src/ics/utils/fetch-adapter");
49+
mockPullRemoteCalendar.mockRejectedValueOnce(new Error("network unreachable"));
50+
51+
const fetcher = createIcsSourceFetcher(buildConfig());
52+
53+
await expect(fetcher.fetchEvents()).rejects.toThrow("network unreachable");
54+
expect(mockCreateSnapshot).not.toHaveBeenCalled();
55+
});
56+
57+
it("returns parsed events on a successful changed fetch", async () => {
58+
const { createIcsSourceFetcher } = await import("../../../src/ics/utils/fetch-adapter");
59+
mockPullRemoteCalendar.mockResolvedValueOnce({ ical: MINIMAL_ICS });
60+
mockCreateSnapshot.mockResolvedValueOnce({ changed: true });
61+
62+
const fetcher = createIcsSourceFetcher(buildConfig());
63+
const result = await fetcher.fetchEvents();
64+
65+
expect(result.events).toHaveLength(1);
66+
expect(result.events[0]?.uid).toBe("event-1@test");
67+
expect(result.unchanged).toBeUndefined();
68+
});
69+
70+
it("returns unchanged when snapshot content has not changed", async () => {
71+
const { createIcsSourceFetcher } = await import("../../../src/ics/utils/fetch-adapter");
72+
mockPullRemoteCalendar.mockResolvedValueOnce({ ical: MINIMAL_ICS });
73+
mockCreateSnapshot.mockResolvedValueOnce({ changed: false });
74+
75+
const fetcher = createIcsSourceFetcher(buildConfig());
76+
const result = await fetcher.fetchEvents();
77+
78+
expect(result.events).toEqual([]);
79+
expect(result.unchanged).toBe(true);
80+
});
81+
});

0 commit comments

Comments
 (0)