From 877a7a93b208cd33bd91cbcab5887a474d2cd266 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:42:01 +0100 Subject: [PATCH] feat: keep calendars on the device and queue calendar changes while offline Adds a `calendar` area to the local store. It syncs calendars, participant identities, events and tasks as masters, through CalendarEvent/changes, and answers Calendar/get, CalendarEvent/query, /get and /set from SQLite when the server can't be reached. - A range query gets series in both client modes, with their overrides, and the calendar store expands them as it does for an older server. - The synthetic-id probe gets the verdict from the last sync, so a client started offline behaves the same once the server is back. - Create, edit, delete and RSVP queue in `calendar_pending`, one merged row per event, and go out through `calendar.flush` or the next sync. A create finds itself by uid after a lost response. - A write the server refuses, or one on a value the server changed, goes to `calendar.conflicts`. - `lib/store/calendar/reminders.ts` lists upcoming alerts with keys that don't depend on synthetic ids. --- lib/recurrence-expansion.ts | 8 +- lib/recurrence-instances.ts | 2 +- .../__tests__/calendar-client-modes.test.ts | 212 ++++ lib/store/__tests__/calendar-no-dom.test.ts | 32 + lib/store/__tests__/calendar-sync.test.ts | 955 ++++++++++++++++++ lib/store/__tests__/helpers/client-wire.ts | 79 ++ lib/store/__tests__/helpers/fake-calendar.ts | 365 +++++++ lib/store/__tests__/helpers/fake-jmap.ts | 2 +- lib/store/area.ts | 5 +- lib/store/areas.ts | 3 +- lib/store/calendar/filter.ts | 220 ++++ lib/store/calendar/flush.ts | 229 +++++ lib/store/calendar/index.ts | 19 + lib/store/calendar/methods.ts | 197 ++++ lib/store/calendar/records.ts | 162 +++ lib/store/calendar/reminders.ts | 160 +++ lib/store/calendar/schema.ts | 77 ++ lib/store/calendar/sync.ts | 264 +++++ lib/store/calendar/writes.ts | 259 +++++ lib/store/engine/account-engine.ts | 2 +- .../calendar-store-offline-occurrence.test.ts | 117 +++ 21 files changed, 3359 insertions(+), 10 deletions(-) create mode 100644 lib/store/__tests__/calendar-client-modes.test.ts create mode 100644 lib/store/__tests__/calendar-no-dom.test.ts create mode 100644 lib/store/__tests__/calendar-sync.test.ts create mode 100644 lib/store/__tests__/helpers/client-wire.ts create mode 100644 lib/store/__tests__/helpers/fake-calendar.ts create mode 100644 lib/store/calendar/filter.ts create mode 100644 lib/store/calendar/flush.ts create mode 100644 lib/store/calendar/index.ts create mode 100644 lib/store/calendar/methods.ts create mode 100644 lib/store/calendar/records.ts create mode 100644 lib/store/calendar/reminders.ts create mode 100644 lib/store/calendar/schema.ts create mode 100644 lib/store/calendar/sync.ts create mode 100644 lib/store/calendar/writes.ts create mode 100644 stores/__tests__/calendar-store-offline-occurrence.test.ts diff --git a/lib/recurrence-expansion.ts b/lib/recurrence-expansion.ts index 90897c253..f8e0f4a33 100644 --- a/lib/recurrence-expansion.ts +++ b/lib/recurrence-expansion.ts @@ -210,7 +210,7 @@ function validDate(value: string | null | undefined): Date | null { } /** The wall-clock fields of a parsed LocalDateTime, read as UTC. */ -function wallAsUtc(wall: Date): number { +export function wallClockFieldsAsUtc(wall: Date): number { return Date.UTC( wall.getFullYear(), wall.getMonth(), wall.getDate(), wall.getHours(), wall.getMinutes(), wall.getSeconds(), @@ -236,7 +236,7 @@ function plausibleInstants( ): { start: Date; end: Date | null } | null { const wall = validDate(event.start); if (!wall) return null; - const wallStart = wallAsUtc(wall); + const wallStart = wallClockFieldsAsUtc(wall); const start = instantNear(event.utcStart, wallStart); if (!start) return null; const end = instantNear(event.utcEnd, wallStart + (parseDurationSeconds(event.duration) ?? 0) * 1000); @@ -288,10 +288,10 @@ function wallClockAsUtcTimestamp(formatter: Intl.DateTimeFormat, instant: number * and return the corresponding UTC instant. Two fixup iterations converge * for all real offsets, including across DST transitions. */ -function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null { +export function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null { const formatter = getTzFormatter(timeZone); if (!formatter) return null; - const wallMs = wallAsUtc(wall); + const wallMs = wallClockFieldsAsUtc(wall); let guess = wallMs; for (let i = 0; i < 2; i++) { guess = wallMs - (wallClockAsUtcTimestamp(formatter, guess) - guess); diff --git a/lib/recurrence-instances.ts b/lib/recurrence-instances.ts index 715895d11..562e7b9e7 100644 --- a/lib/recurrence-instances.ts +++ b/lib/recurrence-instances.ts @@ -341,7 +341,7 @@ export function applyOverridePatch>(event: T, p * replaces whatever the entry patches beneath it, and one beneath a value the * entry sets goes into that value. */ -function mergeOverrideEntry(entry: Record, updates: Record): Record { +export function mergeOverrideEntry(entry: Record, updates: Record): Record { const merged = { ...entry }; for (const [key, value] of Object.entries(updates)) { for (const existing of Object.keys(merged)) { diff --git a/lib/store/__tests__/calendar-client-modes.test.ts b/lib/store/__tests__/calendar-client-modes.test.ts new file mode 100644 index 000000000..35cb3ef7a --- /dev/null +++ b/lib/store/__tests__/calendar-client-modes.test.ts @@ -0,0 +1,212 @@ +// @vitest-environment node +import { afterEach, describe, expect, it } from 'vitest'; +import type { JMAPClient } from '@/lib/jmap/client'; +import type { CalendarEvent } from '@/lib/jmap/types'; +import { expandRecurringEvents } from '@/lib/recurrence-expansion'; +import { isBrowserExpandedOccurrence, isServerRecurrenceInstance } from '@/lib/recurrence-instances'; +import { calendarArea } from '../calendar'; +import { FakeCalendarJmap, type StalwartVersion } from './helpers/fake-calendar'; +import { wire, type Wire } from './helpers/client-wire'; + +const NOW = Date.UTC(2026, 10, 3, 12); +const RANGE = { after: '2026-11-02T00:00:00', before: '2026-11-23T00:00:00' }; + +let current: Wire | null = null; + +afterEach(() => { + current?.restore(); + current = null; +}); + +function seeded(version: StalwartVersion): Wire { + const server = new FakeCalendarJmap(version); + server.addEvent({ + id: 'series', + title: 'Weekly', + start: '2026-01-05T10:00:00', + recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly' }, + recurrenceOverrides: { + '2026-11-09T10:00:00': { title: 'Special' }, + '2026-11-16T10:00:00': { excluded: true }, + }, + }); + server.addEvent({ id: 'single', title: 'One off', start: '2026-11-10T09:00:00' }); + current = wire(server, [calendarArea], NOW); + return current; +} + +/** What the calendar grid shows for a range: the client's query, then the store's expansion. */ +async function grid(client: JMAPClient, range = RANGE): Promise { + const raw = await client.queryAllCalendarEvents(range); + return expandRecurringEvents(raw, range.after, range.before); +} + +function summary(events: CalendarEvent[]): Array<[string, string]> { + return events.map((event): [string, string] => [event.start, event.title]).sort(); +} + +const SHOWN = [ + ['2026-11-02T10:00:00', 'Weekly'], + ['2026-11-09T10:00:00', 'Special'], + ['2026-11-10T09:00:00', 'One off'], +]; + +const expandedAsked = (w: Wire) => w.sent.some(([name, args]) => name === 'CalendarEvent/query' && args.expandRecurrences === true); + +describe('a client that probed online and keeps its mode', () => { + for (const version of ['1.0', '0.16'] as const) { + it(`synthetic-id mode on ${version}: the expanded query gets a correct grid`, async () => { + const w = seeded(version); + await w.engine.requestSync(); + const client = await w.newClient(); + await client.getCalendars(); + expect(await client.supportsSyntheticCalendarIds()).toBe(true); + const online = await grid(client); + expect(online.some(isServerRecurrenceInstance)).toBe(true); + expect(summary(online)).toEqual(SHOWN); + + w.online = false; + w.sent.length = 0; + const offline = await grid(client); + expect(expandedAsked(w)).toBe(true); + expect(summary(offline)).toEqual(SHOWN); + // Occurrences the calendar store can write back as overrides on the master. + const occurrences = offline.filter((event) => event.recurrenceId); + expect(occurrences.length).toBe(2); + for (const occurrence of occurrences) { + expect(isBrowserExpandedOccurrence(occurrence)).toBe(true); + expect(occurrence.originalId).toBe('series'); + } + // Back online, the same client still asks the server to expand. + w.online = true; + expect((await grid(client)).some(isServerRecurrenceInstance)).toBe(true); + }); + } + + it('client-expansion mode on an old server: never asks to expand, same grid', async () => { + const w = seeded('0.16.19'); + await w.engine.requestSync(); + const client = await w.newClient(); + await client.getCalendars(); + expect(await client.supportsSyntheticCalendarIds()).toBe(false); + w.online = false; + w.sent.length = 0; + expect(summary(await grid(client))).toEqual(SHOWN); + expect(expandedAsked(w)).toBe(false); + // The second get with the overrides went out, as the online path sends it. + const gets = w.sent.filter(([name]) => name === 'CalendarEvent/get'); + expect(gets.some(([, args]) => (args.properties as string[]).join() === 'id,recurrenceOverrides')).toBe(true); + }); + + it('gives the grid across a range longer than one expanded query may span', async () => { + const w = seeded('1.0'); + await w.engine.requestSync(); + const client = await w.newClient(); + await client.getCalendars(); + w.online = false; + w.sent.length = 0; + const range = { after: '2026-11-02T00:00:00', before: '2028-11-02T00:00:00' }; + const events = await grid(client, range); + const queries = w.sent.filter(([name]) => name === 'CalendarEvent/query'); + expect(queries.length).toBeGreaterThan(1); + expect(new Set(events.map((event) => event.id)).size).toBe(events.length); + expect(events.filter((event) => event.title === 'Weekly').length).toBeGreaterThan(90); + expect(events.filter((event) => event.title === 'Special')).toHaveLength(1); + }); +}); + +describe('a client started offline', () => { + it('takes the mode the server had at the last sync, so it behaves the same once the server is back', async () => { + const w = seeded('1.0'); + await w.engine.requestSync(); + w.online = false; + const client = await w.newClient(); + await client.getCalendars(); + expect(await client.supportsSyntheticCalendarIds()).toBe(true); + expect(summary(await grid(client))).toEqual(SHOWN); + w.online = true; + expect((await grid(client)).some(isServerRecurrenceInstance)).toBe(true); + }); + + it('does not ask to expand on a server that had no synthetic ids', async () => { + const w = seeded('0.16.19'); + await w.engine.requestSync(); + w.online = false; + w.sent.length = 0; + const client = await w.newClient(); + await client.getCalendars(); + expect(await client.supportsSyntheticCalendarIds()).toBe(false); + expect(summary(await grid(client))).toEqual(SHOWN); + expect(expandedAsked(w)).toBe(false); + }); + + it('uses client-side expansion before any sync, and a failed read does not look like an empty calendar', async () => { + const w = seeded('1.0'); + w.online = false; + const client = await w.newClient(); + expect(await client.supportsSyntheticCalendarIds()).toBe(false); + await expect(client.queryAllCalendarEvents(RANGE)).rejects.toThrow(); + }); +}); + +describe('reads and writes through the real client', () => { + it('reads back a created event under its stand-in id, then edits and deletes it', async () => { + const w = seeded('1.0'); + await w.engine.requestSync(); + const client = await w.newClient(); + await client.getCalendars(); + w.online = false; + + const created = await client.createCalendarEvent({ + title: 'Offline', + start: '2026-11-11T08:00:00', + duration: 'PT1H', + timeZone: 'Europe/Berlin', + calendarIds: { cal1: true }, + } as Partial); + expect(created.id).toMatch(/^local-/); + expect(created.title).toBe('Offline'); + expect(created.utcStart).toBe('2026-11-11T07:00:00Z'); + expect(summary(await grid(client))).toContainEqual(['2026-11-11T08:00:00', 'Offline']); + + await client.updateCalendarEvent(created.id, { title: 'Renamed' }); + expect(summary(await grid(client))).toContainEqual(['2026-11-11T08:00:00', 'Renamed']); + + await client.deleteCalendarEvent(created.id); + expect(summary(await grid(client))).toEqual(SHOWN); + expect(await client.getCalendarEvent(created.id)).toBeNull(); + expect(w.server.events.size).toBe(2); + }); + + it('fetches overrides with its one-event read', async () => { + const w = seeded('1.0'); + await w.engine.requestSync(); + const client = await w.newClient(); + w.online = false; + const event = await client.getCalendarEvent('series'); + expect(event?.recurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'weekly' }]); + expect(Object.keys(event?.recurrenceOverrides ?? {})).toHaveLength(2); + expect(event?.utcStart).toBe('2026-01-05T09:00:00Z'); + }); + + it('lists tasks through the shape fetchTaskObjects reads', async () => { + const w = seeded('1.0'); + w.server.addEvent({ id: 'task', '@type': 'Task', title: 'To do', start: undefined, due: '2026-11-12T10:00:00', progress: 'needs-action' }); + await w.engine.requestSync(); + const client = await w.newClient(); + w.online = false; + const tasks = await client.getCalendarTasks(); + expect(tasks.map((task) => [task.id, task.title, task.due])).toEqual([['task', 'To do', '2026-11-12T10:00:00']]); + expect(summary(await grid(client)).map(([, title]) => title)).not.toContain('To do'); + }); + + it('marks tasks-only calendars and reads identities from the last sync', async () => { + const w = seeded('1.0'); + await w.engine.requestSync(); + const client = await w.newClient(); + w.online = false; + const calendars = await client.getCalendars(); + expect(calendars.map((calendar) => [calendar.id, calendar.isTasksOnly])).toEqual([['cal1', false]]); + expect((await client.getParticipantIdentities())[0].calendarAddress).toBe('mailto:a@example.org'); + }); +}); diff --git a/lib/store/__tests__/calendar-no-dom.test.ts b/lib/store/__tests__/calendar-no-dom.test.ts new file mode 100644 index 000000000..9ee189b68 --- /dev/null +++ b/lib/store/__tests__/calendar-no-dom.test.ts @@ -0,0 +1,32 @@ +// @vitest-environment node +import path from 'node:path'; +import { build } from 'esbuild'; +import { describe, expect, it } from 'vitest'; + +const root = path.resolve(__dirname, '../../..'); + +async function inputsOf(entry: string): Promise { + const result = await build({ + entryPoints: [path.join(root, entry)], + bundle: true, + write: false, + format: 'esm', + platform: 'browser', + alias: { '@': root }, + metafile: true, + logLevel: 'silent', + // Stay away from the packages' own graph: only our files matter here. + packages: 'external', + }); + return Object.keys(result.metafile.inputs); +} + +describe('what the worker pulls in', () => { + for (const entry of ['lib/store/calendar/index.ts', 'lib/store/calendar/reminders.ts']) { + it(`${entry} reaches no UI code`, async () => { + const inputs = await inputsOf(entry); + expect(inputs.length).toBeGreaterThan(3); + expect(inputs.filter((file) => /(^|\/)(components|stores|hooks|app)\//.test(file))).toEqual([]); + }); + } +}); diff --git a/lib/store/__tests__/calendar-sync.test.ts b/lib/store/__tests__/calendar-sync.test.ts new file mode 100644 index 000000000..45ba37796 --- /dev/null +++ b/lib/store/__tests__/calendar-sync.test.ts @@ -0,0 +1,955 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { DEFAULT_SCOPE, type StoreEvent } from '../area'; +import { calendarArea } from '../calendar'; +import { listUpcomingAlerts } from '../calendar/reminders'; +import { AccountEngine } from '../engine/account-engine'; +import { TransportError, type MethodCall } from '../engine/transport'; +import type { CalendarConflict } from '../calendar/writes'; +import { CAL, CALENDAR_SESSION, FakeCalendarJmap, type StalwartVersion } from './helpers/fake-calendar'; +import { JMAP_ACCOUNT } from './helpers/fake-jmap'; +import { openMemoryStore } from './helpers/node-adapter'; + +const NOW = Date.UTC(2026, 10, 3, 12); +const WEEKLY = { '@type': 'RecurrenceRule', frequency: 'weekly' }; + +function setup(version: StalwartVersion = '1.0', server = new FakeCalendarJmap(version)) { + const db = openMemoryStore(); + const events: StoreEvent[] = []; + const engine = new AccountEngine({ + account: 'a@fake.test', + db, + session: CALENDAR_SESSION, + transport: server, + areas: [calendarArea], + scope: DEFAULT_SCOPE, + emit: (event) => events.push(event), + now: () => NOW, + sleep: async () => {}, + }); + return { server, db, engine, events }; +} + +type Setup = ReturnType; + +async function call(s: Setup, name: string, args: Record) { + // A companion call: the engine answers null when nothing at all could be answered. + const response = await s.engine.answer([ + [name, { accountId: JMAP_ACCOUNT, ...args }, 'x'], + ['CalendarEventNotification/get', { accountId: JMAP_ACCOUNT, ids: [] }, 'y'], + ]); + return response!.methodResponses[0]; +} + +async function queryIds(s: Setup, args: Record): Promise { + const [name, result] = await call(s, 'CalendarEvent/query', args); + expect(name, JSON.stringify(result)).toBe('CalendarEvent/query'); + return result.ids; +} + +async function getOne(s: Setup, id: string, properties?: string[], timeZone?: string): Promise | undefined> { + const [, result] = await call(s, 'CalendarEvent/get', { ids: [id], ...(properties ? { properties } : {}), ...(timeZone ? { timeZone } : {}) }); + return result.list[0]; +} + +async function setEvent(s: Setup, args: Record) { + const [name, result] = await call(s, 'CalendarEvent/set', args); + expect(name).toBe('CalendarEvent/set'); + return result; +} + +const flush = (s: Setup) => s.engine.areaCall('calendar.flush', undefined) as Promise<{ sent: number; conflicts: number; pending: number }>; +const conflicts = (s: Setup) => s.engine.areaCall('calendar.conflicts', undefined) as Promise; +const pendingRows = (s: Setup) => s.db.all<{ kind: string; patch: string; record_id: string }>('SELECT kind, patch, record_id FROM calendar_pending'); +const setCalls = (server: FakeCalendarJmap): MethodCall[] => server.calls.filter(([name]) => name === 'CalendarEvent/set') + .filter(([, args]) => !(args.update && 'h333333' in (args.update as object))); + +describe('calendar sync', () => { + it('keeps a recurring event as its master across sync, with its overrides', async () => { + const s = setup(); + s.server.addEvent({ + id: 'series', + recurrenceRule: WEEKLY, + start: '2026-01-05T10:00:00', + recurrenceOverrides: { '2026-11-09T10:00:00': { title: 'Moved' }, '2026-11-16T10:00:00': { excluded: true } }, + }); + await s.engine.requestSync(); + expect(s.db.all('SELECT id FROM calendar_event').map((row) => row.id)).toEqual(['series']); + const stored = await getOne(s, 'series'); + expect(stored?.recurrenceRule).toEqual(WEEKLY); + expect(stored?.recurrenceOverrides).toEqual({ '2026-11-09T10:00:00': { title: 'Moved' }, '2026-11-16T10:00:00': { excluded: true } }); + }); + + it('asks for overrides without the utc properties, which 1.0 refuses next to them', async () => { + const s = setup('1.0'); + s.server.addEvent({ id: 'series', recurrenceRule: WEEKLY, recurrenceOverrides: { '2026-11-17T10:00:00': { title: 'x' } } }); + await s.engine.requestSync(); + const events = s.server.calls.filter(([name]) => name === 'CalendarEvent/get'); + const withOverrides = events.filter(([, args]) => (args.properties as string[]).includes('recurrenceOverrides')); + expect(withOverrides.length).toBeGreaterThan(0); + for (const [, args] of withOverrides) expect(args.properties).not.toContain('utcStart'); + expect(await getOne(s, 'series')).toMatchObject({ recurrenceOverrides: { '2026-11-17T10:00:00': { title: 'x' } } }); + }); + + it('never runs an expanded query or stores a synthetic id', async () => { + const s = setup('0.16'); + s.server.addEvent({ id: 'series', recurrenceRule: WEEKLY }); + s.server.addEvent({ id: 'single' }); + await s.engine.requestSync(); + expect(s.server.calls.filter(([, args]) => args.expandRecurrences)).toEqual([]); + expect(s.db.all('SELECT id FROM calendar_event ORDER BY id').map((row) => row.id)).toEqual(['series', 'single']); + }); + + it('pages past 500 objects', async () => { + const s = setup(); + for (let i = 0; i < 1234; i += 1) s.server.addEvent(); + await s.engine.requestSync(); + expect(Number(s.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM calendar_event')?.n)).toBe(1234); + expect(s.server.calls.filter(([name]) => name === 'CalendarEvent/query').length).toBe(3); + }); + + it('takes an edit of one occurrence from /changes as an update of the master', async () => { + const s = setup('1.0'); + s.server.addEvent({ id: 'series', recurrenceRule: WEEKLY, start: '2026-11-02T10:00:00' }); + await s.engine.requestSync(); + // Someone edits one occurrence through its synthetic id. + const [, expanded] = (await s.server.request([['CalendarEvent/query', { + accountId: JMAP_ACCOUNT, filter: { after: '2026-11-08T00:00:00', before: '2026-11-10T00:00:00' }, expandRecurrences: true, + }, 'q']])).methodResponses[0]; + expect(expanded.canCalculateChanges).toBe(false); + await s.server.request([['CalendarEvent/set', { accountId: JMAP_ACCOUNT, update: { [expanded.ids[0]]: { title: 'One off' } } }, 'u']]); + await s.engine.requestSync(); + expect(s.db.all('SELECT id FROM calendar_event').map((row) => row.id)).toEqual(['series']); + expect(await getOne(s, 'series')).toMatchObject({ + recurrenceOverrides: { '2026-11-09T10:00:00': { title: 'One off' } }, + }); + }); + + it('keeps old single events out and old series and tasks in, whatever their start', async () => { + const s = setup(); + s.server.addEvent({ id: 'ancient', start: '2020-01-01T10:00:00' }); + s.server.addEvent({ id: 'recent', start: '2025-06-01T10:00:00' }); + s.server.addEvent({ id: 'future', start: '2040-01-01T10:00:00' }); + s.server.addEvent({ id: 'old-series', start: '2015-01-01T10:00:00', recurrenceRule: WEEKLY }); + s.server.addEvent({ id: 'old-task', '@type': 'Task', start: undefined, due: '2015-01-01T10:00:00' }); + await s.engine.requestSync(); + expect(s.db.all('SELECT id FROM calendar_event ORDER BY id').map((row) => row.id)) + .toEqual(['future', 'old-series', 'old-task', 'recent']); + }); + + it('starts over when the log is gone, and drops what the server no longer has', async () => { + const s = setup(); + s.server.addEvent({ id: 'stays' }); + s.server.addEvent({ id: 'goes' }); + await s.engine.requestSync(); + s.server.events.delete('goes'); + s.server.eventLog.push('destroyed', 'goes'); + s.server.eventLog.truncatedBelow = s.server.eventLog.log.length; + await s.engine.requestSync(); + expect(s.db.all('SELECT id FROM calendar_event').map((row) => row.id)).toEqual(['stays']); + }); + + it('keeps ids of two JMAP accounts apart', async () => { + const s = setup(); + s.db.run( + "INSERT INTO calendar_event (jmap, id, json) VALUES ('other', 'same', ?)", + [JSON.stringify({ id: 'same', title: 'Theirs', start: '2026-11-10T10:00:00' })], + ); + s.server.addEvent({ id: 'same', title: 'Mine' }); + await s.engine.requestSync(); + expect((await getOne(s, 'same'))?.title).toBe('Mine'); + const row = s.db.get<{ json: string }>("SELECT json FROM calendar_event WHERE jmap = 'other' AND id = 'same'"); + expect(JSON.parse(row!.json).title).toBe('Theirs'); + }); + + it('stores calendars and participant identities and answers them with the real state', async () => { + const s = setup(); + await s.engine.requestSync(); + const [, calendars] = await call(s, 'Calendar/get', { properties: ['id', 'name'] }); + expect(calendars.list).toEqual([{ id: 'cal1', name: 'Personal' }]); + expect(calendars.state).toBe('cs1'); + const [, identities] = await call(s, 'ParticipantIdentity/get', {}); + expect(identities.list[0].calendarAddress).toBe('mailto:a@example.org'); + }); + + it('refuses to read before anything was kept, instead of saying there is nothing', async () => { + const s = setup(); + const [name, result] = await call(s, 'CalendarEvent/query', {}); + expect(name).toBe('error'); + expect(result.type).toBe('serverUnavailable'); + expect((await call(s, 'Calendar/get', {}))[0]).toBe('error'); + }); +}); + +describe('queries from the store', () => { + async function seeded() { + const s = setup(); + s.server.addEvent({ id: 'late', title: 'Late meeting', start: '2026-11-10T23:30:00', duration: 'PT1H', timeZone: 'Europe/Berlin' }); + s.server.addEvent({ + id: 'lunch', + title: 'Lünch with Bob', + description: 'Bring the slides', + start: '2026-11-11T12:00:00', + calendarIds: { cal2: true }, + locations: { l: { '@type': 'Location', name: 'Room Seven' } }, + organizerCalendarAddress: 'mailto:owner@example.org', + participants: { + p1: { '@type': 'Participant', name: 'Bob', calendarAddress: 'mailto:bob@example.org', roles: { attendee: true }, participationStatus: 'accepted' }, + p2: { '@type': 'Participant', name: 'Olga', calendarAddress: 'mailto:olga@example.org', roles: { owner: true }, participationStatus: 'declined' }, + }, + }); + s.server.addEvent({ id: 'series', title: 'Weekly', start: '2026-01-05T10:00:00', recurrenceRule: WEEKLY }); + s.server.addEvent({ id: 'ended', title: 'Ended', start: '2026-01-05T10:00:00', recurrenceRule: { ...WEEKLY, count: 2 } }); + s.server.addEvent({ id: 'task', '@type': 'Task', title: 'Task', start: undefined, due: '2026-11-12T10:00:00' }); + await s.engine.requestSync(); + return s; + } + + const ids = async (s: Setup, filter: unknown, extra: Record = {}) => (await queryIds(s, { filter, ...extra })).sort(); + + it('answers each filter the client can send', async () => { + const s = await seeded(); + expect(await ids(s, { inCalendar: 'cal2' })).toEqual(['lunch']); + expect(await ids(s, { inCalendars: ['cal2', 'nope'] })).toEqual(['lunch']); + expect(await ids(s, { title: 'lunch' })).toEqual(['lunch']); + expect(await ids(s, { description: 'slides' })).toEqual(['lunch']); + expect(await ids(s, { location: 'seven' })).toEqual(['lunch']); + expect(await ids(s, { owner: 'olga' })).toEqual(['lunch']); + expect(await ids(s, { owner: 'bob' })).toEqual([]); + expect(await ids(s, { attendee: 'bob' })).toEqual(['lunch']); + expect(await ids(s, { attendee: 'olga' })).toEqual([]); + expect(await ids(s, { participationStatus: 'declined' })).toEqual(['lunch']); + expect(await ids(s, { attendee: 'bob', participationStatus: 'declined' })).toEqual([]); + expect(await ids(s, { uid: 'lunch@example.org' })).toEqual(['lunch']); + expect(await ids(s, { uid: 'LUNCH@example.org' })).toEqual([]); + expect(await ids(s, { types: ['Task'] })).toEqual(['task']); + expect(await ids(s, { text: 'BOB' })).toEqual(['lunch']); + expect(await ids(s, { text: 'lunch bob' })).toEqual(['lunch']); + expect(await ids(s, { text: 'lunch nothing' })).toEqual([]); + }); + + it('answers operators', async () => { + const s = await seeded(); + expect(await ids(s, { operator: 'OR', conditions: [{ uid: 'late@example.org' }, { uid: 'task@example.org' }] })).toEqual(['late', 'task']); + expect(await ids(s, { operator: 'AND', conditions: [{ text: 'bob' }, { inCalendar: 'cal2' }] })).toEqual(['lunch']); + expect(await ids(s, { operator: 'NOT', conditions: [{ title: 'weekly' }, { title: 'ended' }, { title: 'late' }, { types: ['Task'] }] })).toEqual(['lunch']); + }); + + it('gives a per-call error for a filter it does not know', async () => { + const s = await seeded(); + const [name, result] = await call(s, 'CalendarEvent/query', { filter: { nonsense: 1 } }); + expect(name).toBe('error'); + expect(result.type).toBe('unsupportedFilter'); + }); + + it('reads bounds as wall clock in the request zone', async () => { + const s = await seeded(); + // 23:30 in Berlin on the 10th is 17:30 in New York, and 22:30 UTC. + const day10 = { after: '2026-11-10T00:00:00', before: '2026-11-11T00:00:00' }; + expect(await ids(s, day10, { timeZone: 'Europe/Berlin' })).toContain('late'); + const night = { after: '2026-11-10T22:45:00', before: '2026-11-10T23:00:00' }; + expect(await ids(s, night, { timeZone: 'Europe/Berlin' })).not.toContain('late'); + expect(await ids(s, night)).toContain('late'); + // A Z says nothing: Stalwart drops it and reads the wall clock. + expect(await ids(s, { after: '2026-11-10T22:45:00Z', before: '2026-11-10T23:00:00Z' }, { timeZone: 'Europe/Berlin' })).not.toContain('late'); + expect(await ids(s, { after: '2026-11-10T17:00:00', before: '2026-11-10T18:00:00' }, { timeZone: 'America/New_York' })).toContain('late'); + }); + + it('matches a series by any occurrence, once, and not when it has none in range', async () => { + const s = await seeded(); + const later = { after: '2026-11-16T00:00:00', before: '2026-11-17T00:00:00' }; + expect(await ids(s, later)).toEqual(['series']); + const bare = { operator: 'AND', conditions: [{ after: later.after }, { before: later.before }] }; + expect(await ids(s, bare)).toEqual(['series']); + // `ended` has two occurrences in January and none after. + expect(await ids(s, { after: '2026-01-05T00:00:00', before: '2026-01-06T00:00:00' })).toEqual(['ended', 'series']); + expect(await ids(s, { after: '2026-02-02T00:00:00', before: '2026-02-03T00:00:00' })).toEqual(['series']); + }); + + it('sorts, pages and counts', async () => { + const s = await seeded(); + const sort = [{ property: 'start', isAscending: false }]; + const [, first] = await call(s, 'CalendarEvent/query', { filter: { types: ['Event'] }, sort, limit: 2, calculateTotal: true }); + expect(first.ids).toEqual(['lunch', 'late']); + expect(first.total).toBe(4); + const [, second] = await call(s, 'CalendarEvent/query', { filter: { types: ['Event'] }, sort, limit: 2, position: 2 }); + expect(second.ids.sort()).toEqual(['ended', 'series']); + expect(first.canCalculateChanges).toBe(false); + }); + + it('returns utcStart for the request zone, and reads a floating event in it', async () => { + const s = setup(); + s.server.addEvent({ id: 'floating', start: '2026-11-10T10:00:00', timeZone: null, duration: 'PT1H' }); + s.server.addEvent({ id: 'zoned', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', duration: 'PT1H' }); + await s.engine.requestSync(); + expect(await getOne(s, 'floating', ['utcStart', 'utcEnd'], 'America/New_York')).toEqual({ + id: 'floating', utcStart: '2026-11-10T15:00:00Z', utcEnd: '2026-11-10T16:00:00Z', + }); + expect((await getOne(s, 'zoned', ['utcStart'], 'America/New_York'))?.utcStart).toBe('2026-11-10T09:00:00Z'); + }); + + it('answers the task list like the client reads it', async () => { + const s = await seeded(); + const list = await ids(s, undefined, { limit: 1000 }); + expect(list).toContain('task'); + const task = await getOne(s, 'task', ['id', '@type', 'due', 'progress', 'title']); + expect(task).toMatchObject({ '@type': 'Task', due: '2026-11-12T10:00:00', title: 'Task' }); + }); + + it('answers notifications with nothing rather than failing', async () => { + const s = await seeded(); + const response = await s.engine.answer([ + ['CalendarEventNotification/query', { accountId: JMAP_ACCOUNT, sort: [] }, '0'], + ['CalendarEventNotification/get', { accountId: JMAP_ACCOUNT, '#ids': { resultOf: '0', name: 'CalendarEventNotification/query', path: '/ids' } }, '1'], + ]); + expect(response!.methodResponses[1][1].list).toEqual([]); + }); + + it('refuses an expanded query without both bounds, as both servers do', async () => { + const s = await seeded(); + const [name, result] = await call(s, 'CalendarEvent/query', { filter: { after: '2026-11-16T00:00:00' }, expandRecurrences: true }); + expect(name).toBe('error'); + expect(result.type).toBe('invalidArguments'); + }); +}); + +describe('writes while offline', () => { + async function synced(version: StalwartVersion = '1.0') { + const s = setup(version); + s.server.addEvent({ + id: 'e1', + title: 'Meeting', + participants: { + me: { '@type': 'Participant', calendarAddress: 'mailto:a@example.org', participationStatus: 'needs-action', roles: { attendee: true } }, + bob: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.org', participationStatus: 'accepted', roles: { attendee: true } }, + }, + }); + await s.engine.requestSync(); + s.server.calls.length = 0; + return s; + } + + it('merges edits of one event into one patch, and shows them at once', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'First' } } }); + await setEvent(s, { update: { e1: { description: 'Added' } } }); + await setEvent(s, { update: { e1: { title: 'Second' } } }); + expect(pendingRows(s)).toHaveLength(1); + expect(await getOne(s, 'e1')).toMatchObject({ title: 'Second', description: 'Added' }); + + await flush(s); + const sent = setCalls(s.server); + expect(sent).toHaveLength(1); + expect((sent[0][1].update as Record).e1).toEqual({ title: 'Second', description: 'Added' }); + expect(s.server.events.get('e1')).toMatchObject({ title: 'Second', description: 'Added' }); + expect(pendingRows(s)).toEqual([]); + }); + + it('merges a pointer into a value set whole, and a whole value over pointers beneath it', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { locations: { a: { name: 'One' } } } } }); + await setEvent(s, { update: { e1: { 'locations/b': { name: 'Two' } } } }); + expect(JSON.parse(pendingRows(s)[0].patch)).toEqual({ locations: { a: { name: 'One' }, b: { name: 'Two' } } }); + + const other = await synced(); + await setEvent(other, { update: { e1: { 'participants/me/participationStatus': 'accepted' } } }); + await setEvent(other, { update: { e1: { participants: { only: { name: 'Solo' } } } } }); + expect(JSON.parse(pendingRows(other)[0].patch)).toEqual({ participants: { only: { name: 'Solo' } } }); + }); + + it('queues an RSVP as a participant patch and sends it with scheduling on', async () => { + const s = await synced(); + const answer = await setEvent(s, { + update: { e1: { 'participants/me/participationStatus': 'accepted' } }, + sendSchedulingMessages: true, + }); + expect(answer.updated).toEqual({ e1: null }); + expect(s.server.events.get('e1')?.participants).toMatchObject({ me: { participationStatus: 'needs-action' } }); + const shown = await getOne(s, 'e1') as { participants: Record }; + expect(shown.participants.me.participationStatus).toBe('accepted'); + expect(shown.participants.bob.participationStatus).toBe('accepted'); + + await flush(s); + const [, args] = setCalls(s.server)[0]; + expect(args.sendSchedulingMessages).toBe(true); + expect(args.update).toEqual({ e1: { 'participants/me/participationStatus': 'accepted' } }); + expect(s.server.events.get('e1')?.participants).toMatchObject({ me: { participationStatus: 'accepted' }, bob: { participationStatus: 'accepted' } }); + }); + + it('keeps scheduling on when a later edit does not ask for it', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { 'participants/me/participationStatus': 'accepted' } }, sendSchedulingMessages: true }); + await setEvent(s, { update: { e1: { description: 'Note' } }, sendSchedulingMessages: false }); + await flush(s); + expect(setCalls(s.server)[0][1].sendSchedulingMessages).toBe(true); + }); + + it('survives a sync while pending: the server copy moves on underneath, the edit stays on top', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Mine' } } }); + s.server.patchEvent('e1', { description: 'Theirs' }); + // The delta gets through, the write doesn't. + const realRequest = s.server.request.bind(s.server); + let refuseWrites = true; + s.server.request = async (calls: MethodCall[]) => { + if (refuseWrites && calls.some(([name, args]) => name === 'CalendarEvent/set' && args.update && 'e1' in (args.update as object))) { + throw new TransportError('network', 'Failed to fetch'); + } + return realRequest(calls); + }; + await s.engine.requestSync(); + expect(pendingRows(s)).toHaveLength(1); + expect(s.server.events.get('e1')).toMatchObject({ title: 'Meeting', description: 'Theirs' }); + expect(await getOne(s, 'e1')).toMatchObject({ title: 'Mine', description: 'Theirs' }); + + refuseWrites = false; + await s.engine.requestSync(); + expect(await conflicts(s)).toEqual([]); + expect(s.server.events.get('e1')).toMatchObject({ title: 'Mine', description: 'Theirs' }); + expect(await getOne(s, 'e1')).toMatchObject({ title: 'Mine', description: 'Theirs' }); + }); + + it('sends an edit that came in during a send, and takes its own first write for its own', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'One' } } }); + const realRequest = s.server.request.bind(s.server); + let once = true; + s.server.request = async (calls: MethodCall[]) => { + const response = await realRequest(calls); + if (once && calls.some(([name]) => name === 'CalendarEvent/set')) { + once = false; + await setEvent(s, { update: { e1: { title: 'Two', description: 'During' } } }); + } + return response; + }; + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect(s.server.events.get('e1')).toMatchObject({ title: 'Two', description: 'During' }); + expect(pendingRows(s)).toEqual([]); + }); + + it('does not take an edit whose answer got lost for a conflict when it comes back through sync', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Sent once' } } }); + s.server.loseNextSetResponse = true; + expect(await flush(s)).toMatchObject({ sent: 0, pending: 1 }); + expect(s.server.events.get('e1')?.title).toBe('Sent once'); + expect(await flush(s)).toMatchObject({ sent: 1, pending: 0 }); + expect(await conflicts(s)).toEqual([]); + }); + + it('gives out a stand-in id on create that later edits and deletes can use', async () => { + const s = await synced(); + const result = await setEvent(s, { create: { 'new-event': { title: 'Fresh', calendarIds: { cal1: true }, start: '2026-11-20T09:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' } } }); + const id = result.created['new-event'].id as string; + expect(await getOne(s, id, ['title', 'utcStart', 'uid'])).toMatchObject({ title: 'Fresh', utcStart: '2026-11-20T08:00:00Z' }); + expect(await queryIds(s, { filter: { after: '2026-11-20T00:00:00', before: '2026-11-21T00:00:00' } })).toEqual([id]); + + await setEvent(s, { update: { [id]: { title: 'Fresher', 'locations/a': { name: 'Here' } } } }); + expect(pendingRows(s)).toHaveLength(1); + expect(pendingRows(s)[0].kind).toBe('create'); + expect(await getOne(s, id)).toMatchObject({ title: 'Fresher', locations: { a: { name: 'Here' } } }); + + await flush(s); + const creates = setCalls(s.server).filter(([, args]) => args.create); + expect(creates).toHaveLength(1); + expect(s.server.events.size).toBe(2); + const onServer = [...s.server.events.values()].find((event) => event.title === 'Fresher'); + expect(onServer?.locations).toEqual({ a: { name: 'Here' } }); + expect(await getOne(s, id)).toMatchObject({ id: onServer?.id, title: 'Fresher' }); + expect(s.db.get('SELECT 1 AS x FROM calendar_event WHERE id = ?', [id])).toBeUndefined(); + }); + + it('sends nothing for a create deleted again before it left', async () => { + const s = await synced(); + const result = await setEvent(s, { create: { c: { title: 'Gone', start: '2026-11-20T09:00:00', calendarIds: { cal1: true } } } }); + const id = result.created.c.id as string; + const gone = await setEvent(s, { destroy: [id] }); + expect(gone.destroyed).toEqual([id]); + expect(await getOne(s, id)).toBeUndefined(); + await flush(s); + expect(setCalls(s.server)).toEqual([]); + expect(s.server.events.size).toBe(1); + }); + + it('sends an edit then delete as just the delete, and hides it meanwhile', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Doomed' } } }); + await setEvent(s, { destroy: ['e1'] }); + expect(pendingRows(s)[0].kind).toBe('destroy'); + expect(await getOne(s, 'e1')).toBeUndefined(); + expect(await queryIds(s, {})).toEqual([]); + await flush(s); + expect(setCalls(s.server).map(([, args]) => args.destroy)).toEqual([['e1']]); + expect(s.server.events.has('e1')).toBe(false); + expect(s.db.get('SELECT 1 AS x FROM calendar_event WHERE id = ?', ['e1'])).toBeUndefined(); + }); + + it('takes a delete of an event already gone as done', async () => { + const s = await synced(); + await setEvent(s, { destroy: ['e1'] }); + s.server.removeEvent('e1'); + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect(pendingRows(s)).toEqual([]); + }); + + it('keeps writes across a run without a network and sends them after', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Later' } } }); + s.server.offline = true; + const offline = await flush(s); + expect(offline).toMatchObject({ sent: 0, pending: 1 }); + expect(await getOne(s, 'e1')).toMatchObject({ title: 'Later' }); + s.server.offline = false; + expect(await flush(s)).toMatchObject({ sent: 1, pending: 0 }); + expect(s.server.events.get('e1')?.title).toBe('Later'); + }); + + it('flushes during a normal sync pass too', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'By sync' } } }); + await s.engine.requestSync(); + expect(s.server.events.get('e1')?.title).toBe('By sync'); + expect(pendingRows(s)).toEqual([]); + }); + + it('tells the page something changed', async () => { + const s = await synced(); + s.events.length = 0; + await setEvent(s, { update: { e1: { title: 'x' } } }); + expect(s.events).toContainEqual({ type: 'changed', account: 'a@fake.test', collections: ['CalendarEvent'] }); + }); +}); + +describe('a create and a lost response', () => { + const NEW = { title: 'Once', calendarIds: { cal1: true }, start: '2026-11-20T09:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' }; + + async function queued() { + const s = setup(); + await s.engine.requestSync(); + s.server.calls.length = 0; + const result = await setEvent(s, { create: { c: NEW } }); + return { s, id: result.created.c.id as string, uid: result.created.c.uid as string }; + } + + it('gives the event a uid of its own before it goes out', async () => { + const { s, uid } = await queued(); + expect(uid).toBeTruthy(); + await flush(s); + expect([...s.server.events.values()][0].uid).toBe(uid); + }); + + it('keeps a uid the client chose', async () => { + const s = setup(); + await s.engine.requestSync(); + const result = await setEvent(s, { create: { c: { ...NEW, uid: 'mine@example.org' } } }); + expect(result.created.c.uid).toBe('mine@example.org'); + }); + + it('finds the event by uid instead of creating it again', async () => { + const { s, id, uid } = await queued(); + s.server.loseNextSetResponse = true; + expect(await flush(s)).toMatchObject({ sent: 0, pending: 1 }); + expect(s.server.events.size).toBe(1); + + expect(await flush(s)).toMatchObject({ sent: 1, pending: 0 }); + expect(s.server.events.size).toBe(1); + expect(setCalls(s.server).filter(([, args]) => args.create)).toHaveLength(1); + expect(s.server.calls.some(([name, args]) => name === 'CalendarEvent/query' && (args.filter as { uid?: string })?.uid === uid)).toBe(true); + const real = [...s.server.events.keys()][0]; + expect(await queryIds(s, {})).toEqual([real]); + expect(await getOne(s, id)).toMatchObject({ id: real }); + expect(await conflicts(s)).toEqual([]); + }); + + it('shows one event when a sync finds the created event before the flush does', async () => { + const { s } = await queued(); + s.server.loseNextSetResponse = true; + await flush(s); + await s.engine.requestSync(); + expect((await queryIds(s, {}))).toHaveLength(1); + expect(s.server.events.size).toBe(1); + }); + + it('creates it after all when the request never reached the server', async () => { + const { s } = await queued(); + // The attempt is marked, the network drops before it arrives. + const realRequest = s.server.request.bind(s.server); + let dropped = false; + s.server.request = async (calls: MethodCall[]) => { + if (!dropped && calls.some(([name, args]) => name === 'CalendarEvent/set' && args.create)) { + dropped = true; + throw new TransportError('network', 'Failed to fetch'); + } + return realRequest(calls); + }; + await flush(s); + expect(s.server.events.size).toBe(0); + expect(await flush(s)).toMatchObject({ sent: 1, pending: 0 }); + expect(s.server.events.size).toBe(1); + }); + + it('treats the uid refusal as "it exists already", should the mark have been lost', async () => { + const { s, uid } = await queued(); + s.db.run('UPDATE calendar_pending SET attempted = 0'); + s.server.addEvent({ ...NEW, uid, id: 'already' }); + await flush(s); + expect(s.server.events.size).toBe(1); + expect(await conflicts(s)).toEqual([]); + expect(await queryIds(s, {})).toEqual(['already']); + }); + + it('follows a create with its delete when it went out before the delete came', async () => { + const { s, id } = await queued(); + s.server.loseNextSetResponse = true; + await flush(s); + expect(s.server.events.size).toBe(1); + await setEvent(s, { destroy: [id] }); + expect(await queryIds(s, {})).toEqual([]); + await flush(s); + expect(s.server.events.size).toBe(0); + expect(pendingRows(s)).toEqual([]); + }); + + it('applies a write that came in during the send as a write of its own', async () => { + const { s, id } = await queued(); + const realRequest = s.server.request.bind(s.server); + s.server.request = async (calls: MethodCall[]) => { + const response = await realRequest(calls); + if (calls.some(([name, args]) => name === 'CalendarEvent/set' && args.create)) { + await setEvent(s, { update: { [id]: { title: 'Changed meanwhile' } } }); + } + return response; + }; + await flush(s); + expect([...s.server.events.values()][0].title).toBe('Changed meanwhile'); + expect(pendingRows(s)).toEqual([]); + }); +}); + +describe('conflicts', () => { + async function synced(version: StalwartVersion = '1.0') { + const s = setup(version); + s.server.addEvent({ id: 'e1', title: 'Meeting', recurrenceRule: WEEKLY }); + await s.engine.requestSync(); + s.server.calls.length = 0; + return s; + } + + it('lists a patch on a value the server changed too, and does not send it', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Mine' } } }); + s.server.patchEvent('e1', { title: 'Theirs' }); + const result = await flush(s); + expect(setCalls(s.server)).toEqual([]); + expect(result).toMatchObject({ sent: 0, pending: 0 }); + const [conflict] = await conflicts(s); + expect(conflict).toMatchObject({ recordId: 'e1', kind: 'update', reason: 'changedOnServer', local: { title: 'Mine' } }); + expect(conflict.server).toMatchObject({ title: 'Theirs' }); + expect(s.server.events.get('e1')?.title).toBe('Theirs'); + expect(await getOne(s, 'e1')).toMatchObject({ title: 'Theirs' }); + }); + + it('lets an edit through when the server changed something else', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Mine' } } }); + s.server.patchEvent('e1', { description: 'Theirs' }); + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect(s.server.events.get('e1')).toMatchObject({ title: 'Mine', description: 'Theirs' }); + }); + + it('does not take the same value on both sides for a conflict', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Same' } } }); + s.server.patchEvent('e1', { title: 'Same' }); + await flush(s); + expect(await conflicts(s)).toEqual([]); + }); + + it('lets an RSVP through when the organizer changed the title', async () => { + const s = setup(); + s.server.addEvent({ id: 'e1', participants: { me: { participationStatus: 'needs-action' } } }); + await s.engine.requestSync(); + await setEvent(s, { update: { e1: { 'participants/me/participationStatus': 'accepted' } } }); + s.server.patchEvent('e1', { title: 'New title' }); + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect(s.server.events.get('e1')).toMatchObject({ title: 'New title', participants: { me: { participationStatus: 'accepted' } } }); + }); + + it('lists a patch the server refuses, with its reason, and goes on with the others', async () => { + const s = await synced(); + s.server.addEvent({ id: 'e2', title: 'Other' }); + await s.engine.requestSync(); + await setEvent(s, { update: { e1: { title: 'Refused' } } }); + await setEvent(s, { update: { e2: { title: 'Fine' } } }); + s.server.refuse.e1 = { type: 'forbidden', description: 'No rights' }; + const result = await flush(s); + expect(result).toMatchObject({ sent: 1, conflicts: 1, pending: 0 }); + const [conflict] = await conflicts(s); + expect(conflict).toMatchObject({ recordId: 'e1', reason: 'refused', errorType: 'forbidden', description: 'No rights', local: { title: 'Refused' } }); + expect(await getOne(s, 'e1')).toMatchObject({ title: 'Meeting' }); + expect(s.server.events.get('e2')?.title).toBe('Fine'); + }); + + it('lists an edit of an event the server deleted, and drops the event', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Too late' } } }); + s.server.removeEvent('e1'); + await flush(s); + const [conflict] = await conflicts(s); + expect(conflict).toMatchObject({ reason: 'destroyedOnServer', local: { title: 'Too late' }, server: null }); + expect(await getOne(s, 'e1')).toBeUndefined(); + }); + + it('lists a delete of an event the server changed, and brings the event back', async () => { + const s = await synced(); + await setEvent(s, { destroy: ['e1'] }); + s.server.patchEvent('e1', { title: 'Still wanted' }); + await flush(s); + expect(s.server.events.has('e1')).toBe(true); + expect((await conflicts(s))[0]).toMatchObject({ kind: 'destroy', reason: 'changedOnServer' }); + expect(await getOne(s, 'e1')).toMatchObject({ title: 'Still wanted' }); + }); + + it('lists a create the server refuses and takes the stand-in away', async () => { + const s = await synced(); + const result = await setEvent(s, { create: { c: { title: 'Bad', calendarIds: { nope: true }, start: '2026-11-20T09:00:00' } } }); + s.server.refuse.c = { type: 'invalidProperties', description: 'calendarIds', properties: ['calendarIds'] }; + await flush(s); + const [conflict] = await conflicts(s); + expect(conflict).toMatchObject({ kind: 'create', reason: 'refused', local: { title: 'Bad' } }); + expect(await getOne(s, result.created.c.id)).toBeUndefined(); + expect(pendingRows(s)).toEqual([]); + }); + + it('retries without series keys when 1.0 refuses them inside an override, instead of a conflict', async () => { + const s = setup('1.0'); + s.server.addEvent({ id: 'e1', recurrenceRule: WEEKLY, privacy: 'private' }); + await s.engine.requestSync(); + s.server.calls.length = 0; + await setEvent(s, { update: { e1: { recurrenceOverrides: { '2026-11-17T10:00:00': { title: 'One', privacy: 'private' } } } } }); + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect(setCalls(s.server)).toHaveLength(2); + expect(s.server.events.get('e1')?.recurrenceOverrides).toEqual({ '2026-11-17T10:00:00': { title: 'One' } }); + }); + + it('keeps a write for later when the server has a bad moment', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Soon' } } }); + const realRequest = s.server.request.bind(s.server); + let failed = false; + s.server.request = async (calls: MethodCall[]) => { + if (!failed && calls.some(([name]) => name === 'CalendarEvent/set')) { + failed = true; + return { methodResponses: [['error', { type: 'serverFail' }, 's']] }; + } + return realRequest(calls); + }; + expect(await flush(s)).toMatchObject({ sent: 0, conflicts: 0, pending: 1 }); + expect(await conflicts(s)).toEqual([]); + expect(await flush(s)).toMatchObject({ sent: 1, pending: 0 }); + }); + + it('lists a method error that means the write will not work', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'x' } } }); + const realRequest = s.server.request.bind(s.server); + s.server.request = async (calls: MethodCall[]) => { + if (calls.some(([name]) => name === 'CalendarEvent/set')) return { methodResponses: [['error', { type: 'accountReadOnly' }, 's']] }; + return realRequest(calls); + }; + expect(await flush(s)).toMatchObject({ conflicts: 1, pending: 0 }); + expect((await conflicts(s))[0].errorType).toBe('accountReadOnly'); + }); + + it('can dismiss a conflict', async () => { + const s = await synced(); + await setEvent(s, { update: { e1: { title: 'Mine' } } }); + s.server.patchEvent('e1', { title: 'Theirs' }); + await flush(s); + const [conflict] = await conflicts(s); + await s.engine.areaCall('calendar.dismissConflict', { id: conflict.id }); + expect(await conflicts(s)).toEqual([]); + }); + + it('keeps a pending edit through a resync, and lists it when the event is gone', async () => { + const s = await synced(); + s.server.addEvent({ id: 'e2', title: 'Stays' }); + await s.engine.requestSync(); + await setEvent(s, { update: { e1: { title: 'Mine' } } }); + await setEvent(s, { update: { e2: { title: 'Mine too' } } }); + s.server.events.delete('e1'); + s.server.eventLog.push('destroyed', 'e1'); + s.server.eventLog.truncatedBelow = s.server.eventLog.log.length; + await s.engine.requestSync(); + const [conflict] = await conflicts(s); + expect(conflict).toMatchObject({ recordId: 'e1', reason: 'destroyedOnServer' }); + expect(s.server.events.get('e2')?.title).toBe('Mine too'); + }); +}); + +describe('the synthetic-id probe', () => { + const probe = { update: { h333333: {} } }; + + it('tells a client what the server said at the last sync', async () => { + for (const version of ['1.0', '0.16'] as const) { + const s = setup(version); + await s.engine.requestSync(); + expect((await setEvent(s, probe)).notUpdated.h333333.type).toBe('notFound'); + } + const old = setup('0.16.19'); + await old.engine.requestSync(); + const refusal = (await setEvent(old, probe)).notUpdated.h333333; + expect(refusal.type).toBe('invalidProperties'); + expect(refusal.description).toMatch(/synthetic ids is not yet supported/); + }); + + it('says not supported before any sync, because client-side expansion works on every server', async () => { + const s = setup(); + const refusal = (await setEvent(s, probe)).notUpdated.h333333; + expect(refusal.type).toBe('invalidProperties'); + }); + + it('queues nothing for the probe', async () => { + const s = setup(); + await s.engine.requestSync(); + await setEvent(s, probe); + expect(pendingRows(s)).toEqual([]); + }); + + it('runs the probe on the sync request, and follows a server that was upgraded', async () => { + const s = setup('0.16.19'); + await s.engine.requestSync(); + expect(s.server.requests).toBeGreaterThan(0); + expect((await setEvent(s, probe)).notUpdated.h333333.type).toBe('invalidProperties'); + Object.defineProperty(s.server, 'version', { value: '1.0' }); + await s.engine.requestSync(); + expect((await setEvent(s, probe)).notUpdated.h333333.type).toBe('notFound'); + }); +}); + +describe('occurrence writes for ids the store does not hold', () => { + it('refuses them the way an old server does, so the client writes on the base event', async () => { + const s = setup(); + s.server.addEvent({ id: 'series', recurrenceRule: WEEKLY }); + await s.engine.requestSync(); + const update = await setEvent(s, { update: { h20261110series: { title: 'x' } } }); + expect(update.notUpdated.h20261110series.description).toMatch(/synthetic ids?\b.*\bnot (?:yet )?supported/i); + const destroy = await setEvent(s, { destroy: ['h20261110series'] }); + expect(destroy.notDestroyed.h20261110series.description).toMatch(/synthetic ids?\b.*\bnot (?:yet )?supported/i); + expect(pendingRows(s)).toEqual([]); + }); +}); + +describe('reminders from stored events', () => { + const ALERT = { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } }; + const FROM = Date.UTC(2026, 10, 10); + const TO = Date.UTC(2026, 10, 11); + const OPTIONS = { from: FROM, to: TO, timeZone: 'Europe/Berlin' }; + + it('lists an occurrence of a series, keyed by the master, with the time from its own zone', async () => { + const s = setup(); + s.server.addEvent({ id: 'series', start: '2026-01-06T10:00:00', recurrenceRule: WEEKLY, alerts: { a: ALERT } }); + await s.engine.requestSync(); + const alerts = listUpcomingAlerts(s.db, OPTIONS); + expect(alerts).toHaveLength(1); + const fire = Date.UTC(2026, 10, 10, 8, 45); + expect(alerts[0]).toMatchObject({ + baseEventId: 'series', alertId: 'a', fireTimeMs: fire, recurrenceId: '2026-11-10T10:00:00', key: `series:a:${fire}`, kind: 'event', jmap: JMAP_ACCOUNT, + }); + }); + + it('follows a moved occurrence and skips an excluded one', async () => { + const s = setup(); + s.server.addEvent({ + id: 'moved', + start: '2026-01-06T10:00:00', + recurrenceRule: WEEKLY, + alerts: { a: ALERT }, + recurrenceOverrides: { '2026-11-10T10:00:00': { start: '2026-11-10T15:00:00' } }, + }); + s.server.addEvent({ + id: 'excluded', + start: '2026-01-06T10:00:00', + recurrenceRule: WEEKLY, + alerts: { a: ALERT }, + recurrenceOverrides: { '2026-11-10T10:00:00': { excluded: true } }, + }); + await s.engine.requestSync(); + const alerts = listUpcomingAlerts(s.db, OPTIONS); + expect(alerts.map((alert) => [alert.baseEventId, alert.fireTimeMs])).toEqual([['moved', Date.UTC(2026, 10, 10, 13, 45)]]); + }); + + it('finds an alert days before its occurrence, and a fixed-time alert of a series', async () => { + const s = setup(); + s.server.addEvent({ + id: 'days', + start: '2026-11-13T10:00:00', + alerts: { a: { ...ALERT, trigger: { '@type': 'OffsetTrigger', offset: '-P3D' } } }, + }); + s.server.addEvent({ + id: 'days-series', + start: '2026-01-06T10:00:00', + recurrenceRule: { frequency: 'yearly', byMonth: ['11'], byMonthDay: [13] }, + alerts: { a: { ...ALERT, trigger: { '@type': 'OffsetTrigger', offset: '-P3D' } } }, + }); + s.server.addEvent({ + id: 'fixed', + start: '2026-01-06T10:00:00', + recurrenceRule: WEEKLY, + alerts: { a: { ...ALERT, trigger: { '@type': 'AbsoluteTrigger', when: '2026-11-10T06:00:00Z' } } }, + }); + await s.engine.requestSync(); + const alerts = listUpcomingAlerts(s.db, OPTIONS); + expect(alerts.map((alert) => alert.baseEventId).sort()).toEqual(['days', 'days-series', 'fixed']); + expect(alerts.filter((alert) => alert.baseEventId === 'fixed')).toHaveLength(1); + }); + + it('uses the calendar defaults while the event does, and reads a floating event in the given zone', async () => { + const s = setup(); + s.server.addEvent({ id: 'def', start: '2026-11-10T10:00:00', timeZone: null, useDefaultAlerts: true }); + await s.engine.requestSync(); + const [alert] = listUpcomingAlerts(s.db, OPTIONS); + expect(alert).toMatchObject({ baseEventId: 'def', alertId: 'd1', calendarName: 'Personal', fireTimeMs: Date.UTC(2026, 10, 10, 8, 45) }); + expect(listUpcomingAlerts(s.db, { ...OPTIONS, timeZone: 'UTC' })[0].fireTimeMs).toBe(Date.UTC(2026, 10, 10, 9, 45)); + }); + + it('leaves out what should not fire, and what is outside the window', async () => { + const s = setup(); + s.server.addEvent({ id: 'email', alerts: { a: { ...ALERT, action: 'email' } } }); + s.server.addEvent({ id: 'seen', alerts: { a: { ...ALERT, acknowledged: '2026-11-01T00:00:00Z' } } }); + s.server.addEvent({ id: 'off', status: 'cancelled', alerts: { a: ALERT } }); + s.server.addEvent({ id: 'later', start: '2026-12-10T10:00:00', alerts: { a: ALERT } }); + s.server.addEvent({ id: 'task-done', '@type': 'Task', start: undefined, due: '2026-11-10T10:00:00', progress: 'completed', alerts: { a: ALERT } }); + await s.engine.requestSync(); + expect(listUpcomingAlerts(s.db, OPTIONS)).toEqual([]); + }); + + it('lists a task, and an edit or a delete made offline', async () => { + const s = setup(); + s.server.addEvent({ id: 'task', '@type': 'Task', start: undefined, due: '2026-11-10T10:00:00Z', alerts: { a: ALERT } }); + s.server.addEvent({ id: 'moved', start: '2026-12-10T10:00:00', alerts: { a: ALERT } }); + s.server.addEvent({ id: 'dropped', start: '2026-11-10T11:00:00', alerts: { a: ALERT } }); + await s.engine.requestSync(); + await setEvent(s, { update: { moved: { start: '2026-11-10T09:00:00' } } }); + await setEvent(s, { destroy: ['dropped'] }); + expect(listUpcomingAlerts(s.db, OPTIONS).map((alert) => [alert.baseEventId, alert.kind])).toEqual([['moved', 'event'], ['task', 'task']]); + }); +}); + +it('uses only capabilities the session lists', async () => { + const s = setup(); + s.server.addEvent(); + const usings: string[][] = []; + const realRequest = s.server.request.bind(s.server) as (calls: MethodCall[]) => ReturnType; + s.server.request = ((calls: MethodCall[], using: string[]) => { + usings.push(using); + return realRequest(calls); + }) as FakeCalendarJmap['request']; + await s.engine.requestSync(); + for (const using of usings) expect(using).toEqual(['urn:ietf:params:jmap:core', CAL]); +}); diff --git a/lib/store/__tests__/helpers/client-wire.ts b/lib/store/__tests__/helpers/client-wire.ts new file mode 100644 index 000000000..d3c9860b7 --- /dev/null +++ b/lib/store/__tests__/helpers/client-wire.ts @@ -0,0 +1,79 @@ +/** + * A real JMAPClient talking to a FakeCalendarJmap while "online" and to the + * store's answers while "offline", through a stubbed fetch. + */ + +import { vi } from 'vitest'; +import { JMAPClient } from '@/lib/jmap/client'; +import { DEFAULT_SCOPE, type StoreArea } from '../../area'; +import { AccountEngine } from '../../engine/account-engine'; +import type { MethodCall } from '../../engine/transport'; +import { CALENDAR_SESSION, FakeCalendarJmap } from './fake-calendar'; +import { JMAP_ACCOUNT } from './fake-jmap'; +import { openMemoryStore } from './node-adapter'; + +const CLIENT_SESSION = { + ...CALENDAR_SESSION, + primaryAccounts: { ...CALENDAR_SESSION.primaryAccounts, 'urn:ietf:params:jmap:mail': JMAP_ACCOUNT }, + accounts: { + [JMAP_ACCOUNT]: { + name: 'a@example.org', + isPersonal: true, + accountCapabilities: { ...CALENDAR_SESSION.accounts[JMAP_ACCOUNT].accountCapabilities, 'urn:ietf:params:jmap:mail': {} }, + }, + }, + capabilities: { ...CALENDAR_SESSION.capabilities, 'urn:ietf:params:jmap:mail': {} }, + eventSourceUrl: '', +}; + +function json(body: unknown): Response { + return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); +} + +export interface Wire { + server: FakeCalendarJmap; + engine: AccountEngine; + db: ReturnType; + /** Everything the client sent, wherever it went. */ + sent: MethodCall[]; + online: boolean; + newClient(): Promise; + restore(): void; +} + +export function wire(server: FakeCalendarJmap, areas: readonly StoreArea[], now: number): Wire { + const db = openMemoryStore(); + const engine = new AccountEngine({ + account: 'a@fake.test', + db, + session: CALENDAR_SESSION, + transport: server, + areas, + scope: DEFAULT_SCOPE, + now: () => now, + sleep: async () => {}, + }); + const spy = vi.spyOn(globalThis, 'fetch'); + const state: Wire = { + server, + engine, + db, + sent: [], + online: true, + newClient: async () => { + const client = new JMAPClient('https://fake.test', 'a@example.org', 'pw'); + await client.connect(); + return client; + }, + restore: () => spy.mockRestore(), + }; + spy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => { + if (!init?.body) return json(CLIENT_SESSION); + const calls = (JSON.parse(String(init.body)) as { methodCalls: MethodCall[] }).methodCalls; + state.sent.push(...calls); + const answer = state.online ? await server.request(calls) : await engine.answer(calls); + if (!answer) throw new TypeError('Failed to fetch'); + return json(answer); + }); + return state; +} diff --git a/lib/store/__tests__/helpers/fake-calendar.ts b/lib/store/__tests__/helpers/fake-calendar.ts new file mode 100644 index 000000000..752017d57 --- /dev/null +++ b/lib/store/__tests__/helpers/fake-calendar.ts @@ -0,0 +1,365 @@ +/** + * FakeJmap with calendars, modelled on what was measured on both Stalwarts + * (local-data/desktop-offline-facts-parts/stalwart-changes-prf.md section 7, and + * the probe run in desktop-offline-decisions.md): + * + * - /changes lists masters only. An edit through a synthetic id gives + * `updated: [master]`. + * - A plain query returns masters. An expanded one needs both bounds, says + * canCalculateChanges false on 1.0 and true on 0.16, refuses more than P52W1D + * on 1.0, and gives a single event a synthetic id on 0.16 only. + * - 1.0 refuses recurrenceOverrides next to utcStart in one get, and series keys + * inside an override. `recurrenceRules` is an invalid property on both. + * - A pointer into overrides fails while a series has none. + * - A second event with one uid is refused, uid filters match exactly. + */ + +import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization'; +import { applyPatchObject } from '@/lib/jmap/patch-object'; +import type { CalendarEvent } from '@/lib/jmap/types'; +import { expandRecurringEvents } from '@/lib/recurrence-expansion'; +import { SYNTHETIC_ID_PROBE } from '@/lib/recurrence-instances'; +import { TransportError, type JmapResponse, type MethodCall, type MethodResponse, type StoreSession } from '../../engine/transport'; +import { Collection, FakeJmap, JMAP_ACCOUNT } from './fake-jmap'; + +export const CAL = 'urn:ietf:params:jmap:calendars'; + +export const CALENDAR_SESSION: StoreSession = { + apiUrl: 'https://fake.test/jmap/', + downloadUrl: 'https://fake.test/d/{accountId}/{blobId}/{name}?type={type}', + uploadUrl: 'https://fake.test/u/{accountId}', + primaryAccounts: { [CAL]: JMAP_ACCOUNT }, + accounts: { + [JMAP_ACCOUNT]: { name: 'a@example.org', isPersonal: true, accountCapabilities: { [CAL]: { maxExpandedQueryDuration: 'P52W1D' } } }, + }, + capabilities: { 'urn:ietf:params:jmap:core': {}, [CAL]: {} }, +}; + +export type FakeEvent = Record & { id: string }; +export type StalwartVersion = '1.0' | '0.16' | '0.16.19'; + +function jmapError(type: string, description?: string): Error { + return Object.assign(new Error(type), { jmapError: type, description }); +} + +const SERIES_KEYS_IN_OVERRIDE = ['privacy', 'organizerCalendarAddress']; + +interface Instance { + id: string; + master: FakeEvent; + recurrenceId: string | null; +} + +export class FakeCalendarJmap extends FakeJmap { + readonly events = new Map(); + readonly calendars = new Map & { id: string }>(); + readonly identities: Array> = [{ id: 'pi1', name: 'A', calendarAddress: 'mailto:a@example.org', isDefault: true }]; + readonly eventLog = new Collection(); + private readonly instances = new Map(); + private nextEvent = 1; + /** The next create or update reaches the server, but its answer doesn't reach the client. */ + loseNextSetResponse = false; + /** The next CalendarEvent/set refuses this id like a server would. */ + refuse: Record = {}; + + constructor(readonly version: StalwartVersion = '1.0') { + super(); + this.calendars.set('cal1', { + id: 'cal1', name: 'Personal', isDefault: true, myRights: { mayWriteAll: true }, + defaultAlertsWithTime: { d1: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } }, + }); + } + + addEvent(partial: Partial = {}): FakeEvent { + const id = partial.id ?? `ev${this.nextEvent++}`; + const event: FakeEvent = { + '@type': 'Event', + uid: `${id}@example.org`, + calendarIds: { cal1: true }, + title: `Event ${id}`, + start: '2026-11-10T10:00:00', + duration: 'PT1H', + timeZone: 'Europe/Berlin', + baseEventId: null, + isOrigin: true, + useDefaultAlerts: false, + ...partial, + id, + }; + this.events.set(id, event); + this.eventLog.push('created', id); + return event; + } + + /** Someone else changes an event. */ + patchEvent(id: string, patch: Record): void { + this.events.set(id, applyPatchObject(this.events.get(id) as FakeEvent, patch)); + this.eventLog.push('updated', id); + } + + removeEvent(id: string): void { + this.events.delete(id); + this.eventLog.push('destroyed', id); + } + + private expanded(after: string, before: string): Instance[] { + const out: Instance[] = []; + for (const master of this.events.values()) { + const normal = normalizeCalendarEventLike(master as unknown as CalendarEvent); + if (!normal.recurrenceRules?.length) { + const start = String(master.start); + if (start < after || start >= before) continue; + out.push({ id: this.version === '1.0' ? master.id : `e${master.id}`, master, recurrenceId: null }); + continue; + } + for (const occurrence of expandRecurringEvents([normal], after, before)) { + out.push({ + id: `h${String(occurrence.recurrenceId).replace(/\D/g, '')}${master.id}`, + master, + recurrenceId: occurrence.recurrenceId as string, + }); + } + } + for (const instance of out) this.instances.set(instance.id, instance); + return out; + } + + private instanceView(instance: Instance): FakeEvent { + const { master, recurrenceId } = instance; + if (!recurrenceId) return { ...master, id: instance.id, baseEventId: this.version === '1.0' ? null : master.id }; + const override = ((master.recurrenceOverrides ?? {}) as Record>)[recurrenceId] ?? {}; + const { recurrenceRule: _r, recurrenceOverrides: _o, ...rest } = master; + return { + ...rest, + start: recurrenceId, + ...override, + id: instance.id, + baseEventId: master.id, + recurrenceId, + recurrenceRule: null, + }; + } + + private query(args: Record): Record { + const filter = (args.filter ?? {}) as { uid?: string; after?: string; before?: string; inCalendar?: string; operator?: string }; + const position = Number(args.position ?? 0); + const limit = Number(args.limit ?? 5000) || 5000; + const page = (ids: string[], canCalculateChanges: boolean) => ({ + accountId: args.accountId, + queryState: this.eventLog.state(), + canCalculateChanges, + position, + ids: ids.slice(position, position + limit), + }); + if (args.expandRecurrences) { + if (!filter.after || !filter.before) throw jmapError('invalidArguments', 'Expansion needs after and before'); + const days = (Date.parse(`${filter.before}Z`) - Date.parse(`${filter.after}Z`)) / 86_400_000; + if (this.version === '1.0' && days > 365) throw jmapError('expandDurationTooLarge'); + const list = this.expanded(filter.after, filter.before); + return page(list.map((instance) => instance.id), this.version !== '1.0'); + } + const masters = [...this.events.values()].filter((event) => { + if (filter.uid !== undefined && event.uid !== filter.uid) return false; + if (filter.inCalendar && !(event.calendarIds as Record)[filter.inCalendar]) return false; + return true; + }); + return page(masters.map((event) => event.id), true); + } + + private get(args: Record): Record { + const properties = args.properties as string[] | undefined; + if (properties?.includes('recurrenceRules')) throw jmapError('invalidArguments', 'recurrenceRules'); + if (this.version === '1.0' && properties?.includes('recurrenceOverrides') + && (properties.includes('utcStart') || properties.includes('utcEnd'))) { + throw jmapError('invalidArguments', 'recurrenceOverrides cannot be requested together with utcStart or utcEnd'); + } + const ids = (args.ids as string[] | null) ?? [...this.events.keys()].slice(0, 500); + if (ids.length > 500) throw jmapError('requestTooLarge'); + const list: Record[] = []; + const notFound: string[] = []; + for (const id of ids) { + const instance = this.instances.get(id); + const event = instance && this.events.has(instance.master.id) + ? this.instanceView({ ...instance, master: this.events.get(instance.master.id) as FakeEvent }) + : this.events.get(id); + if (!event) { + notFound.push(id); + continue; + } + const full = { ...event, utcStart: `${String(event.start)}Z`, utcEnd: `${String(event.start)}Z` }; + if (!properties) { + list.push({ ...event }); + continue; + } + const out: Record = { id }; + for (const name of properties) if (name in full) out[name] = (full as Record)[name]; + list.push(out); + } + return { accountId: args.accountId, state: this.eventLog.state(), list, notFound }; + } + + private seriesKeyInOverride(patch: Record): string | null { + if (this.version !== '1.0') return null; + for (const [key, value] of Object.entries(patch)) { + if (key !== 'recurrenceOverrides' && !key.startsWith('recurrenceOverrides/')) continue; + const entries = key === 'recurrenceOverrides' ? Object.values(value as object) : [value]; + for (const entry of entries) { + for (const name of SERIES_KEYS_IN_OVERRIDE) { + if (entry && typeof entry === 'object' && name in entry) return name; + } + } + } + return null; + } + + private update(id: string, patch: Record): { type: string; description?: string; properties?: string[] } | null { + if (this.refuse[id]) { + const refusal = this.refuse[id]; + delete this.refuse[id]; + return refusal; + } + if (id === SYNTHETIC_ID_PROBE) { + return this.version === '0.16.19' + ? { type: 'invalidProperties', description: 'Updating synthetic ids is not yet supported.', properties: ['id'] } + : { type: 'notFound' }; + } + const instance = this.instances.get(id); + if (instance?.recurrenceId) { + if (this.version === '0.16.19') { + return { type: 'invalidProperties', description: 'Updating synthetic ids is not yet supported.', properties: ['id'] }; + } + const master = this.events.get(instance.master.id); + if (!master) return { type: 'notFound' }; + const overrides = { ...(master.recurrenceOverrides as object | undefined) } as Record>; + overrides[instance.recurrenceId] = { ...overrides[instance.recurrenceId], ...patch }; + this.patchEvent(master.id, { recurrenceOverrides: overrides }); + return null; + } + const event = this.events.get(id); + if (!event) return { type: 'notFound' }; + const seriesKey = this.seriesKeyInOverride(patch); + if (seriesKey) { + return { type: 'invalidProperties', description: `${seriesKey} cannot be set inside recurrenceOverrides.`, properties: ['recurrenceOverrides'] }; + } + const pointsIntoNothing = Object.keys(patch).some((key) => key.startsWith('recurrenceOverrides/')) && !event.recurrenceOverrides; + if (pointsIntoNothing) return { type: this.version === '1.0' ? 'invalidPatch' : 'invalidProperties', description: 'Patch operation failed.' }; + this.patchEvent(id, patch); + return null; + } + + private set(args: Record): Record { + const out: Record = { accountId: args.accountId, oldState: this.eventLog.state() }; + const created: Record = {}; + const notCreated: Record = {}; + for (const [key, event] of Object.entries((args.create ?? {}) as Record>)) { + const seriesKey = this.seriesKeyInOverride(event); + if (seriesKey) { + notCreated[key] = { type: 'invalidProperties', description: `${seriesKey} cannot be set inside recurrenceOverrides.`, properties: ['recurrenceOverrides'] }; + } else if (typeof event.uid === 'string' && [...this.events.values()].some((other) => other.uid === event.uid)) { + notCreated[key] = { type: 'invalidProperties', description: `An event with UID ${event.uid} already exists.`, properties: ['uid'] }; + } else if (this.refuse[key]) { + notCreated[key] = this.refuse[key]; + delete this.refuse[key]; + } else { + created[key] = { id: this.addEvent(event).id }; + } + } + const updated: Record = {}; + const notUpdated: Record = {}; + for (const [id, patch] of Object.entries((args.update ?? {}) as Record>)) { + const refusal = this.update(id, patch); + if (refusal) notUpdated[id] = refusal; + else updated[id] = null; + } + const destroyed: string[] = []; + const notDestroyed: Record = {}; + for (const id of (args.destroy ?? []) as string[]) { + const instance = this.instances.get(id); + if (instance?.recurrenceId && this.events.has(instance.master.id)) { + const master = this.events.get(instance.master.id) as FakeEvent; + this.patchEvent(master.id, { + recurrenceOverrides: { ...(master.recurrenceOverrides as object | undefined), [instance.recurrenceId]: { excluded: true } }, + }); + destroyed.push(id); + } else if (this.refuse[id]) { + notDestroyed[id] = this.refuse[id]; + delete this.refuse[id]; + } else if (this.events.has(id)) { + this.removeEvent(id); + destroyed.push(id); + } else { + notDestroyed[id] = { type: 'notFound' }; + } + } + if (Object.keys(created).length > 0) out.created = created; + if (Object.keys(notCreated).length > 0) out.notCreated = notCreated; + if (Object.keys(updated).length > 0) out.updated = updated; + if (Object.keys(notUpdated).length > 0) out.notUpdated = notUpdated; + if (destroyed.length > 0) out.destroyed = destroyed; + if (Object.keys(notDestroyed).length > 0) out.notDestroyed = notDestroyed; + out.newState = this.eventLog.state(); + return out; + } + + private answerCalendarCall(name: string, args: Record): Record { + switch (name) { + case 'Calendar/get': + return { accountId: args.accountId, state: 'cs1', list: [...this.calendars.values()], notFound: [] }; + case 'ParticipantIdentity/get': + return { accountId: args.accountId, list: this.identities, notFound: [] }; + case 'CalendarEvent/query': + return this.query(args); + case 'CalendarEvent/get': + return this.get(args); + case 'CalendarEvent/set': + return this.set(args); + case 'CalendarEvent/changes': { + const since = String(args.sinceState); + if (!/^s\d+$/.test(since)) throw jmapError('invalidArguments'); + const from = Number(since.slice(1)); + if (from < this.eventLog.truncatedBelow || from > this.eventLog.log.length) throw jmapError('cannotCalculateChanges'); + const maxChanges = Math.min(Number(args.maxChanges ?? 5000) || 5000, 5000); + return { accountId: args.accountId, oldState: since, ...this.eventLog.changes(since, maxChanges) }; + } + default: + throw jmapError('unknownMethod'); + } + } + + override async request(calls: MethodCall[]): Promise { + if (!calls.every(([name]) => /^(Calendar|CalendarEvent|ParticipantIdentity)\//.test(name))) return super.request(calls); + this.requests += 1; + this.beforeRequest?.(this.requests); + if (this.offline) throw new TransportError('network', 'Failed to fetch'); + if (calls.length > 16) throw new TransportError('http', 'too many calls', 400, 'urn:ietf:params:jmap:error:limit'); + const done: MethodResponse[] = []; + let lose = false; + for (const call of calls) { + this.calls.push(call); + const [name, raw, id] = call; + const args: Record = {}; + for (const [key, value] of Object.entries(raw)) { + if (key.startsWith('#')) { + const ref = value as { resultOf: string; path: string }; + const source = done.find(([, , callId]) => callId === ref.resultOf); + args[key.slice(1)] = source?.[1][ref.path.slice(1)]; + } else { + args[key] = value; + } + } + try { + done.push([name, this.answerCalendarCall(name, args), id]); + if (name === 'CalendarEvent/set' && (raw.create || raw.update) && this.loseNextSetResponse) lose = true; + } catch (error) { + const type = (error as { jmapError?: string }).jmapError; + if (!type) throw error; + done.push(['error', { type, description: (error as { description?: string }).description }, id]); + } + } + if (lose) { + this.loseNextSetResponse = false; + throw new TransportError('network', 'The connection dropped before the answer came'); + } + return { methodResponses: done }; + } +} diff --git a/lib/store/__tests__/helpers/fake-jmap.ts b/lib/store/__tests__/helpers/fake-jmap.ts index 9aa917be5..bac572400 100644 --- a/lib/store/__tests__/helpers/fake-jmap.ts +++ b/lib/store/__tests__/helpers/fake-jmap.ts @@ -25,7 +25,7 @@ type ChangeKind = 'created' | 'updated' | 'destroyed'; interface Log { kind: ChangeKind; id: string } -class Collection { +export class Collection { log: Log[] = []; /** Below this log position, states are refused as truncated. */ truncatedBelow = 0; diff --git a/lib/store/area.ts b/lib/store/area.ts index fceed110e..b73740b5b 100644 --- a/lib/store/area.ts +++ b/lib/store/area.ts @@ -96,9 +96,10 @@ export type MethodHandler = (ctx: MethodContext, args: Record, /** * A call from the page that isn't JMAP: outbox rows, usage, clearing. Reached * from any tab with `store.area(account, 'outbox.list', params)`. Names are - * unique across areas and start with the area's name. + * unique across areas and start with the area's name. It gets the sync context, + * because some calls talk to the server (`calendar.flush`). */ -export type AreaCall = (ctx: StoreContext, params: never) => unknown | Promise; +export type AreaCall = (ctx: SyncContext, params: never) => unknown | Promise; export interface StoreArea { name: string; diff --git a/lib/store/areas.ts b/lib/store/areas.ts index 6bff665ca..30a6541ff 100644 --- a/lib/store/areas.ts +++ b/lib/store/areas.ts @@ -1,6 +1,7 @@ /** Every part of the local store. New areas go here. */ import type { StoreArea } from './area'; +import { calendarArea } from './calendar'; import { mailArea } from './mail'; -export const STORE_AREAS: readonly StoreArea[] = [mailArea]; +export const STORE_AREAS: readonly StoreArea[] = [mailArea, calendarArea]; diff --git a/lib/store/calendar/filter.ts b/lib/store/calendar/filter.ts new file mode 100644 index 000000000..41a29ffd3 --- /dev/null +++ b/lib/store/calendar/filter.ts @@ -0,0 +1,220 @@ +/** + * CalendarEvent/query filters, read the way Stalwart reads them: after and + * before are LocalDateTimes in the request's time zone, and a series matches + * when any occurrence is in the range. Text matches parts of words, where the + * server only matches whole ones: offline search finds more, not less. + */ + +import { addDays, format, parseISO } from 'date-fns'; +import { isTaskLikeObject } from '@/lib/calendar-component-detection'; +import { normalizeCalendarEventLike, parseDurationSeconds } from '@/lib/calendar-event-normalization'; +import type { CalendarEvent, CalendarEventFilter } from '@/lib/jmap/types'; +import { expandRecurringEvents } from '@/lib/recurrence-expansion'; +import { MethodError } from '../area'; +import { instantOf, instantsOf, isSeries, wallClock, type CalendarObject } from './records'; + +export interface RangeBounds { + after?: string; + before?: string; +} + +/** The range of a filter, in the two shapes the client sends it: plain keys, or an AND of one-key conditions. */ +export function boundsOf(filter: CalendarEventFilter): RangeBounds { + if (filter.operator === 'AND') { + const bounds: RangeBounds = {}; + for (const condition of filter.conditions ?? []) { + if (condition.operator || Object.keys(condition).length !== 1) continue; + if (condition.after) bounds.after = condition.after; + if (condition.before) bounds.before = condition.before; + } + return bounds; + } + if (filter.operator) return {}; + return { after: filter.after, before: filter.before }; +} + +function fold(text: string): string { + return text.normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase(); +} + +function containsWords(haystack: Array, needle: string): boolean { + const text = fold(haystack.filter((part): part is string => typeof part === 'string').join('\n')); + return fold(needle).split(/\s+/).filter(Boolean).every((word) => text.includes(word)); +} + +type Participant = { + name?: string; + email?: string; + calendarAddress?: string; + participationStatus?: string; + roles?: Record; +}; + +function participantsOf(object: CalendarObject): Participant[] { + return Object.values((object.participants ?? {}) as Record); +} + +function placeTexts(object: CalendarObject): unknown[] { + const places = Object.values((object.locations ?? {}) as Record); + const virtual = Object.values((object.virtualLocations ?? {}) as Record); + return [...places, ...virtual].flatMap((place) => [place.name, place.description]); +} + +function personTexts(participant: Participant): unknown[] { + return [participant.name, participant.email, participant.calendarAddress]; +} + +function isOwner(participant: Participant): boolean { + return participant.roles?.owner === true; +} + +function isAttendee(participant: Participant): boolean { + return !participant.roles || participant.roles.attendee === true; +} + +function ownerTexts(object: CalendarObject): unknown[] { + return [object.organizerCalendarAddress, ...participantsOf(object).filter(isOwner).flatMap(personTexts)]; +} + +// An occurrence's wall clock is in the series' zone and the bounds in the +// request's: up to 26 hours apart. The client narrows the rest when it expands. +const SERIES_SLACK_DAYS = 2; +const OPEN_RANGE_DAYS = 5 * 366; + +function seriesOccursIn(object: CalendarObject, after: string | undefined, before: string | undefined): boolean { + const seriesStart = typeof object.start === 'string' ? parseISO(object.start) : null; + if (!seriesStart || Number.isNaN(seriesStart.getTime())) return false; + const lengthMs = (parseDurationSeconds(typeof object.duration === 'string' ? object.duration : undefined) ?? 0) * 1000; + const from = after + ? new Date(addDays(parseISO(wallClock(after)), -SERIES_SLACK_DAYS).getTime() - lengthMs) + : seriesStart; + const to = before ? addDays(parseISO(wallClock(before)), SERIES_SLACK_DAYS) : addDays(from, OPEN_RANGE_DAYS); + if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || from >= to) return false; + const stamp = (date: Date) => format(date, "yyyy-MM-dd'T'HH:mm:ss"); + const event = normalizeCalendarEventLike(object as unknown as CalendarEvent); + return expandRecurringEvents([event], stamp(from), stamp(to)).length > 0; +} + +function inRange(object: CalendarObject, bounds: RangeBounds, zone: string): boolean { + if (isSeries(object)) return seriesOccursIn(object, bounds.after, bounds.before); + const { start, end } = instantsOf(object, zone); + if (start === null || end === null) return false; + const after = bounds.after ? instantOf(bounds.after, zone) : null; + const before = bounds.before ? instantOf(bounds.before, zone) : null; + if (before !== null && start >= before) return false; + if (after === null) return true; + return end > after || (end === start && start >= after); +} + +function matchesCondition(object: CalendarObject, condition: CalendarEventFilter, zone: string): boolean { + const bounds: RangeBounds = {}; + for (const [key, value] of Object.entries(condition)) { + if (value === undefined) continue; + switch (key) { + case 'after': + case 'before': + bounds[key as 'after' | 'before'] = value as string; + break; + case 'inCalendar': + if (!(object.calendarIds as Record | undefined)?.[value as string]) return false; + break; + case 'inCalendars': + if (!(value as string[]).some((id) => (object.calendarIds as Record | undefined)?.[id])) return false; + break; + case 'uid': + if (object.uid !== value) return false; + break; + case 'title': + if (!containsWords([object.title], value as string)) return false; + break; + case 'description': + if (!containsWords([object.description], value as string)) return false; + break; + case 'location': + if (!containsWords(placeTexts(object), value as string)) return false; + break; + case 'owner': + if (!containsWords(ownerTexts(object), value as string)) return false; + break; + case 'attendee': { + // With participationStatus in the same condition: the same person. + const status = condition.participationStatus; + const found = participantsOf(object).some((person) => isAttendee(person) + && containsWords(personTexts(person), value as string) + && (!status || person.participationStatus === status)); + if (!found) return false; + break; + } + case 'participationStatus': + if (!participantsOf(object).some((person) => person.participationStatus === value)) return false; + break; + case 'text': + if (!containsWords([ + object.title, + object.description, + ...placeTexts(object), + ...ownerTexts(object), + ...participantsOf(object).flatMap(personTexts), + ], value as string)) return false; + break; + case 'types': { + const type = isTaskLikeObject(object) ? 'task' : 'event'; + if (!(value as string[]).some((name) => name.toLowerCase() === type)) return false; + break; + } + default: + throw new MethodError('unsupportedFilter', `${key} isn't available offline`); + } + } + return bounds.after || bounds.before ? inRange(object, bounds, zone) : true; +} + +export function matchesFilter(object: CalendarObject, filter: CalendarEventFilter, zone: string): boolean { + const conditions = filter.conditions ?? []; + switch (filter.operator) { + case 'AND': { + // A range split over two conditions is one range: a series must have one + // occurrence inside it, not one after the start and another before the end. + const bounds = boundsOf(filter); + const isBound = (c: CalendarEventFilter) => !c.operator && Object.keys(c).length === 1 && Boolean(c.after || c.before); + if ((bounds.after || bounds.before) && !inRange(object, bounds, zone)) return false; + return conditions.every((c) => isBound(c) || matchesFilter(object, c, zone)); + } + case 'OR': + return conditions.some((c) => matchesFilter(object, c, zone)); + case 'NOT': + return !conditions.some((c) => matchesFilter(object, c, zone)); + case undefined: + return matchesCondition(object, filter, zone); + default: + throw new MethodError('unsupportedFilter', `Unknown operator ${String(filter.operator)}`); + } +} + +export interface EventComparator { + property?: string; + isAscending?: boolean; +} + +function sortValue(object: CalendarObject, property: string | undefined, zone: string): number | string { + if (property === 'start') return instantsOf(object, zone).start ?? 0; + const value = property ? object[property] : undefined; + return typeof value === 'number' || typeof value === 'string' ? value : ''; +} + +/** By start when the request names no order. Ties go by id, so paging is stable. */ +export function sortEvents(objects: CalendarObject[], sort: readonly EventComparator[], zone: string): CalendarObject[] { + const levels = sort.length > 0 ? sort : [{ property: 'start', isAscending: true }]; + const keyed = objects.map((object) => ({ object, keys: levels.map((level) => sortValue(object, level.property, zone)) })); + keyed.sort((a, b) => { + for (let i = 0; i < levels.length; i += 1) { + const x = a.keys[i]; + const y = b.keys[i]; + if (x === y) continue; + const order = x < y ? -1 : 1; + return levels[i].isAscending === false ? -order : order; + } + return a.object.id < b.object.id ? -1 : a.object.id > b.object.id ? 1 : 0; + }); + return keyed.map((entry) => entry.object); +} diff --git a/lib/store/calendar/flush.ts b/lib/store/calendar/flush.ts new file mode 100644 index 000000000..b4266a473 --- /dev/null +++ b/lib/store/calendar/flush.ts @@ -0,0 +1,229 @@ +/** + * Sends pending calendar writes, one event per request, so a lost response + * leaves one event's outcome open and not a batch's. + * + * Only a create can go wrong by being repeated. It's marked `attempted` in its + * own transaction before it goes out, and carries a uid the device chose. A + * create with that mark first looks for its uid on the server. An edit sent + * twice writes the same values twice, and a delete sent twice finds nothing the + * second time. + */ + +import { jmapError } from '@/lib/jmap/jmap-error'; +import { isOverrideSeriesKeyRejection, stableJson, withoutOverrideSeriesKeys } from '@/lib/recurrence-instances'; +import type { CalendarEvent } from '@/lib/jmap/types'; +import { applyPatchObject } from '@/lib/jmap/patch-object'; +import type { SyncContext } from '../area'; +import { JmapMethodError, responseFor } from '../engine/transport'; +import { calendarUsing, currentGen, deleteEventRow, EVENT_PROPERTIES, eventRow, valueAtPointer, writeEvent, type CalendarObject } from './records'; +import { recordConflict, type PendingRow } from './writes'; + +export interface FlushTally { + sent: number; + conflicts: number; +} + +interface SetError { + type?: string; + description?: string; + properties?: string[]; +} + +interface SetResult { + created?: Record | null; + updated?: Record | null; + destroyed?: string[] | null; + notCreated?: Record | null; + notUpdated?: Record | null; + notDestroyed?: Record | null; +} + +// The server had a bad moment, nothing is wrong with this write: try again later. +const PASSING_ERRORS = new Set(['serverFail', 'serverPartialFail', 'serverUnavailable', 'rateLimit', 'tooManyRequests']); + +export function isPassingError(error: unknown): boolean { + return error instanceof JmapMethodError && PASSING_ERRORS.has(error.type); +} + +type Outcome = 'sent' | 'conflict' | 'again'; + +async function sendSet(ctx: SyncContext, row: PendingRow, args: Record): Promise { + const response = await ctx.transport.request([['CalendarEvent/set', { + accountId: row.jmap, + ...(row.send_scheduling === null ? {} : { sendSchedulingMessages: row.send_scheduling === 1 }), + ...args, + }, 's']], calendarUsing(ctx.session)); + return responseFor(response, 's')[1] as SetResult; +} + +/** Stalwart 1.0 refuses what 0.16 expects inside an override. The client normally learns that from the refusal. */ +function isSeriesKeyRefusal(error: SetError | undefined): boolean { + return Boolean(error) && isOverrideSeriesKeyRejection(jmapError(error?.description ?? '', error)); +} + +async function findByUid(ctx: SyncContext, jmap: string, uid: string | null): Promise { + if (!uid) return null; + const response = await ctx.transport.request([['CalendarEvent/query', { accountId: jmap, filter: { uid }, limit: 1 }, 'u']], calendarUsing(ctx.session)); + const ids = responseFor(response, 'u')[1].ids as string[]; + return ids[0] ?? null; +} + +async function fetchEvent(ctx: SyncContext, jmap: string, id: string): Promise { + const response = await ctx.transport.request([['CalendarEvent/get', { accountId: jmap, ids: [id], properties: EVENT_PROPERTIES }, 'g']], calendarUsing(ctx.session)); + return (responseFor(response, 'g')[1].list as CalendarObject[])[0] ?? null; +} + +/** The stand-in becomes the real event. What came in while the create was on its way goes on as a write of its own. */ +function finishCreate(ctx: SyncContext, row: PendingRow, sent: Record, realId: string, event: CalendarObject): Outcome { + const { db } = ctx; + return db.transaction(() => { + const current = db.get('SELECT * FROM calendar_pending WHERE seq = ?', [row.seq]); + const gen = currentGen(db, row.jmap); + deleteEventRow(db, row.jmap, row.record_id); + db.run( + 'INSERT OR REPLACE INTO calendar_id_map (jmap, local_id, server_id) VALUES (?, ?, ?)', + [row.jmap, row.record_id, realId], + ); + ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] }); + if (current?.kind === 'destroy') { + writeEvent(db, row.jmap, { shown: event, server: event, hidden: true, gen }); + db.run('UPDATE calendar_pending SET record_id = ?, uid = NULL, attempted = 0 WHERE seq = ?', [realId, row.seq]); + return 'again'; + } + if (current && current.rev !== row.rev) { + const now = JSON.parse(current.patch) as Record; + const patch: Record = {}; + for (const key of new Set([...Object.keys(sent), ...Object.keys(now)])) { + if (stableJson(sent[key]) !== stableJson(now[key])) patch[key] = now[key] ?? null; + } + const base = Object.fromEntries(Object.keys(patch).map((key) => [key, valueAtPointer(event, key) ?? null])); + db.run( + `UPDATE calendar_pending SET kind = 'update', record_id = ?, patch = ?, base = ?, uid = NULL, attempted = 0 WHERE seq = ?`, + [realId, JSON.stringify(patch), JSON.stringify(base), row.seq], + ); + writeEvent(db, row.jmap, { shown: applyPatchObject(event, patch), server: event, gen }); + return 'again'; + } + db.run('DELETE FROM calendar_pending WHERE seq = ?', [row.seq]); + writeEvent(db, row.jmap, { shown: event, gen }); + return 'sent'; + }); +} + +async function sendCreate(ctx: SyncContext, row: PendingRow): Promise { + const payload = JSON.parse(row.patch) as Record; + let realId = row.attempted ? await findByUid(ctx, row.jmap, row.uid) : null; + if (!realId) { + ctx.db.transaction(() => ctx.db.run('UPDATE calendar_pending SET attempted = 1 WHERE seq = ?', [row.seq])); + let result = await sendSet(ctx, row, { create: { c: payload } }); + if (isSeriesKeyRefusal(result.notCreated?.c)) { + const without = withoutOverrideSeriesKeys(payload as Partial); + result = await sendSet(ctx, row, { create: { c: without } }); + } + realId = result.created?.c?.id ?? null; + const refusal = result.notCreated?.c; + // Both Stalwarts refuse a second event with one uid, which also covers a + // create that got through when the device didn't know it had tried. + if (!realId && refusal?.type === 'invalidProperties' && refusal.properties?.includes('uid')) { + realId = await findByUid(ctx, row.jmap, row.uid); + } + if (!realId) { + ctx.db.transaction(() => recordConflict(ctx, row, 'refused', refusal ?? { type: 'serverFail' })); + return 'conflict'; + } + } + const event = await fetchEvent(ctx, row.jmap, realId) ?? ({ ...payload, id: realId } as CalendarObject); + return finishCreate(ctx, row, payload, realId, event); +} + +function finishUpdate(ctx: SyncContext, row: PendingRow, sent: Record): Outcome { + const { db } = ctx; + return db.transaction(() => { + const current = db.get('SELECT * FROM calendar_pending WHERE seq = ?', [row.seq]); + const event = eventRow(db, row.jmap, row.record_id); + ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] }); + if (!current || !event) return 'sent'; + if (current.rev === row.rev) { + db.run('DELETE FROM calendar_pending WHERE seq = ?', [row.seq]); + writeEvent(db, row.jmap, { shown: JSON.parse(event.json) as CalendarObject, gen: event.gen }); + return 'sent'; + } + // A newer write came in during the send. What went out is the server's now, + // so the next sync must not take it for someone else's change. + const server = applyPatchObject(JSON.parse(event.server_json ?? event.json) as CalendarObject, sent); + const base = JSON.parse(current.base) as Record; + for (const key of Object.keys(sent)) if (key in base) base[key] = sent[key]; + db.run('UPDATE calendar_pending SET base = ? WHERE seq = ?', [JSON.stringify(base), row.seq]); + writeEvent(db, row.jmap, { shown: JSON.parse(event.json) as CalendarObject, server, hidden: Boolean(event.hidden), gen: event.gen }); + return 'again'; + }); +} + +async function sendUpdate(ctx: SyncContext, row: PendingRow): Promise { + let patch = JSON.parse(row.patch) as Record; + const id = row.record_id; + let result = await sendSet(ctx, row, { update: { [id]: patch } }); + if (isSeriesKeyRefusal(result.notUpdated?.[id])) { + patch = withoutOverrideSeriesKeys(patch as Partial) as Record; + result = await sendSet(ctx, row, { update: { [id]: patch } }); + } + const refusal = result.notUpdated?.[id]; + if (refusal) { + ctx.db.transaction(() => recordConflict(ctx, row, refusal.type === 'notFound' ? 'destroyedOnServer' : 'refused', refusal)); + return 'conflict'; + } + return finishUpdate(ctx, row, patch); +} + +async function sendDestroy(ctx: SyncContext, row: PendingRow): Promise { + // A create turned delete while its create was out: find out what the server has. + const id = row.uid ? await findByUid(ctx, row.jmap, row.uid) : row.record_id; + const finish = () => ctx.db.transaction(() => { + ctx.db.run('DELETE FROM calendar_pending WHERE seq = ?', [row.seq]); + deleteEventRow(ctx.db, row.jmap, row.record_id); + ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] }); + }); + if (!id) { + finish(); + return 'sent'; + } + const result = await sendSet(ctx, row, { destroy: [id] }); + const refusal = result.notDestroyed?.[id]; + if (refusal && refusal.type !== 'notFound') { + ctx.db.transaction(() => recordConflict(ctx, row, 'refused', refusal)); + return 'conflict'; + } + finish(); + return 'sent'; +} + +/** + * Sends what's pending for one JMAP account. Network failures and a server's + * bad moment are thrown, with the rest still pending. Any other method error + * means this write won't work as it is, so it goes to the conflict list and the + * others still go out. + */ +export async function sendPending(ctx: SyncContext, jmap: string): Promise { + const tally: FlushTally = { sent: 0, conflicts: 0 }; + const finished = new Set(); + for (;;) { + if (ctx.stopped()) return tally; + const row = ctx.db.all('SELECT * FROM calendar_pending WHERE jmap = ? ORDER BY seq', [jmap]) + .find((candidate) => !finished.has(Number(candidate.seq))); + if (!row) return tally; + let outcome: Outcome; + try { + if (row.kind === 'create') outcome = await sendCreate(ctx, row); + else if (row.kind === 'update') outcome = await sendUpdate(ctx, row); + else outcome = await sendDestroy(ctx, row); + } catch (error) { + if (!(error instanceof JmapMethodError) || isPassingError(error)) throw error; + ctx.db.transaction(() => recordConflict(ctx, row, 'refused', { type: error.type, description: error.message })); + outcome = 'conflict'; + } + if (outcome === 'again') continue; + finished.add(Number(row.seq)); + if (outcome === 'sent') tally.sent += 1; + else tally.conflicts += 1; + } +} diff --git a/lib/store/calendar/index.ts b/lib/store/calendar/index.ts new file mode 100644 index 000000000..da9115b78 --- /dev/null +++ b/lib/store/calendar/index.ts @@ -0,0 +1,19 @@ +import type { StoreArea } from '../area'; +import { CALENDAR_METHODS } from './methods'; +import { CALENDAR_MIGRATIONS } from './schema'; +import { calendarSyncer, flushCalendar } from './sync'; +import { listConflicts } from './writes'; + +export const calendarArea: StoreArea = { + name: 'calendar', + migrations: CALENDAR_MIGRATIONS, + methods: CALENDAR_METHODS, + syncers: [calendarSyncer], + calls: { + 'calendar.flush': (ctx) => flushCalendar(ctx), + 'calendar.conflicts': (ctx) => listConflicts(ctx.db), + 'calendar.dismissConflict': (ctx, params: { id: number }) => { + ctx.db.run('DELETE FROM calendar_conflict WHERE id = ?', [params.id]); + }, + }, +}; diff --git a/lib/store/calendar/methods.ts b/lib/store/calendar/methods.ts new file mode 100644 index 000000000..2443584dc --- /dev/null +++ b/lib/store/calendar/methods.ts @@ -0,0 +1,197 @@ +/** + * Calendar calls answered from the store. + * + * Series, not occurrences, in both client modes. A client in synthetic-id mode + * sends `expandRecurrences: true` and gets the masters, with their overrides + * (see projectEvent). hydrateRecurrenceInstances leaves those alone, and + * expandRecurringEvents in the calendar store makes the occurrences, the same as + * for a client in the other mode. Occurrence writes then arrive as overrides on + * the master, which is what a stand-in id and the pending queue can work with. + */ + +import type { CalendarEventFilter } from '@/lib/jmap/types'; +import { SYNTHETIC_ID_PROBE } from '@/lib/recurrence-instances'; +import { MethodError, type MethodContext, type MethodHandler, type MethodResult } from '../area'; +import { listObjects } from '../engine/core-schema'; +import type { SqlValue, StorageAdapter } from '../storage'; +import { boundsOf, matchesFilter, sortEvents, type EventComparator } from './filter'; +import { CALENDARS_CAPABILITY, instantOf, projectEvent, type CalendarObject } from './records'; +import { syntheticIdVerdict } from './sync'; +import { queueCreate, queueDestroy, queueUpdate, resolveId } from './writes'; + +function jmapOf(ctx: MethodContext, args: Record): string { + return (args.accountId as string | undefined) ?? ctx.session.primaryAccounts[CALENDARS_CAPABILITY] ?? ctx.defaultJmap; +} + +function stateOf(db: StorageAdapter, jmap: string, type: string): string | undefined { + return db.get<{ state: string | null }>('SELECT state FROM sync_state WHERE jmap = ? AND type = ?', [jmap, type])?.state ?? undefined; +} + +/** An account never synced has no answer yet. An empty one would read as "no events". */ +function assertSynced(db: StorageAdapter, jmap: string, type: string): void { + const row = db.get<{ state: string | null; phase: string | null }>('SELECT state, phase FROM sync_state WHERE jmap = ? AND type = ?', [jmap, type]); + if (!row?.state && !row?.phase) throw new MethodError('serverUnavailable', `${type} hasn't been kept on this device yet`); +} + +// An event's start_ms reads a floating event in UTC, and a request's bounds are +// in its own zone: up to 14 hours apart. This only narrows, the filter decides. +const ZONE_SLACK_MS = 15 * 3_600_000; + +function candidates(db: StorageAdapter, jmap: string, filter: CalendarEventFilter, zone: string): CalendarObject[] { + const where = ['jmap = ?', 'hidden = 0']; + const params: SqlValue[] = [jmap]; + if (typeof filter.uid === 'string' && !filter.operator) { + where.push('uid = ?'); + params.push(filter.uid); + } + const { after, before } = boundsOf(filter); + const afterMs = after ? instantOf(after, zone) : null; + const beforeMs = before ? instantOf(before, zone) : null; + if (afterMs !== null || beforeMs !== null) { + where.push(`(is_series = 1 OR start_ms IS NULL OR (start_ms < ? AND end_ms >= ?))`); + params.push(beforeMs === null ? Number.MAX_SAFE_INTEGER : beforeMs + ZONE_SLACK_MS); + params.push(afterMs === null ? Number.MIN_SAFE_INTEGER : afterMs - ZONE_SLACK_MS); + } + return db.all<{ json: string }>(`SELECT json FROM calendar_event WHERE ${where.join(' AND ')}`, params) + .map((row) => JSON.parse(row.json) as CalendarObject); +} + +const calendarGet: MethodHandler = (ctx, args) => { + const jmap = jmapOf(ctx, args); + assertSynced(ctx.db, jmap, 'Calendar'); + const ids = args.ids as string[] | null | undefined; + const properties = args.properties as string[] | undefined; + const all = listObjects<{ id: string } & Record>(ctx.db, jmap, 'Calendar'); + const chosen = ids ? all.filter((calendar) => ids.includes(calendar.id)) : all; + const state = stateOf(ctx.db, jmap, 'Calendar'); + return { + accountId: jmap, + ...(state ? { state } : {}), + list: chosen.map((calendar) => (properties + ? Object.fromEntries(['id', ...properties].filter((name) => name in calendar).map((name) => [name, calendar[name]])) + : calendar)), + notFound: ids ? ids.filter((id) => !all.some((calendar) => calendar.id === id)) : [], + }; +}; + +const eventQuery: MethodHandler = (ctx, args) => { + const jmap = jmapOf(ctx, args); + assertSynced(ctx.db, jmap, 'CalendarEvent'); + const filter = (args.filter ?? {}) as CalendarEventFilter; + const zone = typeof args.timeZone === 'string' && args.timeZone ? args.timeZone : 'UTC'; + const { after, before } = boundsOf(filter); + if (args.expandRecurrences && !(after && before)) { + throw new MethodError('invalidArguments', 'expandRecurrences needs both after and before'); + } + const matching = candidates(ctx.db, jmap, filter, zone).filter((object) => matchesFilter(object, filter, zone)); + const sorted = sortEvents(matching, (args.sort ?? []) as EventComparator[], zone); + const position = Math.max(0, Number(args.position ?? 0) || 0); + const limit = Number(args.limit); + const ids = sorted.map((object) => object.id); + return { + accountId: jmap, + // No queryState: the client doesn't read it, and the store has no real one. + canCalculateChanges: false, + position, + ids: ids.slice(position, limit > 0 ? position + limit : undefined), + ...(args.calculateTotal ? { total: ids.length } : {}), + }; +}; + +const eventGet: MethodHandler = (ctx, args) => { + const jmap = jmapOf(ctx, args); + const requested = args.ids as string[] | null | undefined; + // A page's first calendar list carries `ids: []` to check a time zone, also before any sync. + if (!(Array.isArray(requested) && requested.length === 0)) assertSynced(ctx.db, jmap, 'CalendarEvent'); + const properties = (args.properties as string[] | undefined) ?? null; + const zone = typeof args.timeZone === 'string' && args.timeZone ? args.timeZone : 'UTC'; + const list: Record[] = []; + const notFound: string[] = []; + if (requested) { + for (const id of requested) { + const row = ctx.db.get<{ json: string }>('SELECT json FROM calendar_event WHERE jmap = ? AND id = ? AND hidden = 0', [jmap, resolveId(ctx.db, jmap, id)]); + if (row) list.push(projectEvent(JSON.parse(row.json) as CalendarObject, properties, zone)); + else notFound.push(id); + } + } else { + const rows = ctx.db.all<{ json: string }>('SELECT json FROM calendar_event WHERE jmap = ? AND hidden = 0', [jmap]); + for (const row of rows) list.push(projectEvent(JSON.parse(row.json) as CalendarObject, properties, zone)); + } + const state = stateOf(ctx.db, jmap, 'CalendarEvent'); + return { accountId: jmap, ...(state ? { state } : {}), list, notFound }; +}; + +// What a server without synthetic-id writes says. It's the answer here too, for +// an id the store doesn't hold, which is an occurrence still on screen from +// before the connection went: the client catches this (updateOccurrence, +// destroyOccurrence in stores/calendar-store.ts) and writes the change as an +// override on the base event, which the store does hold. +function syntheticRefusal(verb: 'Updating' | 'Deleting'): { type: string; description: string; properties: string[] } { + return { type: 'invalidProperties', description: `${verb} synthetic ids is not yet supported.`, properties: ['id'] }; +} + +const eventSet: MethodHandler = (ctx, args) => { + const jmap = jmapOf(ctx, args); + const send = typeof args.sendSchedulingMessages === 'boolean' ? args.sendSchedulingMessages : undefined; + const created: Record = {}; + const updated: Record = {}; + const destroyed: string[] = []; + const notUpdated: Record = {}; + const notDestroyed: Record = {}; + let wrote = false; + ctx.db.transaction(() => { + for (const [key, event] of Object.entries((args.create ?? {}) as Record>)) { + created[key] = queueCreate(ctx, jmap, event, send); + wrote = true; + } + for (const [id, patch] of Object.entries((args.update ?? {}) as Record>)) { + if (id === SYNTHETIC_ID_PROBE) { + // A client that started offline asks here. Tell it what the server said + // at the last sync, so it behaves the same when the server is back. With + // no answer yet: client-side expansion, which works on every server. + notUpdated[id] = syntheticIdVerdict(ctx, jmap) === true ? { type: 'notFound' } : syntheticRefusal('Updating'); + continue; + } + if (queueUpdate(ctx, jmap, resolveId(ctx.db, jmap, id), patch, send)) { + updated[id] = null; + wrote = true; + } else { + notUpdated[id] = syntheticRefusal('Updating'); + } + } + for (const id of (args.destroy ?? []) as string[]) { + if (queueDestroy(ctx, jmap, resolveId(ctx.db, jmap, id), send)) { + destroyed.push(id); + wrote = true; + } else { + notDestroyed[id] = syntheticRefusal('Deleting'); + } + } + }); + if (wrote) ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] }); + const result: MethodResult = { accountId: jmap }; + if (Object.keys(created).length > 0) result.created = created; + if (Object.keys(updated).length > 0) result.updated = updated; + if (destroyed.length > 0) result.destroyed = destroyed; + if (Object.keys(notUpdated).length > 0) result.notUpdated = notUpdated; + if (Object.keys(notDestroyed).length > 0) result.notDestroyed = notDestroyed; + return result; +}; + +const identityGet: MethodHandler = (ctx, args) => { + const jmap = jmapOf(ctx, args); + const list = listObjects(ctx.db, jmap, 'ParticipantIdentity'); + if (list.length === 0) throw new MethodError('serverUnavailable', 'No participant identities kept yet'); + return { accountId: jmap, list, notFound: [] }; +}; + +export const CALENDAR_METHODS: Readonly> = { + 'Calendar/get': calendarGet, + 'CalendarEvent/query': eventQuery, + 'CalendarEvent/get': eventGet, + 'CalendarEvent/set': eventSet, + 'ParticipantIdentity/get': identityGet, + // Notifications about others' changes wait for the server. + 'CalendarEventNotification/query': (ctx, args) => ({ accountId: jmapOf(ctx, args), canCalculateChanges: false, position: 0, ids: [] }), + 'CalendarEventNotification/get': (ctx, args) => ({ accountId: jmapOf(ctx, args), list: [], notFound: [] }), +}; diff --git a/lib/store/calendar/records.ts b/lib/store/calendar/records.ts new file mode 100644 index 000000000..84cdcb0cc --- /dev/null +++ b/lib/store/calendar/records.ts @@ -0,0 +1,162 @@ +/** How calendar objects are stored and read back. */ + +import { parseISO } from 'date-fns'; +import { isTaskLikeObject } from '@/lib/calendar-component-detection'; +import { parseDurationSeconds } from '@/lib/calendar-event-normalization'; +import { pointerTokenValue } from '@/lib/jmap/patch-pointer'; +import { wallClockFieldsAsUtc, zonedWallTimeToUtc } from '@/lib/recurrence-expansion'; +import type { StoreSession } from '../engine/transport'; +import type { SqlValue, StorageAdapter } from '../storage'; + +export const CALENDARS_CAPABILITY = 'urn:ietf:params:jmap:calendars'; +const PRINCIPALS_OWNER = 'urn:ietf:params:jmap:principals:owner'; + +export type CalendarObject = Record & { id: string }; + +export function calendarUsing(session: StoreSession): string[] { + const using = ['urn:ietf:params:jmap:core', CALENDARS_CAPABILITY]; + if (PRINCIPALS_OWNER in session.capabilities) using.push(PRINCIPALS_OWNER); + return using; +} + +/** Stalwart's default list for Calendar/get leaves out sharing and default alerts. */ +export const CALENDAR_PROPERTIES = [ + 'id', 'name', 'description', 'color', 'sortOrder', 'isSubscribed', 'isVisible', 'isDefault', + 'includeInAvailability', 'defaultAlertsWithTime', 'defaultAlertsWithoutTime', 'timeZone', 'shareWith', 'myRights', +] as const; + +/** + * Everything the client's event and task property lists ask for, in one get. + * No utcStart or utcEnd: Stalwart 1.0 refuses those next to recurrenceOverrides, + * and they depend on the zone of the request, so answers work them out. + */ +export const EVENT_PROPERTIES = [ + 'id', '@type', 'uid', 'calendarIds', 'title', 'description', 'descriptionContentType', 'created', 'updated', + 'sequence', 'start', 'due', 'duration', 'timeZone', 'showWithoutTime', 'status', 'freeBusyStatus', 'privacy', + 'color', 'keywords', 'categories', 'locale', 'replyTo', 'organizerCalendarAddress', 'participants', + 'mayInviteSelf', 'mayInviteOthers', 'hideAttendees', 'recurrenceId', 'recurrenceIdTimeZone', 'recurrenceRule', + 'excludedRecurrenceRule', 'recurrenceOverrides', 'useDefaultAlerts', 'alerts', 'locations', 'virtualLocations', + 'links', 'relatedTo', 'isDraft', 'isOrigin', 'baseEventId', 'progress', 'priority', 'percentComplete', +] as const; + +export function isSeries(object: Record): boolean { + const rule = object.recurrenceRule ?? object.recurrenceRules; + return Array.isArray(rule) ? rule.length > 0 : Boolean(rule); +} + +/** The value a PatchObject key points at. */ +export function valueAtPointer(object: unknown, pointer: string): unknown { + let value = object; + for (const token of pointer.split('/')) { + if (value === null || typeof value !== 'object') return undefined; + value = (value as Record)[pointerTokenValue(token)]; + } + return value; +} + +/** A LocalDateTime without any `Z` or offset: Stalwart drops those instead of honouring them. */ +export function wallClock(value: string): string { + return value.replace(/(?:Z|[+-]\d{2}:?\d{2})$/, ''); +} + +/** The instant a wall clock names in `zone`. A zone the runtime doesn't know reads as UTC, as 0.16 does. */ +export function instantOf(value: string, zone: string | null | undefined): number | null { + const wall = parseISO(wallClock(value)); + if (Number.isNaN(wall.getTime())) return null; + const zoned = zone ? zonedWallTimeToUtc(wall, zone) : null; + return zoned ? zoned.getTime() : wallClockFieldsAsUtc(wall); +} + +/** An object's start and end as instants. A floating one is read in `zone`. A task without a start goes by its due. */ +export function instantsOf(object: Record, zone: string | null | undefined): { start: number | null; end: number | null } { + const at = typeof object.start === 'string' ? object.start : typeof object.due === 'string' ? object.due : null; + if (!at) return { start: null, end: null }; + const start = instantOf(at, typeof object.timeZone === 'string' ? object.timeZone : zone); + if (start === null) return { start: null, end: null }; + const seconds = typeof object.duration === 'string' ? parseDurationSeconds(object.duration) : null; + return { start, end: start + (seconds ?? 0) * 1000 }; +} + +function utcString(ms: number): string { + return new Date(ms).toISOString().replace('.000Z', 'Z'); +} + +/** + * An object as a CalendarEvent/get asked for it. utcStart and utcEnd are worked + * out here, so they hold for the request's zone and for events edited offline. + * A series keeps its overrides even when not asked for: the client's + * synthetic-id mode reads occurrences without them, and it gets series from the + * store, which it can only expand correctly with them. + */ +export function projectEvent(object: CalendarObject, properties: readonly string[] | null, zone: string | null | undefined): Record { + if (!properties) return { ...object }; + const out: Record = { id: object.id }; + for (const name of properties) { + if (name === 'utcStart' || name === 'utcEnd') continue; + if (name in object) out[name] = object[name]; + } + if (properties.includes('utcStart') || properties.includes('utcEnd')) { + const { start, end } = typeof object.start === 'string' ? instantsOf(object, zone) : { start: null, end: null }; + if (start !== null && properties.includes('utcStart')) out.utcStart = utcString(start); + if (end !== null && properties.includes('utcEnd')) out.utcEnd = utcString(end); + } + if (properties.includes('recurrenceRule') && object.recurrenceOverrides && isSeries(object)) { + out.recurrenceOverrides = object.recurrenceOverrides; + } + return out; +} + +export interface EventRow { + jmap: string; + id: string; + json: string; + server_json: string | null; + hidden: number; + gen: number; +} + +export function eventRow(db: StorageAdapter, jmap: string, id: string): EventRow | undefined { + return db.get('SELECT jmap, id, json, server_json, hidden, gen FROM calendar_event WHERE jmap = ? AND id = ?', [jmap, id]); +} + +export function currentGen(db: StorageAdapter, jmap: string): number { + return Number(db.get<{ gen: number | null }>('SELECT MAX(gen) AS gen FROM calendar_event WHERE jmap = ?', [jmap])?.gen ?? 0); +} + +export interface EventWrite { + /** What answers show. */ + shown: CalendarObject; + /** The server's copy while a write is pending, otherwise null. */ + server?: CalendarObject | null; + hidden?: boolean; + gen: number; +} + +export function writeEvent(db: StorageAdapter, jmap: string, write: EventWrite): void { + const { shown } = write; + const { start, end } = instantsOf(shown, 'UTC'); + const params: SqlValue[] = [ + jmap, + shown.id, + typeof shown.uid === 'string' ? shown.uid : null, + isTaskLikeObject(shown) ? 1 : 0, + isSeries(shown) ? 1 : 0, + start, + end, + JSON.stringify(shown), + write.server ? JSON.stringify(write.server) : null, + write.hidden ? 1 : 0, + write.gen, + ]; + db.run( + `INSERT INTO calendar_event (jmap, id, uid, is_task, is_series, start_ms, end_ms, json, server_json, hidden, gen) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (jmap, id) DO UPDATE SET uid = excluded.uid, is_task = excluded.is_task, is_series = excluded.is_series, start_ms = excluded.start_ms, + end_ms = excluded.end_ms, json = excluded.json, server_json = excluded.server_json, hidden = excluded.hidden, gen = excluded.gen`, + params, + ); +} + +export function deleteEventRow(db: StorageAdapter, jmap: string, id: string): void { + db.run('DELETE FROM calendar_event WHERE jmap = ? AND id = ?', [jmap, id]); +} diff --git a/lib/store/calendar/reminders.ts b/lib/store/calendar/reminders.ts new file mode 100644 index 000000000..4ab5e3227 --- /dev/null +++ b/lib/store/calendar/reminders.ts @@ -0,0 +1,160 @@ +/** + * Which alerts fire in a time window, from what's stored. No DOM, no stores, no + * React: desktop reminders call this while the window is hidden. + * + * Keys use the master's id and the fire time, never an occurrence id. Synthetic + * ids can change, and browser occurrences don't exist on the server. A moved + * occurrence gets a new key, as it should: it's a new time to remind. + */ + +import { format, parseISO } from 'date-fns'; +import { buildAlertKey, computeFireTime, computeTaskFireTime, getEffectiveAlerts, parseAlertOffset } from '@/lib/calendar-alerts'; +import { isTaskLikeObject } from '@/lib/calendar-component-detection'; +import { normalizeCalendarEventLike, parseDurationSeconds } from '@/lib/calendar-event-normalization'; +import type { Calendar, CalendarEvent, CalendarEventAlert, CalendarTask } from '@/lib/jmap/types'; +import { expandRecurringEvents } from '@/lib/recurrence-expansion'; +import { listObjects } from '../engine/core-schema'; +import type { StorageAdapter } from '../storage'; +import { instantsOf, isSeries, type CalendarObject } from './records'; + +export interface UpcomingAlert { + /** Stable for this alert at this time. Use it to remember what was shown. */ + key: string; + jmap: string; + kind: 'event' | 'task'; + /** The stored event or task: the master of a series. */ + baseEventId: string; + /** Which occurrence of the series, when it's one. */ + recurrenceId: string | null; + alertId: string; + fireTimeMs: number; + title: string; + /** The start as a wall clock, or a task's due when it has no start. */ + start: string | null; + timeZone: string | null; + calendarId: string | null; + calendarName: string | null; +} + +export interface UpcomingAlertsOptions { + /** Alerts firing at or after this. */ + from: number; + /** Alerts firing before this. */ + to: number; + /** Reads floating events. The user's zone. */ + timeZone: string; +} + +type AlertMap = Record | null | undefined; + +// An alert fires up to its offset away from its occurrence, and the occurrence's +// wall clock is up to 26 hours from the instant. Expand wide, filter by instant. +function offsetReachMs(alerts: AlertMap): number { + let reach = 0; + for (const alert of Object.values(alerts ?? {})) { + if (alert.trigger?.['@type'] === 'AbsoluteTrigger') continue; + reach = Math.max(reach, Math.abs(parseAlertOffset(alert.trigger?.offset ?? '') ?? 0)); + } + return reach; +} + +function alertsInOverrides(event: CalendarEvent): AlertMap[] { + return Object.values(event.recurrenceOverrides ?? {}).map((override) => (override as { alerts?: AlertMap }).alerts); +} + +const ZONE_REACH_MS = 27 * 3_600_000; + +function occurrencesNear(event: CalendarEvent, calendars: Calendar[], options: UpcomingAlertsOptions): CalendarEvent[] { + const reach = ZONE_REACH_MS + + Math.max(offsetReachMs(getEffectiveAlerts(event, calendars)), ...alertsInOverrides(event).map(offsetReachMs), 0) + + (parseDurationSeconds(event.duration) ?? 0) * 1000; + const stamp = (ms: number) => format(new Date(ms), "yyyy-MM-dd'T'HH:mm:ss"); + return expandRecurringEvents([event], stamp(options.from - reach), stamp(options.to + reach)); +} + +function withInstants(object: CalendarObject, zone: string): CalendarObject { + const { start, end } = instantsOf(object, zone); + return { + ...object, + ...(start !== null ? { utcStart: new Date(start).toISOString() } : {}), + ...(end !== null ? { utcEnd: new Date(end).toISOString() } : {}), + }; +} + +function firstCalendar(calendarIds: unknown, calendars: Calendar[]): { id: string | null; name: string | null } { + const id = Object.keys((calendarIds ?? {}) as Record)[0] ?? null; + return { id, name: calendars.find((calendar) => calendar.id === id)?.name ?? null }; +} + +/** Every `display` alert of the stored events and tasks that fires in the window, soonest first. */ +export function listUpcomingAlerts(db: StorageAdapter, options: UpcomingAlertsOptions): UpcomingAlert[] { + const found = new Map(); + const add = (alert: UpcomingAlert) => { + if (alert.fireTimeMs >= options.from && alert.fireTimeMs < options.to) found.set(alert.key, alert); + }; + const rows = db.all<{ jmap: string; json: string }>('SELECT jmap, json FROM calendar_event WHERE hidden = 0'); + const calendarsOf = new Map(); + for (const row of rows) { + if (!calendarsOf.has(row.jmap)) calendarsOf.set(row.jmap, listObjects(db, row.jmap, 'Calendar')); + const calendars = calendarsOf.get(row.jmap)!; + const object = JSON.parse(row.json) as CalendarObject; + const calendar = firstCalendar(object.calendarIds, calendars); + + if (isTaskLikeObject(object)) { + const task = object as unknown as CalendarTask; + if (task.progress === 'completed' || task.progress === 'cancelled') continue; + for (const [alertId, alert] of Object.entries(task.alerts ?? {})) { + if (alert.action !== 'display' || alert.acknowledged) continue; + const fireTimeMs = computeTaskFireTime(task, alert.trigger); + if (fireTimeMs === null) continue; + add({ + key: buildAlertKey(object.id, alertId, fireTimeMs), + jmap: row.jmap, + kind: 'task', + baseEventId: object.id, + recurrenceId: null, + alertId, + fireTimeMs, + title: task.title ?? '', + start: task.start ?? task.due ?? null, + timeZone: task.timeZone ?? null, + calendarId: calendar.id, + calendarName: calendar.name, + }); + } + continue; + } + + const master = normalizeCalendarEventLike(withInstants(object, options.timeZone) as unknown as CalendarEvent); + if (typeof master.start !== 'string' || Number.isNaN(parseISO(master.start).getTime())) continue; + const series = isSeries(object); + const occurrences = series ? occurrencesNear(master, calendars, options) : [master]; + // A fixed time doesn't need an occurrence nearby. + const candidates = series ? [master, ...occurrences] : occurrences; + for (const occurrence of candidates) { + if (occurrence.status === 'cancelled') continue; + const isMasterOfSeries = series && occurrence === master; + for (const [alertId, alert] of Object.entries(getEffectiveAlerts(occurrence, calendars) ?? {})) { + if (alert.action !== 'display' || alert.acknowledged) continue; + if (isMasterOfSeries && alert.trigger['@type'] !== 'AbsoluteTrigger') continue; + const fireTimeMs = computeFireTime(occurrence, alert.trigger); + if (fireTimeMs === null) continue; + add({ + key: buildAlertKey(object.id, alertId, fireTimeMs), + jmap: row.jmap, + kind: 'event', + baseEventId: object.id, + recurrenceId: occurrence.recurrenceId ?? null, + alertId, + fireTimeMs, + title: occurrence.title ?? '', + start: occurrence.start, + timeZone: occurrence.timeZone ?? null, + calendarId: calendar.id, + calendarName: calendar.name, + }); + } + } + } + return [...found.values()].sort((a, b) => a.fireTimeMs - b.fireTimeMs || a.key.localeCompare(b.key)); +} diff --git a/lib/store/calendar/schema.ts b/lib/store/calendar/schema.ts new file mode 100644 index 000000000..1d56d505e --- /dev/null +++ b/lib/store/calendar/schema.ts @@ -0,0 +1,77 @@ +import type { Migration } from '../storage'; + +export const CALENDAR_MIGRATIONS: readonly Migration[] = [ + { + id: 500, + name: 'calendar', + sql: ` + CREATE TABLE calendar_event ( + jmap TEXT NOT NULL, + id TEXT NOT NULL, + uid TEXT, + is_task INTEGER NOT NULL DEFAULT 0, + -- 1 for a series master. Occurrences are never stored: /changes only + -- lists masters, and the client expands them. + is_series INTEGER NOT NULL DEFAULT 0, + -- Instants to narrow a query with. A floating event is read in UTC here. + start_ms INTEGER, + end_ms INTEGER, + -- What answers show: the server's copy with any pending write applied. + json TEXT NOT NULL, + -- The server's copy while a write is pending on it, otherwise NULL. + server_json TEXT, + -- 1 while a delete is pending. + hidden INTEGER NOT NULL DEFAULT 0, + gen INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (jmap, id) + ) WITHOUT ROWID; + CREATE INDEX calendar_event_uid ON calendar_event (jmap, uid); + CREATE INDEX calendar_event_start ON calendar_event (jmap, start_ms); + CREATE INDEX calendar_event_end ON calendar_event (jmap, end_ms); + + -- One row per event: a later write merges into it (see writes.ts). + CREATE TABLE calendar_pending ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + jmap TEXT NOT NULL, + record_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('create', 'update', 'destroy')), + -- create: the whole event. update: a PatchObject. destroy: empty. + patch TEXT NOT NULL, + -- The server's value at each patched path when it was queued. + base TEXT NOT NULL DEFAULT '{}', + -- For a create: how to find it again after a lost response. + uid TEXT, + -- sendSchedulingMessages: NULL when no write named it. + send_scheduling INTEGER, + -- 1 once a create went out, so the server may hold it already. + attempted INTEGER NOT NULL DEFAULT 0, + -- Moves on every merge, to see a write that came in during a send. + rev INTEGER NOT NULL DEFAULT 0, + queued_at INTEGER NOT NULL, + UNIQUE (jmap, record_id) + ); + + -- What a stand-in id became, for a page still holding the stand-in. + CREATE TABLE calendar_id_map ( + jmap TEXT NOT NULL, + local_id TEXT NOT NULL, + server_id TEXT NOT NULL, + PRIMARY KEY (jmap, local_id) + ) WITHOUT ROWID; + + CREATE TABLE calendar_conflict ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + jmap TEXT NOT NULL, + record_id TEXT NOT NULL, + kind TEXT NOT NULL, + reason TEXT NOT NULL, + error_type TEXT, + description TEXT, + title TEXT, + local TEXT NOT NULL, + server TEXT, + created_at INTEGER NOT NULL + ); + `, + }, +]; diff --git a/lib/store/calendar/sync.ts b/lib/store/calendar/sync.ts new file mode 100644 index 000000000..8463a0896 --- /dev/null +++ b/lib/store/calendar/sync.ts @@ -0,0 +1,264 @@ +/** + * Calendar sync for one JMAP account: calendars, the last participant + * identities, then every event and task. + * + * Masters only. CalendarEvent/changes lists nothing else on either Stalwart, an + * instance edit shows up as `updated: [master]`, and 1.0's expanded queries + * can't give changes and refuse long ranges. Occurrences are made when read. + */ + +import { isTaskLikeObject } from '@/lib/calendar-component-detection'; +import { SYNTHETIC_ID_PROBE } from '@/lib/recurrence-instances'; +import type { StoreContext, SyncContext, Syncer } from '../area'; +import { runChangesLoop } from '../engine/changes'; +import { metaGet, metaSet, putObject } from '../engine/core-schema'; +import { JmapMethodError, responseFor, TransportError } from '../engine/transport'; +import type { StorageAdapter } from '../storage'; +import { isPassingError, sendPending, type FlushTally } from './flush'; +import { + CALENDAR_PROPERTIES, + CALENDARS_CAPABILITY, + calendarUsing, + currentGen, + EVENT_PROPERTIES, + eventRow, + instantsOf, + isSeries, + type CalendarObject, +} from './records'; +import { applyServerDestroy, applyServerEvent, isStandIn, pendingFor } from './writes'; + +const PAGE = 500; +/** How far back single events are kept. A series stays whatever its start, a task whatever its date. */ +const PAST_WINDOW_MS = 2 * 366 * 86_400_000; + +function syntheticIdsKey(jmap: string): string { + return `calendar.syntheticIds.${jmap}`; +} + +/** + * What this account's server said to the synthetic-id probe at the last sync. + * Null before it ever answered. A client that probed online holds its verdict + * for life and never asks again; only one started offline asks the store. + */ +export function syntheticIdVerdict(ctx: StoreContext, jmap: string): boolean | null { + const value = metaGet(ctx.db, syntheticIdsKey(jmap)); + return value === null ? null : value === '1'; +} + +// A shared account probed on suspicion that grants no calendar access (the +// client treats these the same way). +function isAccessDenial(error: unknown): boolean { + return error instanceof JmapMethodError + && (error.type === 'forbidden' || error.type === 'accountNotFound' || error.type === 'accountNotSupportedByMethod'); +} + +/** False when the account grants no calendar access. */ +async function syncCalendars(ctx: SyncContext, jmap: string): Promise { + const response = await ctx.transport.request([ + ['Calendar/get', { accountId: jmap, ids: null, properties: CALENDAR_PROPERTIES }, 'c'], + ['CalendarEvent/set', { accountId: jmap, update: { [SYNTHETIC_ID_PROBE]: {} } }, 'p'], + ['ParticipantIdentity/get', { accountId: jmap }, 'i'], + ], calendarUsing(ctx.session)); + let result: { list: Array<{ id: string }>; state: unknown }; + try { + result = responseFor(response, 'c')[1]; + } catch (error) { + if (isAccessDenial(error)) return false; + throw error; + } + const probe = response.methodResponses.find(([, , id]) => id === 'p'); + const identities = response.methodResponses.find(([, , id]) => id === 'i'); + const before = ctx.syncState(jmap, 'Calendar').state; + ctx.db.transaction(() => { + ctx.db.run("DELETE FROM object WHERE jmap = ? AND type = 'Calendar'", [jmap]); + for (const calendar of result.list) putObject(ctx.db, jmap, 'Calendar', calendar.id, calendar); + ctx.setSyncState(jmap, 'Calendar', { state: String(result.state) }); + if (probe?.[0] === 'CalendarEvent/set') { + const failure = probe[1]?.notUpdated?.[SYNTHETIC_ID_PROBE] as { type?: string } | undefined; + metaSet(ctx.db, syntheticIdsKey(jmap), failure?.type === 'notFound' ? '1' : '0'); + } + // Only ever read as "the last value". 0.16 has no usable /changes for it. + if (identities?.[0] === 'ParticipantIdentity/get') { + ctx.db.run("DELETE FROM object WHERE jmap = ? AND type = 'ParticipantIdentity'", [jmap]); + for (const identity of identities[1].list as Array<{ id: string }>) putObject(ctx.db, jmap, 'ParticipantIdentity', identity.id, identity); + } + }); + if (before !== String(result.state)) ctx.emit({ type: 'changed', account: ctx.account, collections: ['Calendar'] }); + return true; +} + +function inWindow(ctx: SyncContext, event: CalendarObject): boolean { + if (isSeries(event) || isTaskLikeObject(event)) return true; + const { end } = instantsOf(event, 'UTC'); + return end === null || end >= ctx.now() - PAST_WINDOW_MS; +} + +interface ListCursor { position: number; gen: number; captured: string } + +async function startFullList(ctx: SyncContext, jmap: string): Promise { + const response = await ctx.transport.request([['CalendarEvent/get', { accountId: jmap, ids: [], properties: ['id'] }, 's']], calendarUsing(ctx.session)); + const captured = String(responseFor(response, 's')[1].state); + const cursor: ListCursor = { position: 0, gen: currentGen(ctx.db, jmap) + 1, captured }; + ctx.db.transaction(() => ctx.setSyncState(jmap, 'CalendarEvent', { state: null, phase: 'list', cursor: { ...cursor } })); + return cursor; +} + +function storeEvent(ctx: SyncContext, jmap: string, event: CalendarObject, gen: number): void { + // An old event changing doesn't pull it back into the window. + if (!inWindow(ctx, event) && !eventRow(ctx.db, jmap, event.id) && !pendingFor(ctx.db, jmap, event.id)) return; + applyServerEvent(ctx, jmap, event, gen); +} + +/** Pages through every object: the query has no filter for tasks, and old series must stay. False when stopped. */ +async function runFullList(ctx: SyncContext, jmap: string, start: ListCursor): Promise { + const cursor = { ...start }; + for (;;) { + if (ctx.stopped()) return false; + const response = await ctx.transport.request([ + ['CalendarEvent/query', { accountId: jmap, position: cursor.position, limit: PAGE }, 'q'], + ['CalendarEvent/get', { + accountId: jmap, + '#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' }, + properties: EVENT_PROPERTIES, + }, 'g'], + ], calendarUsing(ctx.session)); + const ids = responseFor(response, 'q')[1].ids as string[]; + const list = responseFor(response, 'g')[1].list as CalendarObject[]; + const done = ids.length < PAGE; + cursor.position += ids.length; + ctx.db.transaction(() => { + for (const event of list) storeEvent(ctx, jmap, event, cursor.gen); + if (done) { + // Whatever this pass didn't see is gone from the server. + const stale = ctx.db.all<{ id: string }>('SELECT id FROM calendar_event WHERE jmap = ? AND gen < ?', [jmap, cursor.gen]); + for (const { id } of stale) if (!isStandIn(id)) applyServerDestroy(ctx, jmap, id); + ctx.setSyncState(jmap, 'CalendarEvent', { state: cursor.captured, phase: null, cursor: null }); + } else { + ctx.setSyncState(jmap, 'CalendarEvent', { cursor: { ...cursor } }); + } + }); + ctx.emit({ type: 'progress', account: ctx.account, area: 'calendar', phase: 'list', done: cursor.position, total: null }); + if (done) { + ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] }); + return true; + } + } +} + +async function syncDelta(ctx: SyncContext, jmap: string, since: string, target: string | null): Promise<'done' | 'resync' | 'stopped'> { + const outcome = await runChangesLoop({ + transport: ctx.transport, + type: 'CalendarEvent', + jmap, + using: calendarUsing(ctx.session), + since, + target, + stopped: ctx.stopped, + apply: async (page) => { + const ids = [...page.created, ...page.updated]; + const fetched: CalendarObject[] = []; + for (let i = 0; i < ids.length; i += PAGE) { + const response = await ctx.transport.request([['CalendarEvent/get', { + accountId: jmap, + ids: ids.slice(i, i + PAGE), + properties: EVENT_PROPERTIES, + }, 'g']], calendarUsing(ctx.session)); + fetched.push(...responseFor(response, 'g')[1].list as CalendarObject[]); + } + ctx.db.transaction(() => { + const gen = currentGen(ctx.db, jmap); + for (const event of fetched) storeEvent(ctx, jmap, event, gen); + for (const id of page.destroyed) applyServerDestroy(ctx, jmap, id); + ctx.setSyncState(jmap, 'CalendarEvent', { state: page.newState }); + }); + if (ids.length + page.destroyed.length > 0) ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] }); + }, + }); + if (outcome.outcome === 'resync') return 'resync'; + return outcome.outcome === 'stopped' ? 'stopped' : 'done'; +} + +/** False when stopped before catching up. */ +async function syncEvents(ctx: SyncContext, jmap: string): Promise { + const sync = ctx.syncState(jmap, 'CalendarEvent'); + let cursor = sync.phase === 'list' ? sync.cursor as unknown as ListCursor : null; + if (!cursor && !sync.state) cursor = await startFullList(ctx, jmap); + if (!cursor && sync.state) { + const outcome = await syncDelta(ctx, jmap, sync.state, sync.target); + if (outcome === 'stopped') return false; + if (outcome === 'resync') cursor = await startFullList(ctx, jmap); + } + if (cursor) { + if (!(await runFullList(ctx, jmap, cursor))) return false; + // What changed while paging. + const after = ctx.syncState(jmap, 'CalendarEvent'); + if (after.state && (await syncDelta(ctx, jmap, after.state, after.target)) === 'stopped') return false; + } + return true; +} + +/** Single events that aged out of the window. The window moves on without any change on the server. */ +function pruneOld(ctx: SyncContext, jmap: string): void { + ctx.db.run( + `DELETE FROM calendar_event WHERE jmap = ? AND is_series = 0 AND is_task = 0 AND end_ms IS NOT NULL AND end_ms < ? + AND NOT EXISTS (SELECT 1 FROM calendar_pending p WHERE p.jmap = calendar_event.jmap AND p.record_id = calendar_event.id)`, + [jmap, ctx.now() - PAST_WINDOW_MS], + ); +} + +// A sync and a flush must not interleave. Our own write coming back through +// /changes while it's still pending would read as someone else's change. +const queues = new WeakMap>(); + +function oneAtATime(db: StorageAdapter, work: () => Promise): Promise { + const next = (queues.get(db) ?? Promise.resolve()).catch(() => {}).then(work); + queues.set(db, next); + return next; +} + +async function syncAndSend(ctx: SyncContext, jmap: string): Promise { + const none: FlushTally = { sent: 0, conflicts: 0 }; + if (!(await syncEvents(ctx, jmap)) || ctx.stopped()) return none; + const tally = await sendPending(ctx, jmap); + if (tally.sent > 0 && !ctx.stopped()) await syncEvents(ctx, jmap); + return tally; +} + +export const calendarSyncer: Syncer = { + name: 'calendar', + capability: CALENDARS_CAPABILITY, + async run(ctx, jmap) { + if (!(await syncCalendars(ctx, jmap)) || ctx.stopped()) return; + await oneAtATime(ctx.db, () => syncAndSend(ctx, jmap)); + pruneOld(ctx, jmap); + }, +}; + +export interface FlushResult extends FlushTally { + /** Writes still waiting: the network went away, or the server had a bad moment. */ + pending: number; +} + +/** + * `calendar.flush`: catches up with the server, so a write that collides with + * a change there is found, then sends every pending write. Doesn't throw for a + * lost connection: what wasn't sent stays pending. + */ +export function flushCalendar(ctx: SyncContext): Promise { + return oneAtATime(ctx.db, async () => { + const accounts = ctx.db.all<{ jmap: string }>('SELECT DISTINCT jmap FROM calendar_pending').map((row) => row.jmap); + const total: FlushTally = { sent: 0, conflicts: 0 }; + try { + for (const jmap of accounts) { + const tally = await syncAndSend(ctx, jmap); + total.sent += tally.sent; + total.conflicts += tally.conflicts; + } + } catch (error) { + if (!(error instanceof TransportError) && !isPassingError(error)) throw error; + } + const pending = Number(ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM calendar_pending')?.n ?? 0); + return { ...total, pending }; + }); +} diff --git a/lib/store/calendar/writes.ts b/lib/store/calendar/writes.ts new file mode 100644 index 000000000..80d6b005b --- /dev/null +++ b/lib/store/calendar/writes.ts @@ -0,0 +1,259 @@ +/** + * Calendar writes made while the server can't be reached. + * + * Each event has at most one pending row. A later write merges into it, so a + * flush sends one change per event: edit + edit is one patch, create + edit is + * one create, create + delete is nothing, edit + delete is a delete. + * + * Answers read calendar_event.json, which is the server's copy with the pending + * write applied. server_json keeps the server's copy underneath, so sync can + * move it on and reapply the write, or notice that the two collide. + */ + +import { applyPatchObject } from '@/lib/jmap/patch-object'; +import { mergeOverrideEntry, stableJson } from '@/lib/recurrence-instances'; +import type { StoreContext } from '../area'; +import type { StorageAdapter } from '../storage'; +import { currentGen, deleteEventRow, eventRow, valueAtPointer, writeEvent, type CalendarObject, type EventRow } from './records'; + +export interface PendingRow { + seq: number; + jmap: string; + record_id: string; + kind: 'create' | 'update' | 'destroy'; + patch: string; + base: string; + uid: string | null; + send_scheduling: number | null; + attempted: number; + rev: number; + queued_at: number; +} + +export type ConflictReason = 'refused' | 'changedOnServer' | 'destroyedOnServer'; + +export interface CalendarConflict { + id: number; + jmap: string; + recordId: string; + kind: PendingRow['kind']; + reason: ConflictReason; + errorType: string | null; + description: string | null; + title: string | null; + /** The write that wasn't made: the whole event for a create, otherwise the patch. */ + local: Record; + /** The event as the server has it, when it still does. */ + server: Record | null; + createdAt: number; +} + +const STAND_IN_PREFIX = 'local-'; + +export function isStandIn(id: string): boolean { + return id.startsWith(STAND_IN_PREFIX); +} + +export function pendingFor(db: StorageAdapter, jmap: string, id: string): PendingRow | undefined { + return db.get('SELECT * FROM calendar_pending WHERE jmap = ? AND record_id = ?', [jmap, id]); +} + +/** What a stand-in id became, once its create reached the server. */ +export function resolveId(db: StorageAdapter, jmap: string, id: string): string { + if (!isStandIn(id)) return id; + return db.get<{ server_id: string }>('SELECT server_id FROM calendar_id_map WHERE jmap = ? AND local_id = ?', [jmap, id])?.server_id ?? id; +} + +function mergedScheduling(old: number | null, next: boolean | undefined): number | null { + if (next === undefined) return old; + return old === 1 || next ? 1 : 0; +} + +function serverCopy(row: EventRow): CalendarObject { + return JSON.parse(row.server_json ?? row.json) as CalendarObject; +} + +function changed(ctx: StoreContext, ...collections: string[]): void { + ctx.emit({ type: 'changed', account: ctx.account, collections }); +} + +/** Creates a local event under a stand-in id. The uid is what finds it again after a lost response. */ +export function queueCreate(ctx: StoreContext, jmap: string, event: Record, sendScheduling: boolean | undefined): { id: string; uid: string } { + const id = `${STAND_IN_PREFIX}${crypto.randomUUID()}`; + const uid = typeof event.uid === 'string' && event.uid ? event.uid : crypto.randomUUID(); + const payload = { '@type': 'Event', ...event, uid }; + ctx.db.run( + `INSERT INTO calendar_pending (jmap, record_id, kind, patch, uid, send_scheduling, queued_at) VALUES (?, ?, 'create', ?, ?, ?, ?)`, + [jmap, id, JSON.stringify(payload), uid, sendScheduling === undefined ? null : Number(sendScheduling), ctx.now()], + ); + writeEvent(ctx.db, jmap, { shown: { ...payload, id, isOrigin: true }, gen: currentGen(ctx.db, jmap) }); + return { id, uid }; +} + +/** False when the store has no such event. */ +export function queueUpdate(ctx: StoreContext, jmap: string, id: string, patch: Record, sendScheduling: boolean | undefined): boolean { + const row = eventRow(ctx.db, jmap, id); + if (!row || row.hidden) return false; + const pending = pendingFor(ctx.db, jmap, id); + const gen = row.gen; + if (pending?.kind === 'create') { + const payload = applyPatchObject(JSON.parse(pending.patch) as Record, patch); + ctx.db.run( + 'UPDATE calendar_pending SET patch = ?, send_scheduling = ?, rev = rev + 1 WHERE seq = ?', + [JSON.stringify(payload), mergedScheduling(pending.send_scheduling, sendScheduling), pending.seq], + ); + writeEvent(ctx.db, jmap, { shown: { ...payload, id, isOrigin: true } as CalendarObject, gen }); + return true; + } + const server = serverCopy(row); + const earlier = pending ? JSON.parse(pending.patch) as Record : {}; + const earlierBase = pending ? JSON.parse(pending.base) as Record : {}; + const merged = mergeOverrideEntry(earlier, patch); + const base: Record = {}; + for (const key of Object.keys(merged)) { + base[key] = key in earlierBase ? earlierBase[key] : valueAtPointer(server, key) ?? null; + } + if (pending) { + ctx.db.run( + 'UPDATE calendar_pending SET patch = ?, base = ?, send_scheduling = ?, rev = rev + 1 WHERE seq = ?', + [JSON.stringify(merged), JSON.stringify(base), mergedScheduling(pending.send_scheduling, sendScheduling), pending.seq], + ); + } else { + ctx.db.run( + `INSERT INTO calendar_pending (jmap, record_id, kind, patch, base, send_scheduling, queued_at) VALUES (?, ?, 'update', ?, ?, ?, ?)`, + [jmap, id, JSON.stringify(merged), JSON.stringify(base), sendScheduling === undefined ? null : Number(sendScheduling), ctx.now()], + ); + } + writeEvent(ctx.db, jmap, { shown: applyPatchObject(server, merged), server, gen }); + return true; +} + +/** False when the store has no such event. */ +export function queueDestroy(ctx: StoreContext, jmap: string, id: string, sendScheduling: boolean | undefined): boolean { + const row = eventRow(ctx.db, jmap, id); + if (!row || row.hidden) return false; + const pending = pendingFor(ctx.db, jmap, id); + if (pending?.kind === 'create' && !pending.attempted) { + // It never left the device. + ctx.db.run('DELETE FROM calendar_pending WHERE seq = ?', [pending.seq]); + deleteEventRow(ctx.db, jmap, id); + return true; + } + if (pending) { + // A create that went out stays findable by its uid, so the delete can follow it. + ctx.db.run( + `UPDATE calendar_pending SET kind = 'destroy', patch = '{}', base = '{}', send_scheduling = ?, rev = rev + 1 WHERE seq = ?`, + [mergedScheduling(pending.send_scheduling, sendScheduling), pending.seq], + ); + } else { + ctx.db.run( + `INSERT INTO calendar_pending (jmap, record_id, kind, patch, send_scheduling, queued_at) VALUES (?, ?, 'destroy', '{}', ?, ?)`, + [jmap, id, sendScheduling === undefined ? null : Number(sendScheduling), ctx.now()], + ); + } + const shown = JSON.parse(row.json) as CalendarObject; + writeEvent(ctx.db, jmap, { + shown, + server: pending?.kind === 'create' ? null : serverCopy(row), + hidden: true, + gen: row.gen, + }); + return true; +} + +/** Whether the server moved a value this patch writes, to something other than what the patch writes. */ +function collides(pending: PendingRow, server: Record): boolean { + const base = JSON.parse(pending.base) as Record; + const patch = JSON.parse(pending.patch) as Record; + return Object.keys(patch).some((key) => { + const now = stableJson(valueAtPointer(server, key)); + return now !== stableJson(base[key]) && now !== stableJson(patch[key]); + }); +} + +/** + * Puts a write on the conflict list and takes it off the event, which goes back + * to the server's copy. A refused create leaves nothing behind. + */ +export function recordConflict( + ctx: StoreContext, + pending: PendingRow, + reason: ConflictReason, + error?: { type?: string; description?: string }, +): void { + const { db } = ctx; + const row = eventRow(db, pending.jmap, pending.record_id); + const shown = row ? JSON.parse(row.json) as Record : null; + const known = row && pending.kind !== 'create' && !(pending.kind === 'destroy' && pending.uid); + const server = known && reason !== 'destroyedOnServer' ? serverCopy(row) : null; + db.run( + `INSERT INTO calendar_conflict (jmap, record_id, kind, reason, error_type, description, title, local, server, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + pending.jmap, pending.record_id, pending.kind, reason, error?.type ?? null, error?.description ?? null, + typeof shown?.title === 'string' ? shown.title : null, pending.patch, server ? JSON.stringify(server) : null, ctx.now(), + ], + ); + db.run('DELETE FROM calendar_pending WHERE seq = ?', [pending.seq]); + if (row) { + if (server) writeEvent(db, pending.jmap, { shown: server, gen: row.gen }); + else deleteEventRow(db, pending.jmap, pending.record_id); + } + changed(ctx, 'CalendarEvent', 'CalendarConflict'); +} + +/** The server's copy of an event arriving during sync. Any pending write goes back on top, or to the conflict list. */ +export function applyServerEvent(ctx: StoreContext, jmap: string, event: CalendarObject, gen: number): void { + const { db } = ctx; + const pending = pendingFor(db, jmap, event.id); + if (!pending) { + writeEvent(db, jmap, { shown: event, gen }); + return; + } + const row = eventRow(db, jmap, event.id); + if (pending.kind === 'destroy') { + const previous = row ? stableJson(serverCopy(row)) : null; + if (previous !== null && previous !== stableJson(event)) { + // Deleting what someone just changed is the one write that can't be taken + // back, so the change wins and the user decides. + writeEvent(db, jmap, { shown: JSON.parse(row!.json) as CalendarObject, server: event, hidden: true, gen }); + recordConflict(ctx, pending, 'changedOnServer'); + return; + } + writeEvent(db, jmap, { shown: event, server: event, hidden: true, gen }); + return; + } + writeEvent(db, jmap, { shown: applyPatchObject(event, JSON.parse(pending.patch) as Record), server: event, gen }); + if (collides(pending, event)) recordConflict(ctx, pending, 'changedOnServer'); +} + +/** The server no longer has this event. A pending delete is done; a pending edit has nothing left to change. */ +export function applyServerDestroy(ctx: StoreContext, jmap: string, id: string): void { + const pending = pendingFor(ctx.db, jmap, id); + if (pending?.kind === 'update') { + recordConflict(ctx, pending, 'destroyedOnServer'); + return; + } + if (pending) ctx.db.run('DELETE FROM calendar_pending WHERE seq = ?', [pending.seq]); + deleteEventRow(ctx.db, jmap, id); +} + +export function listConflicts(db: StorageAdapter): CalendarConflict[] { + const rows = db.all<{ + id: number; jmap: string; record_id: string; kind: PendingRow['kind']; reason: ConflictReason; error_type: string | null; + description: string | null; title: string | null; local: string; server: string | null; created_at: number; + }>('SELECT * FROM calendar_conflict ORDER BY id'); + return rows.map((row) => ({ + id: Number(row.id), + jmap: row.jmap, + recordId: row.record_id, + kind: row.kind, + reason: row.reason, + errorType: row.error_type, + description: row.description, + title: row.title, + local: JSON.parse(row.local) as Record, + server: row.server ? JSON.parse(row.server) as Record : null, + createdAt: Number(row.created_at), + })); +} diff --git a/lib/store/engine/account-engine.ts b/lib/store/engine/account-engine.ts index 6281afc71..ab3a2b9b5 100644 --- a/lib/store/engine/account-engine.ts +++ b/lib/store/engine/account-engine.ts @@ -217,7 +217,7 @@ export class AccountEngine { async areaCall(name: string, params: unknown): Promise { const call = this.calls.get(name); if (!call) throw new Error(`Unknown store call ${name}`); - return call(this.storeContext(), params as never); + return call(this.syncContext(), params as never); } /** An answer from the device. Null when nothing in the request could be answered. */ diff --git a/stores/__tests__/calendar-store-offline-occurrence.test.ts b/stores/__tests__/calendar-store-offline-occurrence.test.ts new file mode 100644 index 000000000..4cccb256a --- /dev/null +++ b/stores/__tests__/calendar-store-offline-occurrence.test.ts @@ -0,0 +1,117 @@ +// @vitest-environment node +import { afterEach, describe, expect, it } from 'vitest'; +import { calendarArea } from '@/lib/store/calendar'; +import type { CalendarConflict } from '@/lib/store/calendar/writes'; +import { FakeCalendarJmap } from '@/lib/store/__tests__/helpers/fake-calendar'; +import { wire, type Wire } from '@/lib/store/__tests__/helpers/client-wire'; +import { isBrowserExpandedOccurrence, isServerRecurrenceInstance } from '@/lib/recurrence-instances'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import { useCalendarStore } from '../calendar-store'; + +const NOW = Date.UTC(2026, 10, 3, 12); +const START = '2026-11-02T00:00:00'; +const END = '2026-11-23T00:00:00'; +const ME = { '@type': 'Participant', calendarAddress: 'mailto:a@example.org', participationStatus: 'needs-action', roles: { attendee: true } }; +const ORGANIZER = { '@type': 'Participant', calendarAddress: 'mailto:boss@example.org', participationStatus: 'accepted', roles: { owner: true } }; + +let current: Wire | null = null; + +afterEach(() => { + current?.restore(); + current = null; + useCalendarStore.setState({ events: [], calendars: [] }); +}); + +async function online() { + const server = new FakeCalendarJmap('1.0'); + server.addEvent({ + id: 'series', + title: 'Weekly', + start: '2026-11-02T10:00:00', + recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly' }, + organizerCalendarAddress: 'mailto:boss@example.org', + participants: { me: ME, boss: ORGANIZER }, + }); + const w = wire(server, [calendarArea], NOW); + current = w; + await w.engine.requestSync(); + const client = await w.newClient() as unknown as IJMAPClient; + await client.getCalendars(); + await useCalendarStore.getState().fetchEvents(client, START, END); + return { w, client }; +} + +const overrides = (w: Wire) => w.server.events.get('series')?.recurrenceOverrides as Record> | undefined; +const flush = (w: Wire) => w.engine.areaCall('calendar.flush', undefined) as Promise<{ sent: number; conflicts: number; pending: number }>; +const conflicts = (w: Wire) => w.engine.areaCall('calendar.conflicts', undefined) as Promise; +const occurrenceOn = (day: string) => useCalendarStore.getState().events.find((event) => event.start.startsWith(day)); + +describe('an occurrence on screen from before the connection went', () => { + it('RSVP: goes in as an override on the master, shows after the refetch, reaches the server', async () => { + const { w, client } = await online(); + const instance = occurrenceOn('2026-11-09'); + expect(instance && isServerRecurrenceInstance(instance)).toBe(true); + w.online = false; + + await useCalendarStore.getState().rsvpEvent(client, instance!.id, 'me', 'accepted', undefined, 'occurrence'); + expect(overrides(w)).toBeUndefined(); + + // The refetch after the answer came from the store, with occurrences of its own. + const again = occurrenceOn('2026-11-09'); + expect(again && isBrowserExpandedOccurrence(again)).toBe(true); + expect(again?.participants?.me.participationStatus).toBe('accepted'); + expect(occurrenceOn('2026-11-16')?.participants?.me.participationStatus).toBe('needs-action'); + + w.online = true; + expect(await flush(w)).toMatchObject({ sent: 1, pending: 0 }); + expect(await conflicts(w)).toEqual([]); + const entry = overrides(w)?.['2026-11-09T10:00:00'] as { participants?: Record }; + expect(entry.participants?.me.participationStatus).toBe('accepted'); + }); + + it('RSVP for the whole series: a pointer patch on the master, every occurrence shows it', async () => { + const { w, client } = await online(); + const instance = occurrenceOn('2026-11-09'); + w.online = false; + await useCalendarStore.getState().rsvpEvent(client, instance!.id, 'me', 'accepted'); + expect(occurrenceOn('2026-11-16')?.participants?.me.participationStatus).toBe('accepted'); + w.online = true; + expect(await flush(w)).toMatchObject({ sent: 1, pending: 0 }); + const master = w.server.events.get('series') as unknown as { participants: Record }; + expect(master.participants.me.participationStatus).toBe('accepted'); + expect(master.participants.boss.participationStatus).toBe('accepted'); + }); + + it('delete of one occurrence: excluded on the master, gone from the grid', async () => { + const { w, client } = await online(); + const instance = occurrenceOn('2026-11-16'); + w.online = false; + await useCalendarStore.getState().deleteEvent(client, instance!.id); + expect(occurrenceOn('2026-11-16')).toBeUndefined(); + expect(occurrenceOn('2026-11-09')).toBeTruthy(); + w.online = true; + await flush(w); + expect(overrides(w)?.['2026-11-16T10:00:00']).toMatchObject({ excluded: true }); + }); + + it('edit of one occurrence, then another, of the same series: both reach the server in one write', async () => { + const { w, client } = await online(); + w.online = false; + await useCalendarStore.getState().updateEvent(client, occurrenceOn('2026-11-09')!.id, { title: 'First change' }); + expect(occurrenceOn('2026-11-09')?.title).toBe('First change'); + await useCalendarStore.getState().updateEvent(client, occurrenceOn('2026-11-16')!.id, { title: 'Second change' }); + expect(occurrenceOn('2026-11-09')?.title).toBe('First change'); + expect(occurrenceOn('2026-11-16')?.title).toBe('Second change'); + expect(occurrenceOn('2026-11-02')?.title).toBe('Weekly'); + + w.online = true; + const before = w.server.calls.length; + await flush(w); + const writes = w.server.calls.slice(before).filter(([name]) => name === 'CalendarEvent/set'); + expect(writes).toHaveLength(2); // series keys refused once by 1.0, then without them + expect(await conflicts(w)).toEqual([]); + expect(overrides(w)?.['2026-11-09T10:00:00']).toMatchObject({ title: 'First change' }); + expect(overrides(w)?.['2026-11-16T10:00:00']).toMatchObject({ title: 'Second change' }); + expect(overrides(w)?.['2026-11-09T10:00:00']).not.toHaveProperty('organizerCalendarAddress'); + }); +});