mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
fix: keep the app's calendar filled past a year on Stalwart 1.0
Stalwart 1.0 refuses an expanded CalendarEvent/query that spans more than the account's maxExpandedQueryDuration (P52W1D by default) with expandDurationTooLarge, and one that expands to more than 3000 occurrences with cannotCalculateOccurrences. The scrolling views load windows of up to about 460 days, and refresh() reloads the whole window on every calendar push, after series edits and on pull to refresh, so the calendar showed a raw error over stale events from then on. The fallback to expanding on the device only knew 0.16's invalidArguments. Expanded range queries are now cut into chunks a day shorter than the advertised limit, with overlapping borders, sent together as far as maxCallsInRequest allows, and their ids de-duplicated. A chunk the server still refuses is halved; if a week still expands to too many occurrences, the whole range is expanded on the device as before. The expanded query keeps a filter of the two bounds only: 1.0 refuses operators there, so filtering several calendars would take a query per calendar, and the store loads every calendar anyway. 0.16 advertises the same limit without enforcing it and returns the same events.
This commit is contained in:
@@ -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<typeof vi.fn>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
+204
-26
@@ -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<string[] | null> {
|
||||
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<string, unknown> = { 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<string>();
|
||||
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, unknown>, 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<string, unknown>,
|
||||
filter: Record<string, string>,
|
||||
): Promise<string[] | null> {
|
||||
const ids: string[] = [];
|
||||
for (let page = 0; page < QUERY_MAX_PAGES; page++) {
|
||||
const args: Record<string, unknown> = {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<StalwartVersion>(['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<StalwartVersion>(['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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user