Merge pull request #40 from lucletoffe/fix/calendar-date-paging

fix(calendar): page a server-side date filter through CalendarEvent/query
This commit is contained in:
Linus Rath
2026-08-06 12:26:24 +02:00
committed by GitHub
2 changed files with 94 additions and 20 deletions
+36 -6
View File
@@ -42,10 +42,12 @@ describe('calendar operations', () => {
});
describe('queryEvents', () => {
it('should filter via singular inCalendar conditions (Stalwart-compatible)', async () => {
// Stalwart rejects the draft's plural `inCalendars` filter (and
// after/before); calendars are restricted via singular `inCalendar`
// conditions and date filtering stays client-side.
it('should AND the date window with the singular inCalendar condition', async () => {
// Stalwart rejects the draft's plural `inCalendars` filter, so calendars
// are restricted via singular `inCalendar` conditions. The date window
// must reach the server: without it the query asks for every event in
// the account and the response is capped at the page size, so the
// calendar renders empty for anyone with more than one page of history.
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/query', { ids: ['ev1', 'ev2'] }, '0']],
});
@@ -55,10 +57,38 @@ describe('calendar operations', () => {
const call = mockRequest.mock.calls[0][0][0];
expect(call[0]).toBe('CalendarEvent/query');
expect(call[1].filter).toEqual({ inCalendar: 'cal-1' });
expect(call[1].filter).toEqual({
operator: 'AND',
conditions: [
{ inCalendar: 'cal-1' },
{ after: '2026-03-01T00:00:00Z', before: '2026-03-31T23:59:59Z' },
],
});
expect(call[1].accountId).toBe('acc-1');
});
it('should page through a result set larger than one page', async () => {
// Regression: a full page used to be returned as the complete answer, so
// an account with more events than the page size silently lost every
// event past it - the calendar rendered empty.
const first = Array.from({ length: 1000 }, (_, i) => `a${i}`);
mockRequest
.mockResolvedValueOnce({
methodResponses: [['CalendarEvent/query', { ids: first }, '0']],
})
.mockResolvedValueOnce({
methodResponses: [['CalendarEvent/query', { ids: ['tail-1', 'tail-2'] }, '0']],
});
const result = await queryEvents(['cal-1'], '2026-03-01T00:00:00Z', '2026-03-31T23:59:59Z');
expect(result).toHaveLength(1002);
expect(result[1001]).toBe('tail-2');
expect(mockRequest).toHaveBeenCalledTimes(2);
expect(mockRequest.mock.calls[0][0][0][1].position).toBe(0);
expect(mockRequest.mock.calls[1][0][0][1].position).toBe(1000);
});
it('should OR multiple calendars and respect the target account', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/query', { ids: [] }, '0']],
@@ -74,7 +104,7 @@ describe('calendar operations', () => {
expect(call[1].accountId).toBe('acc-shared');
});
it('should omit the filter when no calendars are given', async () => {
it('should omit the filter when no calendars are given and window is empty', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/query', { ids: [] }, '0']],
});
+58 -14
View File
@@ -185,26 +185,70 @@ export async function getCalendars(): Promise<Calendar[]> {
return [...own, ...shared.flat()];
}
const QUERY_PAGE_SIZE = 1000;
// Backstop against a server that keeps returning full pages; 20 pages is far
// beyond any real calendar window and keeps a bug from looping forever.
const QUERY_MAX_PAGES = 20;
/**
* Combine the calendar restriction and the date window into a single filter.
* Stalwart matches an event when its whole time range - including every
* recurrence - overlaps the window, which is exactly the set the client-side
* expander needs (a recurring master whose base start predates the window
* still comes back if any occurrence falls inside). Either part may be absent
* (callers that want every event in an account pass empty bounds).
*/
function buildEventFilter(
calendarIds: string[],
after: string,
before: string,
): Record<string, unknown> | undefined {
const conditions: Record<string, unknown>[] = [];
if (calendarIds.length > 0) conditions.push(buildInCalendarFilter(calendarIds));
const window: Record<string, unknown> = {};
if (after) window.after = after;
if (before) window.before = before;
if (Object.keys(window).length > 0) conditions.push(window);
if (conditions.length === 0) return undefined;
if (conditions.length === 1) return conditions[0];
return { operator: 'AND', conditions };
}
export async function queryEvents(
calendarIds: string[],
_after: string,
_before: string,
after: string,
before: string,
accountId?: string,
): Promise<string[]> {
// Stalwart rejects after/before filters on CalendarEvent/query; date
// filtering is done client-side. Calendars are restricted via the singular
// `inCalendar` condition (the plural `inCalendars` fails the whole query
// with unsupportedFilter on Stalwart).
// The date window is applied server-side. Without it the query asks for
// every event in the account and the response is silently capped at the
// page size, so any account holding more events than one page loses
// everything past it - including the month being displayed. Calendars are
// restricted via the singular `inCalendar` condition (the plural
// `inCalendars` fails the whole query with unsupportedFilter on Stalwart).
const account = accountId || jmapClient.accountId;
const args: Record<string, unknown> = { accountId: account, limit: 1000 };
const timeZone = getUserTimeZone();
if (timeZone) args.timeZone = timeZone;
if (calendarIds.length > 0) args.filter = buildInCalendarFilter(calendarIds);
const res = await jmapClient.request(
[['CalendarEvent/query', args, '0']],
USING,
);
return methodResult<{ ids: string[] }>(res).ids ?? [];
const filter = buildEventFilter(calendarIds, after, before);
const ids: string[] = [];
// Page through the result set: a window can still hold more events than one
// page, and a truncated page must never pass for a complete answer.
for (let page = 0; page < QUERY_MAX_PAGES; page++) {
const args: Record<string, unknown> = {
accountId: account,
limit: QUERY_PAGE_SIZE,
position: ids.length,
};
if (timeZone) args.timeZone = timeZone;
if (filter) args.filter = filter;
const res = await jmapClient.request(
[['CalendarEvent/query', args, '0']],
USING,
);
const batch = methodResult<{ ids: string[] }>(res).ids ?? [];
ids.push(...batch);
if (batch.length < QUERY_PAGE_SIZE) break;
}
return ids;
}
export async function getEvents(ids: string[], accountId?: string): Promise<CalendarEvent[]> {