diff --git a/src/api/__tests__/calendar-expanded-chunks.test.ts b/src/api/__tests__/calendar-expanded-chunks.test.ts new file mode 100644 index 00000000..7fbd3332 --- /dev/null +++ b/src/api/__tests__/calendar-expanded-chunks.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const h = vi.hoisted(() => ({ session: null as unknown, maxCalls: 16 })); + +vi.mock('../jmap-client', () => ({ + jmapClient: { + accountId: 'acc-1', + request: vi.fn(), + getMaxObjectsInGet: () => 500, + getMaxCallsInRequest: () => h.maxCalls, + get currentSession() { + return h.session; + }, + }, +})); + +import { jmapClient } from '../jmap-client'; +import { queryExpandedEvents } from '../calendar'; +import { useSettingsStore } from '../../stores/settings-store'; +import { + calendarAccountCapability, + createStalwartCalendarMock, + expandingQueryAnswer, + localSeconds, + tooManyOccurrences, + type RangeOccurrence, + type StalwartCalendarMock, + type StalwartVersion, +} from './stalwart-calendar-mock'; + +// An expanded range may span at most maxExpandedQueryDuration (the account's +// calendars capability, an iCalendar duration). Stalwart 1.0 enforces it +// with expandDurationTooLarge; every 0.16 advertises 365 days and never +// checks. A longer range goes out in chunks a day shorter than the limit, +// sharing a second at each border. Too many occurrences in one query +// (cannotCalculateOccurrences on 1.0, invalidArguments on 0.16) halve the +// chunk, down to about a week; past that the device expands the range. + +const mockRequest = jmapClient.request as ReturnType; +const DAY = 86_400; + +// 777 days, about the longest window the month view scrolls to. +const WINDOW = { after: '2025-01-01T00:00:00Z', before: '2027-02-16T23:59:59Z' }; +const occurrences: RangeOccurrence[] = [ + { id: 'o-first', start: '2025-01-02T09:00:00', seconds: 3600 }, + // Across the first border (2025-12-31T00:00:00): found by both chunks. + { id: 'o-across', start: '2025-12-30T23:30:00', seconds: 3600 }, + // Zero-length, exactly on that border: only the overlap finds it. + { id: 'o-border', start: '2025-12-31T00:00:00', seconds: 0 }, + { id: 'o-middle', start: '2026-07-01T09:00:00', seconds: 3600 }, + { id: 'o-late', start: '2027-02-10T09:00:00', seconds: 3600 }, + // Outside the window. + { id: 'o-after', start: '2027-02-17T09:00:00', seconds: 3600 }, +]; +const IN_WINDOW = ['o-first', 'o-across', 'o-border', 'o-middle', 'o-late']; + +let server: StalwartCalendarMock; + +function connect( + version: StalwartVersion, + options: { + maxExpandedQueryDuration?: string | null; + maxSpanSeconds?: number; + maxInstances?: number; + maxCallsInRequest?: number; + occurrences?: RangeOccurrence[]; + } = {}, +): void { + const capability = calendarAccountCapability( + version, + options.maxExpandedQueryDuration === undefined ? 'P52W1D' : options.maxExpandedQueryDuration, + ); + h.session = { accounts: { 'acc-1': { accountCapabilities: { 'urn:ietf:params:jmap:calendars': capability } } } }; + h.maxCalls = options.maxCallsInRequest ?? 16; + server = createStalwartCalendarMock({ + version, + answer: expandingQueryAnswer(version, { seriesId: 'series', occurrences: options.occurrences ?? occurrences, ...options }), + }); + mockRequest.mockImplementation(server.request); +} + +const bounds = () => server.queries().map((q) => { + const filter = q.args.filter as { after: string; before: string }; + return [filter.after, filter.before]; +}); +const spansInDays = () => bounds().map(([after, before]) => (localSeconds(before) - localSeconds(after)) / DAY); + +beforeEach(() => { + vi.clearAllMocks(); + // The bounds are LocalDateTimes in the calendar's zone. + useSettingsStore.setState({ calendarTimeZone: 'UTC' }); +}); + +afterEach(() => { + useSettingsStore.setState({ calendarTimeZone: 'auto' }); +}); + +describe.each(['P365D', 'P52W1D', 'PT8760H'])('with maxExpandedQueryDuration %s', (duration) => { + it('splits a 777-day window into three chunks sent in one request on Stalwart 1.0', async () => { + connect('1.0', { maxExpandedQueryDuration: duration }); + + const ids = await queryExpandedEvents(WINDOW.after, WINDOW.before); + + expect(bounds()).toEqual([ + ['2025-01-01T00:00:00', '2025-12-31T00:00:00'], + ['2025-12-30T23:59:59', '2026-12-29T23:59:59'], + ['2026-12-29T23:59:58', '2027-02-16T23:59:59'], + ]); + for (const days of spansInDays()) expect(days).toBeLessThanOrEqual(364); + expect(new Set(server.queries().map((q) => q.request)).size).toBe(1); + // Each query is one FilterCondition, the only form 1.0 expands. + for (const query of server.queries()) { + expect(Object.keys(query.args.filter as object).sort()).toEqual(['after', 'before']); + } + // Each occurrence once, the border ones included. + expect(ids?.slice().sort()).toEqual([...IN_WINDOW].sort()); + }); +}); + +describe('expanded ranges longer than maxExpandedQueryDuration', () => { + it('gets the same occurrences from 0.16, which advertises the limit but does not enforce it', async () => { + connect('0.16'); + + const ids = await queryExpandedEvents(WINDOW.after, WINDOW.before); + + expect(bounds()).toHaveLength(3); + expect(ids?.slice().sort()).toEqual([...IN_WINDOW].sort()); + }); + + it('asks for two chunks for a 365-day window, like a refresh of a year, on Stalwart 1.0', async () => { + connect('1.0'); + + await queryExpandedEvents('2026-01-01T00:00:00Z', '2027-01-01T00:00:00Z'); + + expect(bounds()).toEqual([ + ['2026-01-01T00:00:00', '2026-12-31T00:00:00'], + ['2026-12-30T23:59:59', '2027-01-01T00:00:00'], + ]); + }); + + it('sends a window within the limit as one query on Stalwart 1.0', async () => { + connect('1.0'); + + await queryExpandedEvents('2026-09-01T00:00:00Z', '2026-10-01T00:00:00Z'); + + expect(bounds()).toEqual([['2026-09-01T00:00:00', '2026-10-01T00:00:00']]); + }); + + it('halves a chunk the server finds too long on Stalwart 1.0', async () => { + // The server enforces less than it advertises. + connect('1.0', { maxSpanSeconds: 200 * DAY }); + + const ids = await queryExpandedEvents(WINDOW.after, WINDOW.before); + + // The two 364-day chunks are refused and halved; the 49-day one is not. + expect(spansInDays().filter((days) => days > 200)).toHaveLength(2); + expect(spansInDays().filter((days) => days <= 200)).toHaveLength(5); + expect(ids?.slice().sort()).toEqual([...IN_WINDOW].sort()); + }); + + it('splits when refused even without the capability on Stalwart 1.0', async () => { + connect('1.0', { maxExpandedQueryDuration: null }); + + const ids = await queryExpandedEvents(WINDOW.after, WINDOW.before); + + // One query for the whole window, refused; its halves (388.5 days), + // refused; their halves. + expect(bounds()[0]).toEqual(['2025-01-01T00:00:00', '2027-02-16T23:59:59']); + expect(bounds()).toHaveLength(1 + 2 + 4); + expect(ids?.slice().sort()).toEqual([...IN_WINDOW].sort()); + }); + + it.each<[StalwartVersion]>([['0.16'], ['1.0']])( + 'halves chunks that expand to too many occurrences on Stalwart %s', + async (version) => { + // Only a chunk holding at most two occurrences is answered. + connect(version, { maxInstances: 2 }); + + const ids = await queryExpandedEvents(WINDOW.after, WINDOW.before); + + expect(server.queries().length).toBeGreaterThan(3); + expect(ids?.slice().sort()).toEqual([...IN_WINDOW].sort()); + }, + ); + + it.each<[StalwartVersion]>([['0.16'], ['1.0']])( + 'leaves the range to the device when even a week expands to too many occurrences on Stalwart %s', + async (version) => { + connect(version); + server = createStalwartCalendarMock({ + version, + answer: (call) => (call.method === 'CalendarEvent/query' ? ['error', tooManyOccurrences(version)] : undefined), + }); + mockRequest.mockImplementation(server.request); + + expect(await queryExpandedEvents('2026-09-01T00:00:00Z', '2026-09-30T23:59:59Z')).toBeNull(); + + // 30 days, halved to 15, 7.5 and 3.75: a chunk of a week or less that + // is still refused gives the whole range up. + const levels = [30, 15, 15, 7.5, 7.5, 7.5, 7.5, ...Array(8).fill(3.75)]; + expect(spansInDays()).toHaveLength(levels.length); + spansInDays().forEach((days, i) => expect(days).toBeCloseTo(levels[i], 0)); + }, + ); + + it('sends as many chunks per request as maxCallsInRequest allows on Stalwart 1.0', async () => { + connect('1.0', { maxCallsInRequest: 2 }); + + await queryExpandedEvents(WINDOW.after, WINDOW.before); + + const [first] = server.queries(); + expect(server.queries().map((q) => q.request)).toEqual([first.request, first.request, first.request + 1]); + }); + + it('pages through a chunk that holds more than one page of occurrences on Stalwart 1.0', async () => { + const busy: RangeOccurrence[] = Array.from({ length: 1500 }, (_, i) => ({ + id: `busy-${i}`, + start: new Date(Date.UTC(2025, 0, 2) + i * 3600_000).toISOString().slice(0, 19), + seconds: 1800, + })); + connect('1.0', { occurrences: [...busy, ...occurrences] }); + + const ids = await queryExpandedEvents(WINDOW.after, WINDOW.before); + + expect(ids).toHaveLength(1500 + IN_WINDOW.length); + expect(server.queries().map((q) => q.args.position)).toEqual([0, 0, 0, 1000]); + }); + + it('reports a refusal it cannot split as a failure on Stalwart 1.0', async () => { + connect('1.0'); + server = createStalwartCalendarMock({ + version: '1.0', + answer: (call) => (call.method === 'CalendarEvent/query' + ? ['error', { type: 'invalidArguments', description: 'The filter must be a single FilterCondition when expanding recurrences' }] + : undefined), + }); + mockRequest.mockImplementation(server.request); + + await expect(queryExpandedEvents(WINDOW.after, WINDOW.before)).rejects.toThrow(/single FilterCondition/); + expect(server.queries()).toHaveLength(3); + }); +}); diff --git a/src/api/__tests__/calendar.test.ts b/src/api/__tests__/calendar.test.ts index 221ba6f0..d43ecd19 100644 --- a/src/api/__tests__/calendar.test.ts +++ b/src/api/__tests__/calendar.test.ts @@ -5,6 +5,7 @@ vi.mock('../jmap-client', () => ({ accountId: 'acc-1', request: vi.fn(), getMaxObjectsInGet: () => 500, + getMaxCallsInRequest: () => 16, }, })); @@ -131,13 +132,14 @@ describe('calendar operations', () => { expect(Object.keys(args.filter).sort()).toEqual(['after', 'before']); }); - it('returns null when the range expands past the server limit', async () => { - mockRequest.mockResolvedValue({ - methodResponses: [['error', { + it('returns null when even a week expands past the server limit', async () => { + // Every chunk query of every request is refused (0.16's answer). + mockRequest.mockImplementation(async (calls: [string, unknown, string][]) => ({ + methodResponses: calls.map(([, , callId]) => ['error', { type: 'invalidArguments', description: 'The number of expanded recurrences exceeds the limit of 1000', - }, '0']], - }); + }, callId]), + })); expect(await queryExpandedEvents('2026-01-01T00:00:00Z', '2027-01-01T00:00:00Z')).toBeNull(); }); diff --git a/src/api/__tests__/stalwart-calendar-mock.ts b/src/api/__tests__/stalwart-calendar-mock.ts index e2eeb17f..12f8ea4a 100644 --- a/src/api/__tests__/stalwart-calendar-mock.ts +++ b/src/api/__tests__/stalwart-calendar-mock.ts @@ -189,6 +189,72 @@ export function createStalwartCalendarMock(options: StalwartCalendarMockOptions) }; } +/** Stalwart 1.0's refusal of an expanded range longer than maxExpandedQueryDuration. */ +export const EXPAND_DURATION_TOO_LARGE = { + type: 'expandDurationTooLarge', + description: "The time span between 'after' and 'before' exceeds maxExpandedQueryDuration", +} as const; + +/** Both versions' refusal of a query that expands to more occurrences than allowed. */ +export function tooManyOccurrences(version: StalwartVersion) { + const description = 'The number of expanded recurrences exceeds the server limit'; + return version === '1.0' + ? { type: 'cannotCalculateOccurrences', description } + : { type: 'invalidArguments', description }; +} + +/** An occurrence of a series: a LocalDateTime start and a length in seconds. */ +export interface RangeOccurrence { + id: string; + start: string; + seconds: number; +} + +/** A LocalDateTime read as seconds (the mock has no time zones). */ +export function localSeconds(value: string): number { + return Date.parse(`${value}Z`) / 1000; +} + +/** Occurrence objects of the series `seriesId`, for `occurrences` of the mock. */ +export function occurrenceObjects(seriesId: string, occurrences: RangeOccurrence[]) { + return Object.fromEntries(occurrences.map((o) => [o.id, { + id: o.id, '@type': 'Event', baseEventId: seriesId, uid: `${seriesId}@example.com`, title: 'Standup', + start: o.start, duration: `PT${o.seconds}S`, recurrenceId: o.start, calendarIds: { cal: true }, + }])); +} + +/** + * Answers CalendarEvent/query the way Stalwart expands a range: occurrences + * that overlap (after, before) strictly, paged. 1.0 refuses a span over + * `maxSpanSeconds` (0.16 never checks it), and both refuse a query that + * expands to more than `maxInstances` occurrences. A query without + * expansion finds the series itself. + */ +export function expandingQueryAnswer( + version: StalwartVersion, + options: { seriesId: string; occurrences: RangeOccurrence[]; maxSpanSeconds?: number; maxInstances?: number }, +) { + return (call: RecordedCall): MethodAnswer | undefined => { + if (call.method !== 'CalendarEvent/query') return undefined; + const { args } = call; + if (!args.expandRecurrences) return ['CalendarEvent/query', { ids: [options.seriesId], position: 0 }]; + const filter = args.filter as { after: string; before: string }; + const after = localSeconds(filter.after); + const before = localSeconds(filter.before); + if (version === '1.0' && before - after > (options.maxSpanSeconds ?? 365 * 86_400)) { + return ['error', { ...EXPAND_DURATION_TOO_LARGE }]; + } + const hits = options.occurrences.filter((o) => { + const start = localSeconds(o.start); + return start < before && start + o.seconds > after; + }); + if (hits.length > (options.maxInstances ?? 3000)) return ['error', tooManyOccurrences(version)]; + const position = (args.position as number | undefined) ?? 0; + const limit = (args.limit as number | undefined) ?? hits.length; + return ['CalendarEvent/query', { ids: hits.slice(position, position + limit).map((o) => o.id), position }]; + }; +} + /** The calendars account capability both versions advertise (0.16 hardcodes 365 days and never enforces it). */ export function calendarAccountCapability( version: StalwartVersion, diff --git a/src/api/calendar.ts b/src/api/calendar.ts index 18b256d7..318720e3 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -9,6 +9,7 @@ import { SYNTHETIC_ID_PROBE, hydrateRecurrenceInstances, isServerRecurrenceInstance, + parseDurationSeconds, } from '../lib/recurrence-instances'; const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS]; @@ -426,15 +427,138 @@ export async function queryEvents( return ids; } +// ─── Expanded ranges ───────────────────────────────────── +// Stalwart 1.0 refuses an expanded CalendarEvent/query that spans more than +// the account's `maxExpandedQueryDuration` (expandDurationTooLarge), and +// one that expands to more occurrences than it allows +// (cannotCalculateOccurrences; 0.16 said invalidArguments). The loaded +// window of a freely scrolling view grows well past a year, so a range is +// queried in chunks within the limit. + +const DAY_SECONDS = 86_400; +// Chunks stay a day below maxExpandedQueryDuration: the server measures the +// span in UTC after resolving the bounds in the request time zone, so a DST +// change or an ambiguous local time moves it. +const EXPANDED_CHUNK_SLACK = DAY_SECONDS; +// A chunk that still expands to too many occurrences is halved down to about +// a week, one the server finds too long down to an hour; past that, and past +// this many queries for one range, the range is expanded on the device. +const MIN_OCCURRENCE_CHUNK = 7 * DAY_SECONDS; +const MIN_DURATION_CHUNK = 3600; +const MAX_EXPANDED_CHUNKS = 64; + +/** + * Part of an expanded range, in seconds of the request time zone's wall + * clock (the LocalDateTime bounds counted as if they were UTC). + */ +interface RangeChunk { + start: number; + end: number; + /** Where the next page of this chunk starts. */ + position: number; +} + +const LOCAL_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/; + +/** A LocalDateTime such as "2026-09-01T00:00:00" as wall-clock seconds, or null. */ +function wallClockSeconds(value: string): number | null { + const match = LOCAL_DATE_TIME.exec(value); + if (!match) return null; + const [year, month, day, hour, minute, second] = match.slice(1).map(Number); + const date = new Date(0); + date.setUTCFullYear(year, month - 1, day); + date.setUTCHours(hour, minute, second, 0); + return date.getTime() / 1000; +} + +function localDateTimeOf(seconds: number): string { + const date = new Date(seconds * 1000); + const pad = (n: number, length = 2) => String(n).padStart(length, '0'); + return `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}` + + `T${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}`; +} + +/** + * [start, end] cut into chunks of at most `span` seconds, or null when that + * takes more than MAX_EXPANDED_CHUNKS. Neighbours share a second: the server + * counts an occurrence in a range only when it overlaps it strictly, so one + * that sits exactly on a border would belong to neither chunk. + */ +function chunkRange(start: number, end: number, span: number): RangeChunk[] | null { + if (span < 2) return null; + const chunks: RangeChunk[] = []; + for (let from = start; chunks.length < MAX_EXPANDED_CHUNKS; from = chunks[chunks.length - 1].end - 1) { + const to = Math.min(from + span, end); + chunks.push({ start: from, end: to, position: 0 }); + if (to >= end) return chunks; + } + return null; +} + +/** + * Whether an expanded query failed on the server's cap on occurrences per + * query: `cannotCalculateOccurrences` on Stalwart 1.0, `invalidArguments` + * ("The number of expanded recurrences exceeds the server limit") on 0.16. + */ +function isOccurrenceLimitError(error: unknown): boolean { + const type = jmapErrorType(error); + if (type === 'cannotCalculateOccurrences') return true; + return type === 'invalidArguments' && /exceeds/i.test(error instanceof Error ? error.message : ''); +} + +/** + * What to query instead of a chunk the server refused: its two halves, or + * null when it is already as small as it gets and the range has to be + * expanded on the device. Any other refusal is rethrown. + */ +function splitRefusedChunk(chunk: RangeChunk, error: unknown): RangeChunk[] | null { + let minimum: number; + if (jmapErrorType(error) === 'expandDurationTooLarge') minimum = MIN_DURATION_CHUNK; + else if (isOccurrenceLimitError(error)) minimum = MIN_OCCURRENCE_CHUNK; + else throw error; + if (chunk.end - chunk.start <= minimum) return null; + const middle = Math.floor((chunk.start + chunk.end) / 2); + return [ + { start: chunk.start, end: middle, position: 0 }, + { start: middle - 1, end: chunk.end, position: 0 }, + ]; +} + +/** + * The longest range an expanded query may span, in seconds: + * `maxExpandedQueryDuration` of the account's calendars capability (the + * primary account's when the account has none), an iCalendar duration such + * as "P52W1D". Every 0.16 advertises 365 days without enforcing it. + */ +function maxExpandedQuerySeconds(accountId: string): number | null { + const accounts = jmapClient.currentSession?.accounts; + for (const id of [accountId, jmapClient.accountId]) { + const capability = accounts?.[id]?.accountCapabilities?.[CAPABILITIES.CALENDARS] as + | { maxExpandedQueryDuration?: unknown } + | undefined; + const duration = capability?.maxExpandedQueryDuration; + const seconds = typeof duration === 'string' ? parseDurationSeconds(duration) : null; + if (seconds) return seconds; + } + return null; +} + /** * Occurrence ids in a date window, with recurring series expanded by the * server into one synthetic id per occurrence (CalendarEvent/query * `expandRecurrences`). Only for servers that accept those ids in - * CalendarEvent/set (supportsSyntheticCalendarIds). The filter holds just - * the two range bounds, the form the server expands with (the webmail - * sends the same); the caller keeps the calendars it asked for. `null` when - * the server refuses to expand this range (more occurrences than its - * maxRecurrenceExpansions): the caller then expands on the device. + * CalendarEvent/set (supportsSyntheticCalendarIds). + * + * Each query's filter is one FilterCondition of the two bounds - Stalwart + * 1.0 refuses operators in an expanded query - so the caller keeps the + * calendars it asked for. The window goes out in chunks within the server's + * maxExpandedQueryDuration, as many per request as maxCallsInRequest + * allows; a synthetic id does not depend on the chunk it was found in, so + * an occurrence two chunks share is kept once. A chunk the server still + * refuses is halved: when it is too long, or when it expands to too many + * occurrences, down to about a week. `null` when even that is refused: the + * caller then expands the whole window on the device (mixing in occurrences + * found so far would show series twice). */ export async function queryExpandedEvents( after: string, @@ -443,30 +567,84 @@ export async function queryExpandedEvents( ): Promise { const account = accountId || jmapClient.accountId; const timeZone = getUserTimeZone(); - const filter = { - after: toLocalDateTime(after, timeZone), - before: toLocalDateTime(before, timeZone), - }; + const bounds = { after: toLocalDateTime(after, timeZone), before: toLocalDateTime(before, timeZone) }; + const baseArgs: Record = { accountId: account, expandRecurrences: true }; + if (timeZone) baseArgs.timeZone = timeZone; + + const start = wallClockSeconds(bounds.after); + const end = wallClockSeconds(bounds.before); + if (start === null || end === null || end <= start) return queryExpandedRange(baseArgs, bounds); + const maxSeconds = maxExpandedQuerySeconds(account); + const span = maxSeconds === null + ? end - start + : maxSeconds - Math.min(EXPANDED_CHUNK_SLACK, Math.floor(maxSeconds / 2)); + const chunks = chunkRange(start, end, span); + if (!chunks) return null; + + const ids = new Set(); + let pending = chunks; + let chunksSent = 0; + while (pending.length > 0) { + const batch = pending.splice(0, Math.max(1, jmapClient.getMaxCallsInRequest())); + // Pages are bounded below; splitting is what has to stop somewhere. + chunksSent += batch.filter((chunk) => chunk.position === 0).length; + if (chunksSent > MAX_EXPANDED_CHUNKS) return null; + const res = await jmapClient.request(batch.map((chunk, i): [string, Record, string] => [ + 'CalendarEvent/query', + { + ...baseArgs, + filter: { after: localDateTimeOf(chunk.start), before: localDateTimeOf(chunk.end) }, + position: chunk.position, + limit: QUERY_PAGE_SIZE, + }, + String(i), + ]), USING); + + // The halves of a refused chunk, and the next page of a full one, go to + // the front of the queue. + const again: RangeChunk[] = []; + for (const [i, chunk] of batch.entries()) { + let page: string[]; + try { + page = methodResult<{ ids?: string[] }>(res, String(i)).ids ?? []; + } catch (error) { + const halves = splitRefusedChunk(chunk, error); + if (!halves) return null; + again.push(...halves); + continue; + } + for (const id of page) ids.add(id); + // A server that keeps returning full pages stops after QUERY_MAX_PAGES. + const next = chunk.position + page.length; + if (page.length === QUERY_PAGE_SIZE && next < QUERY_MAX_PAGES * QUERY_PAGE_SIZE) { + again.push({ ...chunk, position: next }); + } + } + pending = [...again, ...pending]; + } + return [...ids]; +} + +/** + * One expanded query for `filter`, paged: for bounds that cannot be cut into + * chunks. `null` when the server will not expand it. + */ +async function queryExpandedRange( + baseArgs: Record, + filter: Record, +): Promise { const ids: string[] = []; for (let page = 0; page < QUERY_MAX_PAGES; page++) { - const args: Record = { - accountId: account, - filter, - expandRecurrences: true, - limit: QUERY_PAGE_SIZE, - position: ids.length, - }; - if (timeZone) args.timeZone = timeZone; - const res = await jmapClient.request([['CalendarEvent/query', args, '0']], USING); - const entry = res?.methodResponses?.[0]; - if ( - entry?.[0] === 'error' - && entry[1]?.type === 'invalidArguments' - && /expanded recurrences exceeds/i.test(entry[1]?.description ?? '') - ) { - return null; + const res = await jmapClient.request([['CalendarEvent/query', { + ...baseArgs, filter, position: ids.length, limit: QUERY_PAGE_SIZE, + }, '0']], USING); + let batch: string[]; + try { + batch = methodResult<{ ids?: string[] }>(res).ids ?? []; + } catch (error) { + if (jmapErrorType(error) === 'expandDurationTooLarge' || isOccurrenceLimitError(error)) return null; + throw error; } - const batch = methodResult<{ ids: string[] }>(res).ids ?? []; ids.push(...batch); if (batch.length < QUERY_PAGE_SIZE) break; } diff --git a/src/lib/recurrence-instances.ts b/src/lib/recurrence-instances.ts index a03b9c25..d5521333 100644 --- a/src/lib/recurrence-instances.ts +++ b/src/lib/recurrence-instances.ts @@ -201,8 +201,8 @@ function collapseDuplicateInstances( return result; } -/** An ISO 8601 duration ("PT1H30M", "P1DT2H", "P1W") in seconds; null when unparsable. */ -function parseDurationSeconds(duration: string | null | undefined): number | null { +/** An ISO 8601 duration ("PT1H30M", "P1DT2H", "P52W1D") in seconds; null when unparsable. */ +export function parseDurationSeconds(duration: string | null | undefined): number | null { if (!duration) return null; const match = /^-?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/.exec(duration); if (!match) return null; diff --git a/src/stores/__tests__/calendar-store-server-reads.test.ts b/src/stores/__tests__/calendar-store-server-reads.test.ts index 3015e35d..36351ff3 100644 --- a/src/stores/__tests__/calendar-store-server-reads.test.ts +++ b/src/stores/__tests__/calendar-store-server-reads.test.ts @@ -6,6 +6,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // utcEnd, and every read of the app did: the grid, tasks, reminders, the // master of a series, the invitation look-up and the iCal subscription sync. +const h = vi.hoisted(() => ({ session: null as unknown })); + vi.mock('../../api/jmap-client', () => ({ jmapClient: { accountId: 'acc-1', @@ -13,6 +15,9 @@ vi.mock('../../api/jmap-client', () => ({ request: vi.fn(), getMaxObjectsInGet: () => 500, getMaxCallsInRequest: () => 16, + get currentSession() { + return h.session; + }, }, })); vi.mock('../../api/blob', () => ({ uploadBytes: vi.fn(async () => ({ blobId: 'feed-blob' })) })); @@ -21,9 +26,14 @@ import { jmapClient } from '../../api/jmap-client'; import type { CalendarEvent } from '../../api/types'; import { loadEventsInRange, useCalendarStore } from '../calendar-store'; import { useCalendarSubscriptionsStore } from '../calendar-subscriptions-store'; +import { useSettingsStore } from '../settings-store'; import { asksForOverridesWithUtc, + calendarAccountCapability, createStalwartCalendarMock, + expandingQueryAnswer, + occurrenceObjects, + type RangeOccurrence, type StalwartCalendarMock, type StalwartCalendarMockOptions, type StalwartVersion, @@ -205,3 +215,70 @@ describe.each(['0.16', '1.0'])('calendar reads through the stor expect(error).toBe('Internal server error'); }); }); + +// The loaded window of a freely scrolling view grows past a year, and +// refresh() - on every calendar push, after a series edit, on pull to +// refresh - loads all of it again in one range. Stalwart 1.0 refuses an +// expanded range longer than maxExpandedQueryDuration (P52W1D). +describe.each(['0.16', '1.0'])('long loaded windows on Stalwart %s', (version) => { + // About 460 days: a month view after four forward extensions. + const LOADED = { after: '2025-12-01T00:00:00.000Z', before: '2027-03-06T00:00:00.000Z' }; + const standups: RangeOccurrence[] = [ + { id: 'maaaaab', start: '2025-12-15T10:00:00', seconds: 3600 }, + { id: 'maaaaac', start: '2026-09-07T10:00:00', seconds: 3600 }, + { id: 'maaaaad', start: '2027-03-01T10:00:00', seconds: 3600 }, + ]; + let server: StalwartCalendarMock; + + function serve(options: { maxInstances?: number } = {}): void { + server = createStalwartCalendarMock({ + version, + calendars: CALENDARS, + objects: { b: SERIES }, + occurrences: occurrenceObjects('b', standups), + answer: expandingQueryAnswer(version, { seriesId: 'b', occurrences: standups, ...options }), + }); + mockRequest.mockImplementation(server.request); + } + + beforeEach(() => { + vi.clearAllMocks(); + useSettingsStore.setState({ calendarTimeZone: 'UTC' }); + h.session = { + accounts: { 'acc-1': { accountCapabilities: { 'urn:ietf:params:jmap:calendars': calendarAccountCapability(version) } } }, + }; + useCalendarStore.getState().reset(); + useCalendarStore.setState({ calendars: CALENDARS as never, hiddenCalendarIds: [], loadedRange: LOADED }); + }); + + afterEach(() => { + h.session = null; + useSettingsStore.setState({ calendarTimeZone: 'auto' }); + }); + + it(`refreshes the whole loaded window in chunks within the limit on Stalwart ${version}`, async () => { + serve(); + + await useCalendarStore.getState().refresh(); + + const { events, error } = useCalendarStore.getState(); + expect(error).toBeNull(); + expect(events.map((e) => e.id).sort()).toEqual(['maaaaab', 'maaaaac', 'maaaaad']); + expect(events.every((e) => e.recurrenceRules?.length === 1)).toBe(true); + expect(server.queries().filter((q) => q.args.expandRecurrences)).toHaveLength(2); + }); + + it(`expands the window on the device when the server refuses every piece of it on Stalwart ${version}`, async () => { + serve({ maxInstances: 0 }); + + await useCalendarStore.getState().refresh(); + + const { events, error } = useCalendarStore.getState(); + expect(error).toBeNull(); + // The series itself, expanded here: five days, the fourth excluded. + expect(events.map((e) => e.recurrenceId)).toEqual([ + '2026-09-07T10:00:00', '2026-09-08T10:00:00', '2026-09-09T10:00:00', '2026-09-11T10:00:00', + ]); + expect(events.every((e) => e.originalId === 'b')).toBe(true); + }); +}); diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index d6c55f97..5c1e2561 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -343,8 +343,9 @@ export async function loadEventsInRange( try { let fetched: CalendarEvent[] | null = null; if (expand) { - // null: the server won't expand a range this long; the series are - // then fetched whole and expanded below. + // null: the server won't expand the range even in small pieces + // (too many occurrences); the series are then fetched whole and + // expanded below. const expandedIds = await queryExpandedEvents(after, before, accountId); if (expandedIds) { const occurrences = expandedIds.length > 0