From fdeb7963bab486358179f1e1861cff6c6c0abe38 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:41:35 +0100 Subject: [PATCH 1/3] refactor: read event durations without loading the calendar UI --- components/calendar/event-card.tsx | 15 +-------------- lib/calendar-alerts.ts | 2 +- lib/calendar-event-normalization.ts | 15 +++++++++++++++ lib/calendar-utils.ts | 2 +- stores/calendar-store.ts | 2 +- 5 files changed, 19 insertions(+), 17 deletions(-) diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx index d53f88b36..b7618622e 100644 --- a/components/calendar/event-card.tsx +++ b/components/calendar/event-card.tsx @@ -4,6 +4,7 @@ import { useCallback, useRef, useState, type CSSProperties, type DragEvent } fro import { useTranslations } from "next-intl"; import { cn } from "@/lib/utils"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; +import { parseDuration } from "@/lib/calendar-event-normalization"; import { format } from "date-fns"; import { ChevronDown, ChevronUp, Users } from "@/components/icons"; import { getParticipantCount, isDeclinedByUser } from "@/lib/calendar-participants"; @@ -101,20 +102,6 @@ export function eventFillClass(tinted: boolean, inactive: boolean): string | und return tinted && !inactive ? "start-bar" : undefined; } -function parseDuration(duration: string | undefined): number { - if (!duration) return 0; - let totalMinutes = 0; - const weekMatch = duration.match(/(\d+)W/); - const hourMatch = duration.match(/(\d+)H/); - const minMatch = duration.match(/(\d+)M/); - const dayMatch = duration.match(/(\d+)D/); - if (weekMatch) totalMinutes += parseInt(weekMatch[1]) * 7 * 24 * 60; - if (dayMatch) totalMinutes += parseInt(dayMatch[1]) * 24 * 60; - if (hourMatch) totalMinutes += parseInt(hourMatch[1]) * 60; - if (minMatch) totalMinutes += parseInt(minMatch[1]); - return totalMinutes; -} - function createEventDragPreview(title: string, timeRange: string, color: string): HTMLElement { const el = document.createElement("div"); el.style.cssText = ` diff --git a/lib/calendar-alerts.ts b/lib/calendar-alerts.ts index bef0151e1..2c5e8ff96 100644 --- a/lib/calendar-alerts.ts +++ b/lib/calendar-alerts.ts @@ -7,7 +7,7 @@ import type { Calendar, CalendarTask, } from '@/lib/jmap/types'; -import { parseDuration } from '@/components/calendar/event-card'; +import { parseDuration } from '@/lib/calendar-event-normalization'; export interface PendingAlert { eventId: string; diff --git a/lib/calendar-event-normalization.ts b/lib/calendar-event-normalization.ts index 6dd31bf83..5305faa8e 100644 --- a/lib/calendar-event-normalization.ts +++ b/lib/calendar-event-normalization.ts @@ -39,6 +39,21 @@ export function parseDurationSeconds(duration: string | undefined): number | nul return (((weeks * 7 + days) * 24 + hours) * 60 + minutes) * 60 + seconds; } +/** A duration in whole minutes, 0 when there is none. */ +export function parseDuration(duration: string | undefined): number { + if (!duration) return 0; + let totalMinutes = 0; + const weekMatch = duration.match(/(\d+)W/); + const hourMatch = duration.match(/(\d+)H/); + const minMatch = duration.match(/(\d+)M/); + const dayMatch = duration.match(/(\d+)D/); + if (weekMatch) totalMinutes += parseInt(weekMatch[1]) * 7 * 24 * 60; + if (dayMatch) totalMinutes += parseInt(dayMatch[1]) * 24 * 60; + if (hourMatch) totalMinutes += parseInt(hourMatch[1]) * 60; + if (minMatch) totalMinutes += parseInt(minMatch[1]); + return totalMinutes; +} + export function normalizeAllDayDurationValue(duration: string | undefined): string | undefined { const totalSeconds = parseDurationSeconds(duration); if (totalSeconds === null || totalSeconds < 86400 || totalSeconds % 86400 !== 0) { diff --git a/lib/calendar-utils.ts b/lib/calendar-utils.ts index 2299fb54c..faff279c4 100644 --- a/lib/calendar-utils.ts +++ b/lib/calendar-utils.ts @@ -1,5 +1,5 @@ import { addDays, differenceInCalendarDays, parseISO, startOfDay, subMilliseconds } from "date-fns"; -import { parseDuration } from "@/components/calendar/event-card"; +import { parseDuration } from "@/lib/calendar-event-normalization"; import type { CalendarEvent } from "@/lib/jmap/types"; import { fromWallClock, isValidTimeZone, parseLocalDateTime, toDisplayDate } from "@/lib/timezone"; diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index d7f9c53f7..fceb9c4e7 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -5,7 +5,7 @@ import type { Calendar, CalendarEvent, CalendarParticipant, CalendarParticipantI import { debug } from '@/lib/debug'; import { getEventStartDate, normalizeAllDayDuration } from '@/lib/calendar-utils'; import { displayNow } from '@/lib/timezone'; -import { parseDuration } from '@/components/calendar/event-card'; +import { parseDuration } from '@/lib/calendar-event-normalization'; import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization'; import { feedChanged, feedFields } from '@/lib/calendar-feed-diff'; import { expandRecurringEvents } from '@/lib/recurrence-expansion'; 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 2/3] 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'); + }); +}); From a3819f6a1c95007e1cef6c7ced555e5f6f62461d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 06:32:53 +0100 Subject: [PATCH 3/3] fix: keep offline calendar writes in order, and stop them getting lost or doubled Review of the calendar store found writes that went missing, went out wrong, or appeared twice. - Writes with different scheduling flags stay separate and go out in order. Merging them collapsed the two steps of an occurrence change, so attendees who had answered got no mail, and "don't notify" went out to everyone with a later RSVP. - An edit made after a create's lost response is sent as an update. An import of a uid the server has updates what differs. - `createCalendarEvent`, `batchCreateCalendarEvents` and `createCalendarTask` choose a uid before the first send, so a lost answer online doesn't duplicate. - The client resolves stand-in ids before the network, which covers an open dialog once the server is back. - A stale whole `recurrenceOverrides` map no longer erases overrides. - A 4xx refuses that write and the queue goes on. `serverFail`, `rateLimit` or an answer with no verdict retry instead of deleting. - "This and all following" is refused offline with a clear message. - A full list only drops what the server says it doesn't have. Sync adopts a stand-in by uid. Reminder keys survive a stand-in becoming real. Accounts the session no longer lists are forgotten. Changed tests: the one asserting a sticky scheduling flag is gone, it locked in the bug. Reminder keys now use the account and uid. Two flush assertions drop `sent: 1`, because sync reads a lost write back first. The two-account id test uses a listed account, as unlisted ones are removed. --- components/calendar/calendar-app.tsx | 7 +- lib/jmap/client-interface.ts | 2 + lib/jmap/client.ts | 43 +- lib/jmap/offline-hooks.ts | 6 + lib/jmap/stand-in-ids.ts | 76 +++ lib/recurrence-scope-edit.ts | 18 + lib/store/__tests__/calendar-no-dom.test.ts | 2 +- lib/store/__tests__/calendar-sync.test.ts | 31 +- .../__tests__/calendar-writes-review.test.ts | 520 ++++++++++++++++++ lib/store/__tests__/helpers/client-wire.ts | 32 +- lib/store/__tests__/helpers/fake-calendar.ts | 45 +- lib/store/calendar/flush.ts | 192 +++---- lib/store/calendar/index.ts | 3 +- lib/store/calendar/patches.ts | 141 +++++ lib/store/calendar/reminders.ts | 13 +- lib/store/calendar/schema.ts | 12 +- lib/store/calendar/sync.ts | 88 ++- lib/store/calendar/writes.ts | 363 ++++++++---- locales/ar/common.json | 3 + locales/ca/common.json | 3 + locales/cs/common.json | 3 + locales/da/common.json | 3 + locales/de/common.json | 3 + locales/en/common.json | 3 + locales/es/common.json | 3 + locales/et/common.json | 3 + locales/fa/common.json | 3 + locales/fr/common.json | 3 + locales/he/common.json | 3 + locales/hu/common.json | 3 + locales/it/common.json | 3 + locales/ja/common.json | 3 + locales/ko/common.json | 3 + locales/lv/common.json | 3 + locales/mn/common.json | 3 + locales/nb/common.json | 3 + locales/nl/common.json | 3 + locales/pl/common.json | 3 + locales/pt/common.json | 3 + locales/ro/common.json | 3 + locales/ru/common.json | 3 + locales/sk/common.json | 3 + locales/th/common.json | 3 + locales/tr/common.json | 3 + locales/uk/common.json | 3 + locales/vi/common.json | 3 + locales/zh-TW/common.json | 3 + locales/zh/common.json | 3 + .../calendar-store-offline-occurrence.test.ts | 135 ++++- 49 files changed, 1540 insertions(+), 279 deletions(-) create mode 100644 lib/jmap/stand-in-ids.ts create mode 100644 lib/store/__tests__/calendar-writes-review.test.ts create mode 100644 lib/store/calendar/patches.ts diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 1bccba921..6e766ab98 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -155,6 +155,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = // The server's reason for a refused change, where the user can act on it. const describeError = useCalendarErrorMessage(); const tSidebar = useTranslations("sidebar"); + const tOffline = useTranslations("calendar_offline"); useDocumentTitle(tSidebar("calendar")); const skeletonLabels = useSkeletonLabels(); const tWebcalAction = useTranslations("calendar.webcal_action"); @@ -1446,9 +1447,13 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = debug.error("Failed to refresh calendar after scope operation"); } } catch (error) { + if (error instanceof Error && error.name === "SeriesSplitNeedsServerError") { + toast.error(tOffline("split_needs_server")); + return; + } toast.error(describeError(error, t(type === "delete" ? "notifications.event_delete_error" : "notifications.event_error"))); } - }, [client, pendingScopeAction, updateEvent, deleteEvent, createEvent, findMasterEvent, currentUserEmails, resolveContactName, refetchCurrentRange, submitRsvp, describeError, t]); + }, [client, pendingScopeAction, updateEvent, deleteEvent, createEvent, findMasterEvent, currentUserEmails, resolveContactName, refetchCurrentRange, submitRsvp, describeError, t, tOffline]); // A drag or resize. A series asks which occurrences it covers, as the // editor does; one event moves at once and can be moved back. diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 377e61525..908fe45a6 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -635,6 +635,8 @@ export interface IJMAPClient { options?: CalendarEventUpdateOptions, ): Promise; deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise; + /** Whether the last request was answered from this device instead of the server. */ + answeredFromDevice?(): boolean; batchDeleteCalendarEvents(eventIds: string[], targetAccountId?: string): Promise<{ destroyed: string[]; notDestroyed: Record }>; queryCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, targetAccountId?: string, options?: CalendarQueryOptions): Promise; queryAllCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, options?: CalendarQueryOptions): Promise; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 6d35dcdfa..3ee284a55 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -4,6 +4,7 @@ import { IncompleteDeleteError } from "./delete-error"; import { downloadAllowed, saveBlobAs, type DownloadKind } from "@/lib/download-guard"; import { ArchiveEmailsError } from "./archive-error"; import { generateUUID } from '@/lib/utils'; +import { standInsIn, withRealIds, withStandInsBack } from './stand-in-ids'; import type { Attachment, Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, CalendarEventNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarComponentType, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, CreateCalendarOptions, FileNode, FileNodeFilter, FileNodeRights, Principal, PrincipalAvailability, PrincipalListing, PushSubscription, EmailPushConfig, DeliveryStatus, EmailSubmission, RejectedRecipient, ScheduledEmail, SendEmailResult, SharedAccount } from "./types"; import type { SieveScript, SieveCapabilities } from "./sieve-types"; import type { BlobBatch, CalendarEventUpdateOptions, CalendarQueryOptions, FileNodeListings, IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration, SearchEmailsResult, SearchRequestOptions, UploadedEmailImport, UploadedEmailImportResult } from "./client-interface"; @@ -2195,9 +2196,30 @@ export class JMAPClient implements IJMAPClient { const offline = getOfflineHooks(); if (this.offlineOnly || offline?.skipNetwork(this)) { const local = offline ? await offline.answer(this, methodCalls) : null; - if (local) return local as JMAPResponse; + if (local) { + this.lastAnswerFromDevice = true; + return local as JMAPResponse; + } throw new OfflineUnavailableError(); } + // A stand-in id from an event created offline, still held by an open dialog + // or a list: the server knows it by its real id, or not yet at all. + const standIns = offline?.resolveStandIns ? standInsIn(methodCalls) : []; + let realIds: Record | null = null; + if (standIns.length > 0) { + const resolved = await offline!.resolveStandIns!(this, standIns); + if (standIns.some((id) => !resolved[id])) { + const local = await offline!.answer(this, methodCalls); + if (local) { + this.lastAnswerFromDevice = true; + return local as JMAPResponse; + } + throw new OfflineUnavailableError(); + } + realIds = resolved as Record; + methodCalls = withRealIds(methodCalls, realIds); + } + const asAsked = (response: JMAPResponse) => (realIds ? withStandInsBack(response, realIds) : response); let answer: JMAPResponse; try { answer = await this.networkRequest(methodCalls, using, opts); @@ -2206,15 +2228,25 @@ export class JMAPClient implements IJMAPClient { // headers looks the same to the browser and must stay an error. if (offline && isNetworkFailure(error) && await offline.serverGone(this)) { const local = await offline.answer(this, methodCalls); - if (local) return local as JMAPResponse; + if (local) { + this.lastAnswerFromDevice = true; + return asAsked(local as JMAPResponse); + } } throw error; } + this.lastAnswerFromDevice = false; if (offline) { offline.noteNetwork(this, true); if (!opts?.skipOfflineRecord) offline.record(this, methodCalls, answer); } - return answer; + return asAsked(answer); + } + + private lastAnswerFromDevice = false; + + answeredFromDevice(): boolean { + return this.lastAnswerFromDevice; } private async networkRequest(methodCalls: JMAPMethodCall[], using?: string[], opts?: { transfer?: boolean }): Promise { @@ -9418,6 +9450,8 @@ export class JMAPClient implements IJMAPClient { // Strip client-only shared fields before sending to JMAP const { originalId: _oi, baseEventId: _be, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanEvent } = event as CalendarEvent; cleanRecurrenceRules(cleanEvent as unknown as Record); + // Chosen here, not by the server: if the answer is lost, this finds the event again. + cleanEvent.uid ||= generateUUID(); debug.group('CalendarEvent/create', 'calendar'); debug.log('calendar', 'CalendarEvent/create outgoing payload', { @@ -9524,6 +9558,7 @@ export class JMAPClient implements IJMAPClient { for (const { event, index } of batch) { const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...clean } = event as CalendarEvent; cleanRecurrenceRules(clean as unknown as Record); + clean.uid ||= generateUUID(); createMap[`new-${index}`] = clean; } @@ -9948,7 +9983,7 @@ export class JMAPClient implements IJMAPClient { accountId = resolved.accountId; taskData.calendarIds = resolved.calendarIds; } - const cleanTask = { ...completeBareTaskDates(taskData), '@type': 'Task' }; + const cleanTask = { ...completeBareTaskDates(taskData), '@type': 'Task', uid: taskData.uid || generateUUID() }; debug.group('CalendarTask/create', 'tasks'); debug.log('tasks', 'CalendarTask/create accountId', accountId); diff --git a/lib/jmap/offline-hooks.ts b/lib/jmap/offline-hooks.ts index ab0477fc9..85411c0a5 100644 --- a/lib/jmap/offline-hooks.ts +++ b/lib/jmap/offline-hooks.ts @@ -45,6 +45,12 @@ export interface OfflineHooks { recordBlob(client: OfflineClientRef, blobId: string, accountId: string, blob: Blob): void; /** Keep a file to upload later; the stand-in blob id, or null when offline mode doesn't cover the client. */ holdUpload(client: OfflineClientRef, file: File, accountId: string): Promise<{ blobId: string; size: number; type: string } | null>; + /** + * Real ids for stand-in ids the page still holds (lib/jmap/stand-in-ids.ts), or + * null for one that hasn't reached the server yet. Without it the client sends + * ids as they are. + */ + resolveStandIns?(client: OfflineClientRef, ids: string[]): Promise>; } const KEY = '__bulwarkOfflineHooks'; diff --git a/lib/jmap/stand-in-ids.ts b/lib/jmap/stand-in-ids.ts new file mode 100644 index 000000000..00da263e6 --- /dev/null +++ b/lib/jmap/stand-in-ids.ts @@ -0,0 +1,76 @@ +/** + * Stand-in ids: what the local store hands out for an event created offline. + * + * A page can hold one long after the event reached the server: an open dialog, + * a list not refetched yet, a plugin. So the client swaps them for the real ids + * on the way to the network, and swaps them back in the answer, because callers + * look up the outcome under the id they asked about. + */ + +import type { OfflineMethodCall, OfflineResponse } from './offline-hooks'; + +const STAND_IN_PREFIX = 'local-'; + +export function isStandInId(id: string): boolean { + return id.startsWith(STAND_IN_PREFIX); +} + +export function newStandInId(): string { + return `${STAND_IN_PREFIX}${crypto.randomUUID()}`; +} + +/** The stand-in ids a request names. */ +export function standInsIn(calls: readonly OfflineMethodCall[]): string[] { + const found = new Set(); + for (const [name, args] of calls) { + if (name === 'CalendarEvent/set') { + for (const id of Object.keys((args.update ?? {}) as object)) if (isStandInId(id)) found.add(id); + for (const id of (args.destroy ?? []) as string[]) if (isStandInId(id)) found.add(id); + } else if (name === 'CalendarEvent/get') { + for (const id of (args.ids ?? []) as string[]) if (isStandInId(id)) found.add(id); + } + } + return [...found]; +} + +function renamedKeys(map: Record, rename: (id: string) => string): Record { + return Object.fromEntries(Object.entries(map).map(([id, value]) => [rename(id), value])); +} + +/** The request with every stand-in swapped for its real id. */ +export function withRealIds(calls: readonly T[], real: Readonly>): T[] { + const rename = (id: string) => real[id] ?? id; + return calls.map((call) => { + const [name, args, callId] = call; + if (name === 'CalendarEvent/set') { + const next = { ...args }; + if (args.update) next.update = renamedKeys(args.update as Record, rename); + if (args.destroy) next.destroy = (args.destroy as string[]).map(rename); + return [name, next, callId] as unknown as T; + } + if (name === 'CalendarEvent/get' && args.ids) { + return [name, { ...args, ids: (args.ids as string[]).map(rename) }, callId] as unknown as T; + } + return call; + }); +} + +/** The answer as if the caller's ids had gone out. The objects in a get's list keep their real id. */ +export function withStandInsBack(response: T, real: Readonly>): T { + const back = new Map(Object.entries(real).map(([standIn, id]) => [id, standIn])); + const rename = (id: string) => back.get(id) ?? id; + return { + ...response, + methodResponses: response.methodResponses.map(([name, result, callId]) => { + if (name !== 'CalendarEvent/set' && name !== 'CalendarEvent/get') return [name, result, callId]; + const next = { ...result }; + for (const key of ['updated', 'notUpdated', 'notDestroyed']) { + if (next[key]) next[key] = renamedKeys(next[key], rename); + } + for (const key of ['destroyed', 'notFound']) { + if (next[key]) next[key] = next[key].map(rename); + } + return [name, next, callId]; + }), + }; +} diff --git a/lib/recurrence-scope-edit.ts b/lib/recurrence-scope-edit.ts index 4de698447..7c45c9db5 100644 --- a/lib/recurrence-scope-edit.ts +++ b/lib/recurrence-scope-edit.ts @@ -645,6 +645,19 @@ function raisesSequences(series: Partial, master: CalendarEvent, */ export type OccurrenceOutcome = boolean | 'stopped'; +/** + * "This and all following" splits a series: ends the old one, creates the rest as + * a new one. Queued on a device these are two writes the server takes or refuses + * apart, with no one there to put the series back when only one goes through, so + * it waits for the server. + */ +export class SeriesSplitNeedsServerError extends Error { + constructor() { + super('Splitting a series needs the server'); + this.name = 'SeriesSplitNeedsServerError'; + } +} + /** The store actions resolve to false when a plugin stopped the write. */ function stopped(result: boolean | void): boolean { return result === false; @@ -1035,6 +1048,11 @@ export async function saveOccurrenceEdit( // From the first occurrence on is the whole series; ended before its // first occurrence, the series would be left with none. if (isFirstOccurrence(event, master)) return saveOccurrenceEdit('all', edit, actions); + if (client.answeredFromDevice) { + // Whether the server is there right now: the last answer may be minutes old. + await client.getCalendarEvent(master.originalId ?? master.id, master.accountId); + if (client.answeredFromDevice()) throw new SeriesSplitNeedsServerError(); + } const truncated = await truncateSeries(master, event, actions, { sendScheduling, userEmails: edit.userEmails }); if (!truncated) return 'stopped'; const { restore, announced, removed } = truncated; diff --git a/lib/store/__tests__/calendar-no-dom.test.ts b/lib/store/__tests__/calendar-no-dom.test.ts index 9ee189b68..8cd3d10a1 100644 --- a/lib/store/__tests__/calendar-no-dom.test.ts +++ b/lib/store/__tests__/calendar-no-dom.test.ts @@ -27,6 +27,6 @@ describe('what the worker pulls in', () => { const inputs = await inputsOf(entry); expect(inputs.length).toBeGreaterThan(3); expect(inputs.filter((file) => /(^|\/)(components|stores|hooks|app)\//.test(file))).toEqual([]); - }); + }, 30_000); } }); diff --git a/lib/store/__tests__/calendar-sync.test.ts b/lib/store/__tests__/calendar-sync.test.ts index 45ba37796..2b3e90c65 100644 --- a/lib/store/__tests__/calendar-sync.test.ts +++ b/lib/store/__tests__/calendar-sync.test.ts @@ -13,13 +13,13 @@ 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)) { +function setup(version: StalwartVersion = '1.0', server = new FakeCalendarJmap(version), session = CALENDAR_SESSION) { const db = openMemoryStore(); const events: StoreEvent[] = []; const engine = new AccountEngine({ account: 'a@fake.test', db, - session: CALENDAR_SESSION, + session, transport: server, areas: [calendarArea], scope: DEFAULT_SCOPE, @@ -150,13 +150,13 @@ describe('calendar sync', () => { }); 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' })], - ); + const s = setup('1.0', undefined, { + ...CALENDAR_SESSION, + accounts: { ...CALENDAR_SESSION.accounts, other: { name: 'other', isPersonal: false, accountCapabilities: { [CAL]: {} } } }, + }); s.server.addEvent({ id: 'same', title: 'Mine' }); await s.engine.requestSync(); + await call(s, 'CalendarEvent/set', { accountId: 'other', update: { same: { title: 'Theirs' } } }); 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'); @@ -376,14 +376,6 @@ describe('writes while offline', () => { 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' } } }); @@ -434,7 +426,8 @@ describe('writes while offline', () => { 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 }); + // The next sync reads it back, so there's nothing left to send. + expect(await flush(s)).toMatchObject({ pending: 0 }); expect(await conflicts(s)).toEqual([]); }); @@ -553,10 +546,10 @@ describe('a create and a lost response', () => { 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(await flush(s)).toMatchObject({ 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); + expect(uid).toBe([...s.server.events.values()][0].uid); const real = [...s.server.events.keys()][0]; expect(await queryIds(s, {})).toEqual([real]); expect(await getOne(s, id)).toMatchObject({ id: real }); @@ -859,7 +852,7 @@ describe('reminders from stored events', () => { 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, + baseEventId: 'series', alertId: 'a', fireTimeMs: fire, recurrenceId: '2026-11-10T10:00:00', key: `${JMAP_ACCOUNT}:series@example.org:a:${fire}`, kind: 'event', jmap: JMAP_ACCOUNT, }); }); diff --git a/lib/store/__tests__/calendar-writes-review.test.ts b/lib/store/__tests__/calendar-writes-review.test.ts new file mode 100644 index 000000000..9ab9cfc37 --- /dev/null +++ b/lib/store/__tests__/calendar-writes-review.test.ts @@ -0,0 +1,520 @@ +// @vitest-environment node +import { afterEach, describe, expect, it } from 'vitest'; +import type { CalendarEvent } from '@/lib/jmap/types'; +import { DEFAULT_SCOPE } from '../area'; +import { calendarArea } from '../calendar'; +import { listUpcomingAlerts } from '../calendar/reminders'; +import type { CalendarConflict } from '../calendar/writes'; +import { AccountEngine } from '../engine/account-engine'; +import { TransportError, type JmapResponse, type MethodCall, type StoreSession } from '../engine/transport'; +import { wire, type Wire } from './helpers/client-wire'; +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' }; +const K1 = '2026-11-17T10:00:00'; +const K2 = '2026-11-24T10:00:00'; + +function setup(version: StalwartVersion = '1.0', session: StoreSession = CALENDAR_SESSION) { + const server = new FakeCalendarJmap(version); + const db = openMemoryStore(); + const engine = new AccountEngine({ + account: 'a@fake.test', db, session, transport: server, areas: [calendarArea], scope: DEFAULT_SCOPE, now: () => NOW, sleep: async () => {}, + }); + return { server, db, engine }; +} +type Setup = ReturnType; + +async function write(s: Setup, args: Record, jmap = JMAP_ACCOUNT) { + const response = await s.engine.answer([['CalendarEvent/set', { accountId: jmap, ...args }, 'x']]); + return response!.methodResponses[0][1]; +} + +async function read(s: Setup, id: string, properties?: string[]) { + const response = await s.engine.answer([['CalendarEvent/get', { accountId: JMAP_ACCOUNT, ids: [id], ...(properties ? { properties } : {}) }, 'x']]); + return response!.methodResponses[0][1].list[0] as Record | undefined; +} + +async function query(s: Setup, args: Record = {}) { + const response = await s.engine.answer([['CalendarEvent/query', { accountId: JMAP_ACCOUNT, ...args }, 'x']]); + return response!.methodResponses[0][1].ids as string[]; +} + +const flush = (s: Pick) => s.engine.areaCall('calendar.flush', undefined) as Promise<{ sent: number; conflicts: number; pending: number }>; +const conflicts = (s: Pick) => s.engine.areaCall('calendar.conflicts', undefined) as Promise; +const sets = (server: FakeCalendarJmap) => server.calls.filter(([name, args]) => name === 'CalendarEvent/set' && !(args.update && 'h333333' in (args.update as object))); +const pending = (s: Pick) => s.db.all<{ kind: string; patch: string; record_id: string; send_scheduling: number | null }>('SELECT kind, patch, record_id, send_scheduling FROM calendar_pending ORDER BY seq'); +const NEW = { title: 'Fresh', calendarIds: { cal1: true }, start: '2026-11-20T09:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' }; + +async function synced(version: StalwartVersion = '1.0', add?: (server: FakeCalendarJmap) => void) { + const s = setup(version); + s.server.addEvent({ id: 'e1', title: 'Meeting', description: 'Old' }); + add?.(s.server); + await s.engine.requestSync(); + s.server.calls.length = 0; + return s; +} + +function failSets(s: Setup, decide: (args: Record) => JmapResponse | Error | null) { + const real = s.server.request.bind(s.server); + s.server.request = async (calls: MethodCall[]) => { + for (const [name, args] of calls) { + if (name !== 'CalendarEvent/set' || (args.update && 'h333333' in (args.update as object))) continue; + const outcome = decide(args); + if (outcome instanceof Error) throw outcome; + if (outcome) return outcome; + } + return real(calls); + }; +} + +describe('an edit after a create whose answer got lost', () => { + it('reaches the server as an update, without a second create', async () => { + const s = await synced(); + const id = (await write(s, { create: { c: NEW } })).created.c.id as string; + s.server.loseNextSetResponse = true; + await flush(s); + await write(s, { update: { [id]: { title: 'Edited after the attempt' } } }); + expect(await flush(s)).toMatchObject({ pending: 0 }); + expect(s.server.events.size).toBe(2); + expect(sets(s.server).filter(([, args]) => args.create)).toHaveLength(1); + const onServer = [...s.server.events.values()].find((event) => event.uid !== 'e1@example.org'); + expect(onServer?.title).toBe('Edited after the attempt'); + expect(await read(s, onServer!.id)).toMatchObject({ title: 'Edited after the attempt' }); + }); + + it('also removes what the edit removed', async () => { + const s = await synced(); + const id = (await write(s, { create: { c: { ...NEW, description: 'To go' } } })).created.c.id as string; + s.server.loseNextSetResponse = true; + await flush(s); + await write(s, { update: { [id]: { description: null } } }); + await flush(s); + const onServer = [...s.server.events.values()].find((event) => event.title === 'Fresh'); + expect(onServer?.description ?? null).toBeNull(); + }); + + it('applies an import whose uid the server already has as an update', async () => { + const s = await synced(); + await write(s, { create: { c: { ...NEW, uid: 'e1@example.org', title: 'Imported', description: 'Old' } } }); + await flush(s); + expect(sets(s.server).filter(([, args]) => args.create)).toEqual([]); + expect(s.server.events.size).toBe(1); + expect(s.server.events.get('e1')).toMatchObject({ title: 'Imported', description: 'Old' }); + expect(await query(s)).toEqual(['e1']); + }); + + it('sends no update when the server already holds the same event', async () => { + const s = await synced(); + const held = s.server.events.get('e1')!; + await write(s, { create: { c: { uid: 'e1@example.org', title: held.title, calendarIds: held.calendarIds, start: held.start, duration: held.duration, timeZone: held.timeZone, '@type': 'Event' } } }); + await flush(s); + expect(sets(s.server)).toEqual([]); + expect(pending(s)).toEqual([]); + }); +}); + +describe('writes with different scheduling flags', () => { + it('stay separate and go out in order', async () => { + const s = await synced(); + await write(s, { update: { e1: { title: 'Quiet edit' } }, sendSchedulingMessages: false }); + await write(s, { update: { e1: { 'participants/me/participationStatus': 'accepted' } }, sendSchedulingMessages: true }); + expect(pending(s).map((row) => row.send_scheduling)).toEqual([0, 1]); + expect(await read(s, 'e1')).toMatchObject({ title: 'Quiet edit', participants: { me: { participationStatus: 'accepted' } } }); + + await flush(s); + const sent = sets(s.server); + expect(sent.map(([, args]) => args.sendSchedulingMessages)).toEqual([false, true]); + expect((sent[0][1].update as Record).e1).toEqual({ title: 'Quiet edit' }); + expect((sent[1][1].update as Record).e1).toEqual({ 'participants/me/participationStatus': 'accepted' }); + }); + + it('still merge when the flags are the same', async () => { + const s = await synced(); + await write(s, { update: { e1: { title: 'One' } }, sendSchedulingMessages: true }); + await write(s, { update: { e1: { description: 'Two' } }, sendSchedulingMessages: true }); + expect(pending(s)).toHaveLength(1); + }); + + it('create and a later edit that notifies go out as two writes, the second under the real id', async () => { + const s = await synced(); + const id = (await write(s, { create: { c: NEW }, sendSchedulingMessages: false })).created.c.id as string; + await write(s, { update: { [id]: { title: 'Told everyone' } }, sendSchedulingMessages: true }); + expect(pending(s).map((row) => row.kind)).toEqual(['create', 'update']); + await flush(s); + const sent = sets(s.server); + expect(sent).toHaveLength(2); + expect(sent[0][1].sendSchedulingMessages).toBe(false); + const real = Object.keys(sent[1][1].update as object)[0]; + expect(s.server.events.get(real)).toMatchObject({ title: 'Told everyone' }); + expect(sent[1][1].sendSchedulingMessages).toBe(true); + expect(await flush(s)).toMatchObject({ pending: 0 }); + }); + + it('a create deleted before it left takes its later rows with it', async () => { + const s = await synced(); + const id = (await write(s, { create: { c: NEW }, sendSchedulingMessages: true })).created.c.id as string; + await write(s, { update: { [id]: { title: 'Quiet' } }, sendSchedulingMessages: false }); + await write(s, { destroy: [id] }); + expect(pending(s)).toEqual([]); + await flush(s); + expect(sets(s.server)).toEqual([]); + }); + + it('a conflict on the first takes the ones built on it along', async () => { + const s = await synced(); + await write(s, { update: { e1: { title: 'Mine' } }, sendSchedulingMessages: false }); + await write(s, { update: { e1: { description: 'Mine too' } }, sendSchedulingMessages: true }); + s.server.patchEvent('e1', { title: 'Theirs' }); + await flush(s); + expect(sets(s.server)).toEqual([]); + expect((await conflicts(s)).map((conflict) => conflict.local)).toEqual([{ title: 'Mine' }, { description: 'Mine too' }]); + expect(await read(s, 'e1')).toMatchObject({ title: 'Theirs', description: 'Old' }); + }); +}); + +describe('a whole recurrenceOverrides map from stale state', () => { + const series = (server: FakeCalendarJmap) => server.addEvent({ id: 's', title: 'Weekly', start: '2026-11-03T10:00:00', recurrenceRule: WEEKLY }); + + it('does not erase an override someone else added', async () => { + const s = await synced('1.0', series); + await write(s, { update: { s: { recurrenceOverrides: { [K1]: { title: 'Mine' } } } } }); + s.server.patchEvent('s', { recurrenceOverrides: { [K2]: { title: 'Theirs' } } }); + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect(s.server.events.get('s')?.recurrenceOverrides).toEqual({ [K1]: { title: 'Mine' }, [K2]: { title: 'Theirs' } }); + }); + + it('goes out as a whole map only when the server has none', async () => { + const s = await synced('1.0', series); + await write(s, { update: { s: { recurrenceOverrides: { [K1]: { title: 'Mine' } } } } }); + await flush(s); + expect((sets(s.server)[0][1].update as Record).s).toEqual({ recurrenceOverrides: { [K1]: { title: 'Mine' } } }); + expect(s.server.events.get('s')?.recurrenceOverrides).toEqual({ [K1]: { title: 'Mine' } }); + }); + + it('removes only the entries the sender knew and left out', async () => { + const s = await synced('1.0', (server) => { + series(server); + server.patchEvent('s', { recurrenceOverrides: { [K1]: { title: 'Dropped' } } }); + }); + await write(s, { update: { s: { recurrenceOverrides: { [K2]: { title: 'Kept' } } } } }); + s.server.patchEvent('s', { [`recurrenceOverrides/2026-12-01T10:00:00`]: { title: 'Theirs' } }); + await flush(s); + expect(s.server.events.get('s')?.recurrenceOverrides).toEqual({ [K2]: { title: 'Kept' }, '2026-12-01T10:00:00': { title: 'Theirs' } }); + }); + + it('collides on the same entry', async () => { + const s = await synced('1.0', series); + await write(s, { update: { s: { recurrenceOverrides: { [K1]: { title: 'Mine' } } } } }); + s.server.patchEvent('s', { recurrenceOverrides: { [K1]: { title: 'Theirs' } } }); + await flush(s); + expect((await conflicts(s))[0]).toMatchObject({ reason: 'changedOnServer' }); + }); +}); + +describe('one bad write', () => { + it('a 4xx refuses that write and the next one still goes', async () => { + const s = await synced('1.0', (server) => server.addEvent({ id: 'e2', title: 'Other' })); + await write(s, { update: { e1: { title: 'Bad' } } }); + await write(s, { update: { e2: { title: 'Good' } } }); + failSets(s, (args) => (args.update && 'e1' in (args.update as object) ? new TransportError('http', 'Bad request', 400) : null)); + expect(await flush(s)).toMatchObject({ sent: 1, conflicts: 1, pending: 0 }); + expect((await conflicts(s))[0]).toMatchObject({ recordId: 'e1', reason: 'refused' }); + expect(s.server.events.get('e2')?.title).toBe('Good'); + expect(await read(s, 'e1')).toMatchObject({ title: 'Meeting' }); + }); + + it('401, 429 and a 5xx stay pending', async () => { + for (const error of [new TransportError('auth', 'x', 401), new TransportError('rate', 'x', 429), new TransportError('http', 'x', 500)]) { + const s = await synced(); + await write(s, { update: { e1: { title: 'Later' } } }); + failSets(s, () => error); + expect(await flush(s)).toMatchObject({ conflicts: 0, pending: 1 }); + expect(await conflicts(s)).toEqual([]); + } + }); + + it('an answer without a verdict, or a passing refusal, retries instead of deleting or finishing', async () => { + const s = await synced(); + const id = (await write(s, { create: { c: NEW } })).created.c.id as string; + await write(s, { update: { e1: { title: 'Later' } } }); + let round = 0; + failSets(s, (args) => { + if (round > 3) return null; + if (args.create) { + const notCreated = round === 1 ? { c: { type: 'serverFail' } } : round === 2 ? { c: { type: 'rateLimit' } } : undefined; + return { methodResponses: [['CalendarEvent/set', notCreated ? { notCreated } : {}, 's']] }; + } + return { methodResponses: [['CalendarEvent/set', {}, 's']] }; + }); + for (round = 1; round <= 3; round += 1) { + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect(pending(s)).toHaveLength(2); + expect(await read(s, id)).toMatchObject({ title: 'Fresh' }); + } + expect(await flush(s)).toMatchObject({ pending: 0 }); + expect(s.server.events.get('e1')?.title).toBe('Later'); + expect([...s.server.events.values()].filter((event) => event.title === 'Fresh')).toHaveLength(1); + }); +}); + +describe('a lost answer on an edit', () => { + const series = (server: FakeCalendarJmap) => server.addEvent({ + id: 's', + title: 'Weekly', + description: 'Series', + start: '2026-11-03T10:00:00', + recurrenceRule: WEEKLY, + participants: { + me: { '@type': 'Participant', calendarAddress: 'mailto:a@example.org', participationStatus: 'needs-action', roles: { attendee: true } }, + boss: { '@type': 'Participant', calendarAddress: 'mailto:boss@example.org', participationStatus: 'accepted', roles: { owner: true } }, + }, + }); + + for (const version of ['1.0', '0.16'] as const) { + it(`an occurrence answer that read back changed on ${version} is not a conflict, and isn't sent twice`, async () => { + const s = await synced(version, series); + const master = s.server.events.get('s') as unknown as { participants: Record> } & Record; + const entry = { + title: 'Weekly', + description: 'Series', + start: K1, + duration: 'PT1H', + sequence: 0, + participants: { ...master.participants, me: { ...master.participants.me, participationStatus: 'accepted' } }, + }; + await write(s, { update: { s: { recurrenceOverrides: { [K1]: entry } } }, sendSchedulingMessages: true }); + s.server.loseNextSetResponse = true; + expect(await flush(s)).toMatchObject({ pending: 1 }); + expect(await flush(s)).toMatchObject({ conflicts: 0, pending: 0 }); + expect(await conflicts(s)).toEqual([]); + expect(sets(s.server)).toHaveLength(1); + }); + + it(`a change to another override on ${version} is not a conflict on this one`, async () => { + const s = await synced(version, (server) => { + series(server); + server.patchEvent('s', { recurrenceOverrides: { [K1]: { title: 'One' }, [K2]: { title: 'Two' } } }); + }); + await write(s, { update: { s: { [`recurrenceOverrides/${K1}`]: { title: 'Mine' } } } }); + s.server.patchEvent('s', { [`recurrenceOverrides/${K2}`]: { title: 'Theirs' } }); + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect((s.server.events.get('s')?.recurrenceOverrides as Record)[K1].title).toBe('Mine'); + }); + } + + it('an edit made after the lost one reads the lost value as ours, and goes out', async () => { + const s = await synced(); + await write(s, { update: { e1: { title: 'First' } } }); + s.server.loseNextSetResponse = true; + await flush(s); + await write(s, { update: { e1: { title: 'Second' } } }); + await flush(s); + expect(await conflicts(s)).toEqual([]); + expect(s.server.events.get('e1')?.title).toBe('Second'); + }); + + it('drops an RSVP whose value the server already holds, without sending', async () => { + const s = await synced('1.0', (server) => server.addEvent({ id: 'r', participants: { me: { participationStatus: 'accepted' } } })); + await write(s, { update: { r: { 'participants/me/participationStatus': 'accepted' } }, sendSchedulingMessages: true }); + await flush(s); + expect(sets(s.server)).toEqual([]); + expect(pending(s)).toEqual([]); + expect(await conflicts(s)).toEqual([]); + }); + + it('drops it during sync too when someone answered the same from elsewhere', async () => { + const s = await synced('1.0', (server) => server.addEvent({ id: 'r', participants: { me: { participationStatus: 'needs-action' } } })); + await write(s, { update: { r: { 'participants/me/participationStatus': 'accepted' } }, sendSchedulingMessages: true }); + s.server.patchEvent('r', { 'participants/me/participationStatus': 'accepted' }); + await flush(s); + expect(sets(s.server)).toEqual([]); + }); +}); + +describe('sync finding a created event', () => { + it('adopts the stand-in by uid during the delta, without a flush', async () => { + const s = await synced(); + const id = (await write(s, { create: { c: NEW } })).created.c.id as string; + s.server.loseNextSetResponse = true; + await flush(s); + expect(await query(s)).toHaveLength(2); + // Only the delta gets through: no lookup by uid, no writes. + const real = s.server.request.bind(s.server); + s.server.request = async (calls: MethodCall[]) => { + if (calls.some(([name, args]) => (name === 'CalendarEvent/set' && !(args.update && 'h333333' in (args.update as object))) + || (name === 'CalendarEvent/query' && (args.filter as { uid?: string } | undefined)?.uid))) { + throw new TransportError('network', 'Failed to fetch'); + } + return real(calls); + }; + await s.engine.requestSync(); + const ids = await query(s); + expect(ids).toHaveLength(2); + expect(ids.filter((value) => value.startsWith('local-'))).toEqual([]); + expect(pending(s)).toEqual([]); + expect((await read(s, id))?.id).toBe(ids.find((value) => value !== 'e1')); + }); +}); + +describe('a full list', () => { + it('keeps a live event the paging skipped, and drops only what is really gone', async () => { + const s = setup(); + for (let i = 0; i < 620; i += 1) s.server.addEvent({ id: `e${String(i).padStart(3, '0')}` }); + await s.engine.requestSync(); + // The stored state can't be used, so this pass lists everything again. + s.db.run("UPDATE sync_state SET state = 'not usable!' WHERE type = 'CalendarEvent'"); + let pages = 0; + const real = s.server.request.bind(s.server); + s.server.request = async (calls: MethodCall[]) => { + const answer = await real(calls); + if (calls.some(([name]) => name === 'CalendarEvent/query') && (pages += 1) === 1) { + // A delete before the page boundary moves one event across it. + s.server.removeEvent('e000'); + } + return answer; + }; + await s.engine.requestSync(); + const ids = new Set(s.db.all<{ id: string }>('SELECT id FROM calendar_event').map((row) => row.id)); + expect(ids.has('e000')).toBe(false); + expect(ids.has('e500')).toBe(true); + expect(ids.size).toBe(619); + }); +}); + +describe('flush around other accounts', () => { + const session: StoreSession = { + ...CALENDAR_SESSION, + accounts: { + ...CALENDAR_SESSION.accounts, + shared: { name: 'shared', isPersonal: false, accountCapabilities: { [CAL]: {} } }, + }, + }; + + it('does not touch the network for a never-synced account once stopped', async () => { + const s = setup(); + await write(s, { create: { c: NEW } }); + s.engine.setOnline(false); + const before = s.server.requests; + await flush(s); + expect(s.server.requests).toBe(before); + expect(pending(s)).toHaveLength(1); + }); + + it('goes on with the next account after one answers accountNotFound', async () => { + const s = setup('1.0', session); + s.server.addEvent({ id: 'e1', title: 'Meeting' }); + await s.engine.requestSync(); + // The primary account comes first and fails, the shared one must still go. + await write(s, { update: { e1: { title: 'Primary' } } }); + await write(s, { update: { e1: { title: 'Shared' } } }, 'shared'); + const real = s.server.request.bind(s.server); + s.server.request = async (calls: MethodCall[]) => { + if (calls[0][1].accountId === JMAP_ACCOUNT && calls[0][0] === 'CalendarEvent/changes') { + return { methodResponses: [['error', { type: 'accountNotFound' }, calls[0][2]]] }; + } + return real(calls); + }; + await flush(s); + expect(s.server.events.get('e1')?.title).toBe('Shared'); + expect(pending(s).map((row) => JSON.parse(row.patch))).toEqual([{ title: 'Primary' }]); + }); + + it('forgets an account the session no longer lists, and its reminders', async () => { + const s = setup('1.0', session); + s.server.addEvent({ + id: 'e1', + start: '2026-11-10T10:00:00', + alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } }, + }); + await s.engine.requestSync(); + const options = { from: Date.UTC(2026, 10, 10), to: Date.UTC(2026, 10, 11), timeZone: 'Europe/Berlin' }; + expect(listUpcomingAlerts(s.db, options).map((alert) => alert.jmap).sort()).toEqual([JMAP_ACCOUNT, 'shared'].sort()); + s.engine.setSession(CALENDAR_SESSION); + await s.engine.requestSync(); + expect(s.db.all("SELECT 1 FROM calendar_event WHERE jmap = 'shared'")).toEqual([]); + expect(s.db.all("SELECT 1 FROM object WHERE jmap = 'shared'")).toEqual([]); + expect(listUpcomingAlerts(s.db, options).map((alert) => alert.jmap)).toEqual([JMAP_ACCOUNT]); + }); +}); + +describe('reminder keys', () => { + it('stay the same when a stand-in becomes real', async () => { + const s = await synced(); + await write(s, { create: { c: { ...NEW, alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } } } } }); + const options = { from: Date.UTC(2026, 10, 20), to: Date.UTC(2026, 10, 21), timeZone: 'Europe/Berlin' }; + const [before] = listUpcomingAlerts(s.db, options); + await flush(s); + const [after] = listUpcomingAlerts(s.db, options); + expect(before.baseEventId).toMatch(/^local-/); + expect(after.baseEventId).not.toMatch(/^local-/); + expect(after.key).toBe(before.key); + }); + + it('differ between two accounts holding the same id', async () => { + const s = setup('1.0', { + ...CALENDAR_SESSION, + accounts: { ...CALENDAR_SESSION.accounts, shared: { name: 'shared', isPersonal: false, accountCapabilities: { [CAL]: {} } } }, + }); + s.server.addEvent({ id: 'e1', start: '2026-11-10T10:00:00', alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } } }); + await s.engine.requestSync(); + expect(listUpcomingAlerts(s.db, { from: Date.UTC(2026, 10, 10), to: Date.UTC(2026, 10, 11), timeZone: 'Europe/Berlin' })).toHaveLength(2); + }); +}); + +describe('creates through the real client', () => { + let w: Wire; + afterEach(() => w?.restore()); + + async function online() { + w = wire(new FakeCalendarJmap('1.0'), [calendarArea], NOW); + await w.engine.requestSync(); + return w.newClient(); + } + const created = () => w.sent.flatMap(([name, args]) => (name === 'CalendarEvent/set' ? Object.values((args.create ?? {}) as Record) : [])); + + it('createCalendarEvent has a uid on its first send, and a lost answer does not duplicate', async () => { + const client = await online(); + w.server.loseNextSetResponse = true; + const event = await client.createCalendarEvent({ ...NEW } as Partial); + expect(created()[0].uid).toBeTruthy(); + expect(event.id).toMatch(/^local-/); + expect(w.server.events.size).toBe(1); + await flush(w); + expect(w.server.events.size).toBe(1); + expect(await conflicts(w)).toEqual([]); + expect(pending(w)).toEqual([]); + }); + + it('batchCreateCalendarEvents does the same, which covers import', async () => { + const client = await online(); + w.server.loseNextSetResponse = true; + const result = await client.batchCreateCalendarEvents([{ ...NEW } as Partial, { ...NEW, title: 'Two' } as Partial]); + expect(result.created).toHaveLength(2); + expect(created().every((event) => event.uid)).toBe(true); + expect(w.server.events.size).toBe(2); + await flush(w); + expect(w.server.events.size).toBe(2); + expect(await conflicts(w)).toEqual([]); + }); + + it('createCalendarTask does the same', async () => { + const client = await online(); + w.server.loseNextSetResponse = true; + await client.createCalendarTask({ title: 'Task', calendarIds: { cal1: true }, due: '2026-12-01T10:00:00' }); + expect(created()[0].uid).toBeTruthy(); + await flush(w); + expect(w.server.events.size).toBe(1); + }); + + it('keeps a uid the caller chose', async () => { + const client = await online(); + await client.createCalendarEvent({ ...NEW, uid: 'chosen@example.org' } as Partial); + expect(created()[0].uid).toBe('chosen@example.org'); + }); +}); diff --git a/lib/store/__tests__/helpers/client-wire.ts b/lib/store/__tests__/helpers/client-wire.ts index d3c9860b7..f46fb343f 100644 --- a/lib/store/__tests__/helpers/client-wire.ts +++ b/lib/store/__tests__/helpers/client-wire.ts @@ -5,9 +5,10 @@ import { vi } from 'vitest'; import { JMAPClient } from '@/lib/jmap/client'; +import { setOfflineHooks, type OfflineMethodCall, type OfflineResponse } from '@/lib/jmap/offline-hooks'; import { DEFAULT_SCOPE, type StoreArea } from '../../area'; import { AccountEngine } from '../../engine/account-engine'; -import type { MethodCall } from '../../engine/transport'; +import { TransportError, type MethodCall } from '../../engine/transport'; import { CALENDAR_SESSION, FakeCalendarJmap } from './fake-calendar'; import { JMAP_ACCOUNT } from './fake-jmap'; import { openMemoryStore } from './node-adapter'; @@ -65,15 +66,36 @@ export function wire(server: FakeCalendarJmap, areas: readonly StoreArea[], now: await client.connect(); return client; }, - restore: () => spy.mockRestore(), + restore: () => { + spy.mockRestore(); + setOfflineHooks(null); + }, }; 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); + if (!state.online) throw new TypeError('Failed to fetch'); + try { + return json(await server.request(calls)); + } catch (error) { + if (error instanceof TransportError && error.kind === 'network') throw new TypeError('Failed to fetch'); + throw error; + } + }); + setOfflineHooks({ + skipNetwork: () => !state.online, + answer: async (_client, calls: OfflineMethodCall[]) => { + state.sent.push(...calls); + return engine.answer(calls) as Promise; + }, + record: () => {}, + noteNetwork: () => {}, + serverGone: async () => true, + blob: async () => null, + recordBlob: () => {}, + holdUpload: async () => null, + resolveStandIns: (_client, ids) => engine.areaCall('calendar.resolveStandIns', { ids }) as Promise>, }); return state; } diff --git a/lib/store/__tests__/helpers/fake-calendar.ts b/lib/store/__tests__/helpers/fake-calendar.ts index 752017d57..795f42b50 100644 --- a/lib/store/__tests__/helpers/fake-calendar.ts +++ b/lib/store/__tests__/helpers/fake-calendar.ts @@ -12,6 +12,10 @@ * 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. + * - Overrides read back differently from what was written. 1.0 keeps only + * what differs from the series, a participant as a pointer key. 0.16 keeps + * the entry, adds `updated` to every entry on any change of the series, and + * drops an attendee-only `roles` from participants. */ import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization'; @@ -38,6 +42,39 @@ export const CALENDAR_SESSION: StoreSession = { export type FakeEvent = Record & { id: string }; export type StalwartVersion = '1.0' | '0.16' | '0.16.19'; +/** One override as the server hands it back (measured on both, 2026-11-03). */ +function readBack(version: StalwartVersion, master: FakeEvent, key: string, entry: Record, tick: number): Record { + if (entry.excluded) return entry; + if (version === '1.0') { + const out: Record = {}; + for (const [name, value] of Object.entries(entry)) { + if (name === 'updated') continue; + if (name === 'start' && value === key) continue; + if (name === 'participants') { + const series = (master.participants ?? {}) as Record>; + for (const [id, person] of Object.entries(value as Record>)) { + for (const [field, fieldValue] of Object.entries(person)) { + if (JSON.stringify(series[id]?.[field]) !== JSON.stringify(fieldValue)) out[`participants/${id}/${field}`] = fieldValue; + } + } + continue; + } + if (name !== 'sequence' && JSON.stringify(master[name]) === JSON.stringify(value)) continue; + out[name] = value; + } + return out; + } + const out: Record = { ...entry, updated: `2026-11-03T00:00:${String(tick % 60).padStart(2, '0')}Z` }; + if (out.participants) { + out.participants = Object.fromEntries(Object.entries(out.participants as Record>).map(([id, person]) => { + const { roles, ...rest } = person; + const attendeeOnly = roles && Object.keys(roles).length === 1 && (roles as Record).attendee; + return [id, attendeeOnly ? rest : person]; + })); + } + return out; +} + function jmapError(type: string, description?: string): Error { return Object.assign(new Error(type), { jmapError: type, description }); } @@ -93,7 +130,13 @@ export class FakeCalendarJmap extends FakeJmap { /** Someone else changes an event. */ patchEvent(id: string, patch: Record): void { - this.events.set(id, applyPatchObject(this.events.get(id) as FakeEvent, patch)); + const next = applyPatchObject(this.events.get(id) as FakeEvent, patch); + if (next.recurrenceOverrides) { + const tick = this.eventLog.log.length; + next.recurrenceOverrides = Object.fromEntries(Object.entries(next.recurrenceOverrides as Record>) + .map(([key, entry]) => [key, readBack(this.version, next, key, entry, tick)])); + } + this.events.set(id, next); this.eventLog.push('updated', id); } diff --git a/lib/store/calendar/flush.ts b/lib/store/calendar/flush.ts index b4266a473..550930279 100644 --- a/lib/store/calendar/flush.ts +++ b/lib/store/calendar/flush.ts @@ -1,22 +1,26 @@ /** - * Sends pending calendar writes, one event per request, so a lost response - * leaves one event's outcome open and not a batch's. + * Sends pending calendar writes, one write per request, so a lost response leaves + * one write's outcome open and not a batch's. An event's writes go in order. * - * 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. + * A row is marked before it goes out. A create so marked first looks for its uid + * on the server, because repeating it could duplicate the event. A marked edit + * whose values read back from the server is done, and one that's repeated writes + * the same values. A delete sent twice finds nothing the second time. + * + * A write that can't work goes to the conflict list and the others still go out. + * Anything that may pass stays pending: an answer with no verdict for it, a set + * error such as serverFail or rateLimit, and for the whole run a network failure, + * 401, 429 or 5xx. */ 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 { isOverrideSeriesKeyRejection, withoutOverrideSeriesKeys } from '@/lib/recurrence-instances'; 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'; +import { JmapMethodError, responseFor, TransportError } from '../engine/transport'; +import { holdsPatch, patchForServer } from './patches'; +import { calendarUsing, EVENT_PROPERTIES, eventRow, type CalendarObject } from './records'; +import { adoptStandIn, finishWrite, markSent, pendingBySeq, recordConflict, type PendingRow } from './writes'; export interface FlushTally { sent: number; @@ -45,7 +49,16 @@ export function isPassingError(error: unknown): boolean { return error instanceof JmapMethodError && PASSING_ERRORS.has(error.type); } -type Outcome = 'sent' | 'conflict' | 'again'; +function passes(refusal: SetError | undefined): boolean { + return !refusal || PASSING_ERRORS.has(refusal.type ?? ''); +} + +/** A 4xx other than 401 and 429, which the transport reports as their own kinds: this request won't work. */ +function isRefusedRequest(error: unknown): boolean { + return error instanceof TransportError && error.kind === 'http' && (error.status ?? 0) >= 400 && (error.status ?? 0) < 500; +} + +type Outcome = 'sent' | 'conflict' | 'again' | 'retry'; async function sendSet(ctx: SyncContext, row: PendingRow, args: Record): Promise { const response = await ctx.transport.request([['CalendarEvent/set', { @@ -73,157 +86,116 @@ async function fetchEvent(ctx: SyncContext, jmap: string, id: string): Promise, 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'; - }); +function conflict(ctx: SyncContext, row: PendingRow, reason: 'refused' | 'destroyedOnServer', error: SetError): Outcome { + ctx.db.transaction(() => recordConflict(ctx, row, reason, error)); + return 'conflict'; } 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])); + markSent(ctx, row, payload); 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 } }); + result = await sendSet(ctx, row, { create: { c: withoutOverrideSeriesKeys(payload as Partial) } }); } 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'; + // Both Stalwarts refuse a second event with one uid. Nothing of ours went + // in then, so what the server holds is compared as a whole. + if (refusal?.type === 'invalidProperties' && refusal.properties?.includes('uid')) { + ctx.db.transaction(() => ctx.db.run('UPDATE calendar_pending SET sent = NULL WHERE seq = ?', [row.seq])); + realId = await findByUid(ctx, row.jmap, row.uid); + } + if (!realId) return passes(refusal) ? 'retry' : conflict(ctx, row, 'refused', refusal as SetError); } } 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'; - }); + const left = ctx.db.transaction(() => adoptStandIn(ctx, row, event)); + return left ? 'again' : 'sent'; } async function sendUpdate(ctx: SyncContext, row: PendingRow): Promise { - let patch = JSON.parse(row.patch) as Record; const id = row.record_id; + const stored = eventRow(ctx.db, row.jmap, id); + const server = JSON.parse(stored?.server_json ?? stored?.json ?? '{}') as Record; + const queued = JSON.parse(row.patch) as Record; + let patch = patchForServer(queued, server); + // Nothing to change, and an answer sent twice would mail the organizer twice. + if (holdsPatch(server, queued) || Object.keys(patch).length === 0) { + return ctx.db.transaction(() => finishWrite(ctx, row, {})) ? 'again' : 'sent'; + } + markSent(ctx, row, patch); 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); + if (refusal && !passes(refusal)) return conflict(ctx, row, refusal.type === 'notFound' ? 'destroyedOnServer' : 'refused', refusal); + if (refusal || !result.updated || !(id in result.updated)) return 'retry'; + return ctx.db.transaction(() => finishWrite(ctx, row, patch)) ? 'again' : 'sent'; } async function sendDestroy(ctx: SyncContext, row: PendingRow): Promise { - // A create turned delete while its create was out: find out what the server has. + // A create turned delete: only the server knows whether the create got there. const id = row.uid ? await findByUid(ctx, row.jmap, row.uid) : row.record_id; - const finish = () => ctx.db.transaction(() => { + const finish = (): Outcome => ctx.db.transaction(() => { ctx.db.run('DELETE FROM calendar_pending WHERE seq = ?', [row.seq]); - deleteEventRow(ctx.db, row.jmap, row.record_id); + ctx.db.run('DELETE FROM calendar_event WHERE jmap = ? AND id = ?', [row.jmap, row.record_id]); ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] }); + return 'sent' as const; }); - if (!id) { - finish(); - return 'sent'; - } + if (!id) return finish(); 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'; + if (refusal?.type === 'notFound' || result.destroyed?.includes(id)) return finish(); + return passes(refusal) ? 'retry' : conflict(ctx, row, 'refused', refusal as SetError); +} + +function conflictCount(ctx: SyncContext): number { + return Number(ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM calendar_conflict')?.n ?? 0); } /** - * 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. + * Sends what's pending for one JMAP account. Network failures and a server's bad + * moment are thrown, with the rest still pending. */ export async function sendPending(ctx: SyncContext, jmap: string): Promise { const tally: FlushTally = { sent: 0, conflicts: 0 }; const finished = new Set(); + // An event whose write has to wait: its later writes wait with it. + const waiting = 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))); + .find((candidate) => !finished.has(Number(candidate.seq)) && !waiting.has(candidate.record_id)); if (!row) return tally; + const conflictsBefore = conflictCount(ctx); 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'; + const current = pendingBySeq(ctx.db, row.seq) ?? row; + if (isRefusedRequest(error)) { + outcome = conflict(ctx, current, 'refused', { type: 'http', description: (error as Error).message }); + } else if (error instanceof JmapMethodError && !isPassingError(error)) { + outcome = conflict(ctx, current, 'refused', { type: error.type, description: error.message }); + } else { + throw error; + } } if (outcome === 'again') continue; + if (outcome === 'retry') { + waiting.add(row.record_id); + continue; + } finished.add(Number(row.seq)); if (outcome === 'sent') tally.sent += 1; - else tally.conflicts += 1; + else tally.conflicts += conflictCount(ctx) - conflictsBefore; } } diff --git a/lib/store/calendar/index.ts b/lib/store/calendar/index.ts index da9115b78..12d908660 100644 --- a/lib/store/calendar/index.ts +++ b/lib/store/calendar/index.ts @@ -2,7 +2,7 @@ import type { StoreArea } from '../area'; import { CALENDAR_METHODS } from './methods'; import { CALENDAR_MIGRATIONS } from './schema'; import { calendarSyncer, flushCalendar } from './sync'; -import { listConflicts } from './writes'; +import { listConflicts, resolveStandIns } from './writes'; export const calendarArea: StoreArea = { name: 'calendar', @@ -12,6 +12,7 @@ export const calendarArea: StoreArea = { calls: { 'calendar.flush': (ctx) => flushCalendar(ctx), 'calendar.conflicts': (ctx) => listConflicts(ctx.db), + 'calendar.resolveStandIns': (ctx, params: { ids: string[] }) => resolveStandIns(ctx.db, params.ids), 'calendar.dismissConflict': (ctx, params: { id: number }) => { ctx.db.run('DELETE FROM calendar_conflict WHERE id = ?', [params.id]); }, diff --git a/lib/store/calendar/patches.ts b/lib/store/calendar/patches.ts new file mode 100644 index 000000000..8fb751de3 --- /dev/null +++ b/lib/store/calendar/patches.ts @@ -0,0 +1,141 @@ +/** + * Reading a PatchObject against the server's copy of an event. + * + * What goes in doesn't read back the same (measured on both Stalwarts, + * 2026-11-03). 1.0 keeps only what an override changes against its series, a + * participant as a pointer key such as `participants/me/participationStatus`. + * 0.16 keeps the whole entry, moves `updated` in every entry on any change of the + * series, and drops an attendee-only `roles`. Both drop `@type` from a recurrence + * rule and add `iCalendar` to a location. So a value counts as held when the + * server's has what was sent, and an override is read as its occurrence. + */ + +import { applyPatchObject } from '@/lib/jmap/patch-object'; +import { pointerToken, pointerTokenValue } from '@/lib/jmap/patch-pointer'; +import { applyOverridePatch, stableJson } from '@/lib/recurrence-instances'; +import { valueAtPointer } from './records'; + +type Patch = Record; + +const OVERRIDE_POINTER = /^recurrenceOverrides\/([^/]+)(?:\/(.+))?$/; +const IGNORED_MEMBERS = new Set(['@type']); + +function isEmptyDefault(value: unknown): boolean { + if (value === null || value === undefined || value === false || value === 0 || value === '') return true; + return typeof value === 'object' && Object.keys(value as object).length === 0; +} + +/** Whether `actual` has what `sent` says. Extra members in `actual` don't matter. */ +function contains(actual: unknown, sent: unknown, member?: string): boolean { + if (actual === null || actual === undefined) { + if (isEmptyDefault(sent)) return true; + return member === 'roles' && stableJson(sent) === stableJson({ attendee: true }); + } + if (Array.isArray(sent)) { + return Array.isArray(actual) && actual.length === sent.length && sent.every((item, i) => contains(actual[i], item)); + } + if (sent !== null && typeof sent === 'object') { + if (typeof actual !== 'object' || Array.isArray(actual)) return false; + return Object.entries(sent).every(([name, value]) => IGNORED_MEMBERS.has(name) + || contains((actual as Record)[name], value, name)); + } + return actual === sent; +} + +function overrideEntry(server: Patch, key: string): Patch | undefined { + return (server.recurrenceOverrides as Record | null | undefined)?.[key]; +} + +/** Whether the server's copy holds `value` at PatchObject key `key`. */ +export function holdsValue(server: Patch, key: string, value: unknown): boolean { + if (key === 'recurrenceOverrides') { + const entries = Object.entries((value ?? {}) as Record); + if (entries.length === 0) return Object.keys((server.recurrenceOverrides ?? {}) as object).length === 0; + return entries.every(([id, entry]) => holdsValue(server, `recurrenceOverrides/${pointerToken(id)}`, entry)); + } + const match = OVERRIDE_POINTER.exec(key); + if (!match) return contains(valueAtPointer(server, key), value); + const id = pointerTokenValue(match[1]); + const entry = overrideEntry(server, id); + const inEntry = match[2]; + if (value === null && !inEntry) return !entry; + if (!entry) return false; + if (!inEntry && (entry.excluded || (value as Patch).excluded)) return Boolean(entry.excluded) === Boolean((value as Patch).excluded); + // `start` as the rule puts it: a server leaves it out of an unmoved override. + const occurrence = applyOverridePatch({ ...server, start: id } as Patch, entry); + return contains(inEntry ? valueAtPointer(occurrence, inEntry) : occurrence, value); +} + +/** Whether a row's write is already on the server, whoever put it there. */ +export function holdsPatch(server: Patch, patch: Patch): boolean { + return Object.entries(patch).every(([key, value]) => holdsValue(server, key, value)); +} + +// `updated` inside an override moves on 0.16 when any part of the series changes. +function comparable(key: string, value: unknown): string { + if (OVERRIDE_POINTER.test(key) && value && typeof value === 'object') { + const { updated: _updated, ...rest } = value as Patch; + return stableJson(rest); + } + return stableJson(value); +} + +export interface PatchState { + patch: Patch; + /** The server's value at each key when this was queued. */ + base: Patch; + /** Values of ours that may have reached the server: a send whose answer was lost, an earlier row of the same event. */ + sent: Patch | null; +} + +/** Whether the server moved a value this patch writes, to something that isn't ours. */ +export function collides({ patch, base, sent }: PatchState, server: Patch): boolean { + return Object.keys(patch).some((key) => { + if (holdsValue(server, key, patch[key])) return false; + if (sent && key in sent && holdsValue(server, key, sent[key])) return false; + return comparable(key, valueAtPointer(server, key)) !== comparable(key, base[key]); + }); +} + +/** + * A whole overrides map becomes one key per entry that differs from what the + * writer could see. Written whole, it would erase any override someone else + * added while the write waited; this way that one collides only on the same entry. + */ +export function asOverridePointers(patch: Patch, shown: Patch): Patch { + if (!('recurrenceOverrides' in patch)) return patch; + const { recurrenceOverrides: map, ...rest } = patch; + const entries = (map ?? {}) as Record; + const known = (shown.recurrenceOverrides ?? {}) as Record; + const out: Patch = { ...rest }; + for (const [id, entry] of Object.entries(entries)) { + if (stableJson(entry) !== stableJson(known[id])) out[`recurrenceOverrides/${pointerToken(id)}`] = entry; + } + for (const id of Object.keys(known)) { + if (!(id in entries)) out[`recurrenceOverrides/${pointerToken(id)}`] = null; + } + return out; +} + +/** + * The patch as it can go out now. A pointer into overrides fails while a series + * has none, on both Stalwarts, so they go as a whole map then, which erases + * nothing. Removing an entry the server doesn't have is nothing to send. + */ +export function patchForServer(patch: Patch, server: Patch): Patch { + const pointers = Object.keys(patch).filter((key) => OVERRIDE_POINTER.test(key)); + if (pointers.length === 0) return patch; + const out: Patch = {}; + for (const [key, value] of Object.entries(patch)) if (!OVERRIDE_POINTER.test(key)) out[key] = value; + if (Object.keys((server.recurrenceOverrides ?? {}) as object).length === 0) { + const built = applyPatchObject({ recurrenceOverrides: {} }, Object.fromEntries(pointers.map((key) => [key, patch[key]]))); + if (Object.keys(built.recurrenceOverrides).length > 0) out.recurrenceOverrides = built.recurrenceOverrides; + return out; + } + for (const key of pointers) { + const entry = overrideEntry(server, pointerTokenValue((OVERRIDE_POINTER.exec(key) as RegExpExecArray)[1])); + if (patch[key] === null && !entry) continue; + out[key] = patch[key]; + } + return out; +} diff --git a/lib/store/calendar/reminders.ts b/lib/store/calendar/reminders.ts index 4ab5e3227..b42307346 100644 --- a/lib/store/calendar/reminders.ts +++ b/lib/store/calendar/reminders.ts @@ -2,9 +2,10 @@ * 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. + * Keys use the series' uid and the fire time, never an id. Synthetic ids can + * change, browser occurrences don't exist on the server, and a stand-in id becomes + * a real one. A moved occurrence gets a new key, as it should: it's a new time to + * remind. */ import { format, parseISO } from 'date-fns'; @@ -99,6 +100,8 @@ export function listUpcomingAlerts(db: StorageAdapter, options: UpcomingAlertsOp const calendars = calendarsOf.get(row.jmap)!; const object = JSON.parse(row.json) as CalendarObject; const calendar = firstCalendar(object.calendarIds, calendars); + // With the account: Stalwart's ids repeat across accounts, and one meeting can be in two. + const stableId = `${row.jmap}:${typeof object.uid === 'string' && object.uid ? object.uid : object.id}`; if (isTaskLikeObject(object)) { const task = object as unknown as CalendarTask; @@ -108,7 +111,7 @@ export function listUpcomingAlerts(db: StorageAdapter, options: UpcomingAlertsOp const fireTimeMs = computeTaskFireTime(task, alert.trigger); if (fireTimeMs === null) continue; add({ - key: buildAlertKey(object.id, alertId, fireTimeMs), + key: buildAlertKey(stableId, alertId, fireTimeMs), jmap: row.jmap, kind: 'task', baseEventId: object.id, @@ -140,7 +143,7 @@ export function listUpcomingAlerts(db: StorageAdapter, options: UpcomingAlertsOp const fireTimeMs = computeFireTime(occurrence, alert.trigger); if (fireTimeMs === null) continue; add({ - key: buildAlertKey(object.id, alertId, fireTimeMs), + key: buildAlertKey(stableId, alertId, fireTimeMs), jmap: row.jmap, kind: 'event', baseEventId: object.id, diff --git a/lib/store/calendar/schema.ts b/lib/store/calendar/schema.ts index 1d56d505e..3bf7e82ba 100644 --- a/lib/store/calendar/schema.ts +++ b/lib/store/calendar/schema.ts @@ -29,7 +29,7 @@ export const CALENDAR_MIGRATIONS: readonly Migration[] = [ 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). + -- An event's writes in order. A later one merges when the flags match (see writes.ts). CREATE TABLE calendar_pending ( seq INTEGER PRIMARY KEY AUTOINCREMENT, jmap TEXT NOT NULL, @@ -43,13 +43,17 @@ export const CALENDAR_MIGRATIONS: readonly Migration[] = [ 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. + -- 1 once it went out, or when the server may hold it for another reason. attempted INTEGER NOT NULL DEFAULT 0, + -- What went out last, or for a later row what its earlier rows wrote: + -- values of ours the server may hold. + sent TEXT, -- 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) + queued_at INTEGER NOT NULL ); + CREATE INDEX calendar_pending_record ON calendar_pending (jmap, record_id, seq); + CREATE INDEX calendar_pending_uid ON calendar_pending (jmap, uid); -- What a stand-in id became, for a page still holding the stand-in. CREATE TABLE calendar_id_map ( diff --git a/lib/store/calendar/sync.ts b/lib/store/calendar/sync.ts index 8463a0896..71ad94a0c 100644 --- a/lib/store/calendar/sync.ts +++ b/lib/store/calendar/sync.ts @@ -26,7 +26,7 @@ import { isSeries, type CalendarObject, } from './records'; -import { applyServerDestroy, applyServerEvent, isStandIn, pendingFor } from './writes'; +import { applyServerDestroy, applyServerEvent, isStandIn, pendingChain, recordConflict, type PendingRow } from './writes'; const PAGE = 500; /** How far back single events are kept. A series stays whatever its start, a task whatever its date. */ @@ -106,7 +106,8 @@ async function startFullList(ctx: SyncContext, jmap: string): Promise { 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 } }); - } + if (!done) ctx.setSyncState(jmap, 'CalendarEvent', { cursor: { ...cursor } }); }); + if (done && !(await finishFullList(ctx, jmap, cursor))) return false; 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'] }); @@ -146,6 +141,36 @@ async function runFullList(ctx: SyncContext, jmap: string, start: ListCursor): P } } +/** + * A delete during paging moves later objects across a page boundary, so what this + * pass didn't see may still be there. Asks for it by id, and only what the server + * says it doesn't have goes. False when stopped. + */ +async function finishFullList(ctx: SyncContext, jmap: string, cursor: ListCursor): Promise { + const unseen = ctx.db.all<{ id: string }>('SELECT id FROM calendar_event WHERE jmap = ? AND gen < ?', [jmap, cursor.gen]) + .map((row) => row.id) + .filter((id) => !isStandIn(id)); + const found: CalendarObject[] = []; + const gone: string[] = []; + for (let i = 0; i < unseen.length; i += PAGE) { + if (ctx.stopped()) return false; + const response = await ctx.transport.request([['CalendarEvent/get', { + accountId: jmap, + ids: unseen.slice(i, i + PAGE), + properties: EVENT_PROPERTIES, + }, 'g']], calendarUsing(ctx.session)); + const [, result] = responseFor(response, 'g'); + found.push(...result.list as CalendarObject[]); + gone.push(...result.notFound as string[]); + } + ctx.db.transaction(() => { + for (const event of found) storeEvent(ctx, jmap, event, cursor.gen); + for (const id of gone) applyServerDestroy(ctx, jmap, id); + ctx.setSyncState(jmap, 'CalendarEvent', { state: cursor.captured, phase: null, cursor: null }); + }); + 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, @@ -181,6 +206,7 @@ async function syncDelta(ctx: SyncContext, jmap: string, since: string, target: /** False when stopped before catching up. */ async function syncEvents(ctx: SyncContext, jmap: string): Promise { + if (ctx.stopped()) return false; 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); @@ -207,6 +233,35 @@ function pruneOld(ctx: SyncContext, jmap: string): void { ); } +/** + * A share taken away: the session stops listing the account, and nothing of it + * stays on the device, reminders included. Its waiting writes can't go anywhere. + */ +function forgetUnlistedAccounts(ctx: SyncContext): void { + const listed = new Set(Object.keys(ctx.session.accounts)); + if (listed.size === 0) return; + const kept = ctx.db.all<{ jmap: string }>( + `SELECT jmap FROM calendar_event UNION SELECT jmap FROM calendar_pending + UNION SELECT jmap FROM object WHERE type IN ('Calendar', 'ParticipantIdentity')`, + ).map((row) => row.jmap); + for (const jmap of kept) { + if (listed.has(jmap)) continue; + ctx.db.transaction(() => { + for (;;) { + const row = ctx.db.get('SELECT * FROM calendar_pending WHERE jmap = ? ORDER BY seq LIMIT 1', [jmap]); + if (!row) break; + recordConflict(ctx, row, 'destroyedOnServer', { type: 'accountNotFound' }); + } + ctx.db.run('DELETE FROM calendar_event WHERE jmap = ?', [jmap]); + ctx.db.run('DELETE FROM calendar_id_map WHERE jmap = ?', [jmap]); + ctx.db.run("DELETE FROM object WHERE jmap = ? AND type IN ('Calendar', 'ParticipantIdentity')", [jmap]); + ctx.db.run("DELETE FROM sync_state WHERE jmap = ? AND type IN ('Calendar', 'CalendarEvent')", [jmap]); + metaSet(ctx.db, syntheticIdsKey(jmap), null); + }); + ctx.emit({ type: 'changed', account: ctx.account, collections: ['Calendar', 'CalendarEvent'] }); + } +} + // 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>(); @@ -229,6 +284,7 @@ export const calendarSyncer: Syncer = { name: 'calendar', capability: CALENDARS_CAPABILITY, async run(ctx, jmap) { + forgetUnlistedAccounts(ctx); if (!(await syncCalendars(ctx, jmap)) || ctx.stopped()) return; await oneAtATime(ctx.db, () => syncAndSend(ctx, jmap)); pruneOld(ctx, jmap); @@ -247,16 +303,20 @@ export interface FlushResult extends FlushTally { */ export function flushCalendar(ctx: SyncContext): Promise { return oneAtATime(ctx.db, async () => { + forgetUnlistedAccounts(ctx); 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) { + for (const jmap of accounts) { + try { const tally = await syncAndSend(ctx, jmap); total.sent += tally.sent; total.conflicts += tally.conflicts; + } catch (error) { + // No network, no credentials: no point in trying the other accounts. + if (error instanceof TransportError && (error.kind === 'network' || error.kind === 'auth')) break; + // Anything else is about this account, whose writes stay pending. + if (!(error instanceof TransportError) && !(error instanceof JmapMethodError) && !isPassingError(error)) throw error; } - } 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 index 80d6b005b..d47ff1eca 100644 --- a/lib/store/calendar/writes.ts +++ b/lib/store/calendar/writes.ts @@ -1,19 +1,24 @@ /** * 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. + * An event has a chain of pending rows, sent in order. A later write merges into + * the last row when both have the same scheduling flag: edit + edit is one patch, + * create + edit is one create. Different flags stay apart, because they mean + * different things to attendees. The change to one occurrence that must reach + * those who answered is a quiet write and then one with messages, and a change + * made with "don't notify" must not go out with a later answer that does. * - * 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. + * Answers read calendar_event.json, which is the server's copy with the chain + * applied. server_json keeps the server's copy underneath, so sync can move it on + * and reapply the chain, or notice that the two collide. */ import { applyPatchObject } from '@/lib/jmap/patch-object'; +import { isStandInId, newStandInId } from '@/lib/jmap/stand-in-ids'; import { mergeOverrideEntry, stableJson } from '@/lib/recurrence-instances'; import type { StoreContext } from '../area'; import type { StorageAdapter } from '../storage'; +import { asOverridePointers, collides, holdsValue } from './patches'; import { currentGen, deleteEventRow, eventRow, valueAtPointer, writeEvent, type CalendarObject, type EventRow } from './records'; export interface PendingRow { @@ -26,6 +31,7 @@ export interface PendingRow { uid: string | null; send_scheduling: number | null; attempted: number; + sent: string | null; rev: number; queued_at: number; } @@ -48,14 +54,15 @@ export interface CalendarConflict { createdAt: number; } -const STAND_IN_PREFIX = 'local-'; +export const isStandIn = isStandInId; -export function isStandIn(id: string): boolean { - return id.startsWith(STAND_IN_PREFIX); +/** An event's pending writes, in the order they go out. */ +export function pendingChain(db: StorageAdapter, jmap: string, id: string): PendingRow[] { + return db.all('SELECT * FROM calendar_pending WHERE jmap = ? AND record_id = ? ORDER BY seq', [jmap, id]); } -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]); +export function pendingBySeq(db: StorageAdapter, seq: number): PendingRow | undefined { + return db.get('SELECT * FROM calendar_pending WHERE seq = ?', [seq]); } /** What a stand-in id became, once its create reached the server. */ @@ -64,9 +71,21 @@ export function resolveId(db: StorageAdapter, jmap: string, id: string): string 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; +/** For the page's client: the real id, or null while it's still only on this device. */ +export function resolveStandIns(db: StorageAdapter, ids: readonly string[]): Record { + const out: Record = {}; + for (const id of ids) { + out[id] = db.get<{ server_id: string }>('SELECT server_id FROM calendar_id_map WHERE local_id = ?', [id])?.server_id ?? null; + } + return out; +} + +function flagOf(sendScheduling: boolean | undefined): number | null { + return sendScheduling === undefined ? null : Number(sendScheduling); +} + +function parse(json: string): Record { + return JSON.parse(json) as Record; } function serverCopy(row: EventRow): CalendarObject { @@ -77,103 +96,125 @@ 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. */ +/** Writes what answers show: `server` with the event's chain on top. */ +function rewriteShown(db: StorageAdapter, jmap: string, id: string, server: CalendarObject | null, gen: number): void { + const chain = pendingChain(db, jmap, id); + if (chain.length === 0) { + if (server) writeEvent(db, jmap, { shown: server, gen }); + else deleteEventRow(db, jmap, id); + return; + } + const row = eventRow(db, jmap, id); + let shown: CalendarObject | null = server ?? (row ? JSON.parse(row.json) as CalendarObject : null); + let hidden = false; + for (const pending of chain) { + if (pending.kind === 'create') shown = { ...parse(pending.patch), id, isOrigin: true }; + else if (pending.kind === 'update') shown = applyPatchObject(shown as CalendarObject, parse(pending.patch)); + else hidden = true; + } + if (shown) writeEvent(db, jmap, { shown, server, hidden, gen }); +} + +/** + * Creates a local event under a stand-in id. The uid is what finds it again. One + * the device chose can't be on the server yet. One from the caller might: the + * client's request may have got through before its answer was lost, or an import + * names an event that exists. That counts as attempted, so a flush looks first. + */ 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 id = newStandInId(); + const given = typeof event.uid === 'string' && event.uid ? event.uid : null; + const uid = given ?? 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()], + `INSERT INTO calendar_pending (jmap, record_id, kind, patch, uid, send_scheduling, attempted, queued_at) VALUES (?, ?, 'create', ?, ?, ?, ?, ?)`, + [jmap, id, JSON.stringify(payload), uid, flagOf(sendScheduling), given ? 1 : 0, ctx.now()], ); - writeEvent(ctx.db, jmap, { shown: { ...payload, id, isOrigin: true }, gen: currentGen(ctx.db, jmap) }); + rewriteShown(ctx.db, jmap, id, null, currentGen(ctx.db, jmap)); return { id, uid }; } +function baseFor(server: Record, patch: Record, earlier: Record): Record { + const base: Record = {}; + for (const key of Object.keys(patch)) base[key] = key in earlier ? earlier[key] : valueAtPointer(server, key) ?? null; + return base; +} + /** 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); +export function queueUpdate(ctx: StoreContext, jmap: string, id: string, rawPatch: Record, sendScheduling: boolean | undefined): boolean { + const { db } = ctx; + const row = eventRow(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], + const chain = pendingChain(db, jmap, id); + const last = chain.at(-1); + const flag = flagOf(sendScheduling); + const local = chain[0]?.kind === 'create'; + const server = local ? null : serverCopy(row); + if (last?.kind === 'create' && last.send_scheduling === flag) { + db.run( + 'UPDATE calendar_pending SET patch = ?, rev = rev + 1 WHERE seq = ?', + [JSON.stringify(applyPatchObject(parse(last.patch), rawPatch)), last.seq], ); - writeEvent(ctx.db, jmap, { shown: { ...payload, id, isOrigin: true } as CalendarObject, gen }); + rewriteShown(db, jmap, id, null, row.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], - ); + const patch = asOverridePointers(rawPatch, JSON.parse(row.json) as Record); + if (Object.keys(patch).length === 0) return true; + if (last?.kind === 'update' && last.send_scheduling === flag) { + const merged = mergeOverrideEntry(parse(last.patch), patch); + const base = baseFor(server ?? {}, merged, parse(last.base)); + db.run('UPDATE calendar_pending SET patch = ?, base = ?, rev = rev + 1 WHERE seq = ?', [JSON.stringify(merged), JSON.stringify(base), last.seq]); } else { - ctx.db.run( + 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()], + [jmap, id, JSON.stringify(patch), JSON.stringify(baseFor(server ?? {}, patch, {})), flag, ctx.now()], ); } - writeEvent(ctx.db, jmap, { shown: applyPatchObject(server, merged), server, gen }); + rewriteShown(db, jmap, id, server, row.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); + const { db } = ctx; + const row = eventRow(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); + const chain = pendingChain(db, jmap, id); + const flag = flagOf(sendScheduling); + const head = chain[0]; + if (head?.kind === 'create') { + db.run('DELETE FROM calendar_pending WHERE jmap = ? AND record_id = ? AND seq <> ?', [jmap, id, head.seq]); + if (!head.attempted) { + // It never left the device. + db.run('DELETE FROM calendar_pending WHERE seq = ?', [head.seq]); + deleteEventRow(db, jmap, id); + return true; + } + // It may be on the server. Its uid stays, so the delete can follow it. + db.run( + `UPDATE calendar_pending SET kind = 'destroy', patch = '{}', base = '{}', send_scheduling = ?, rev = rev + 1 WHERE seq = ?`, + [flag, head.seq], + ); + rewriteShown(db, jmap, id, null, row.gen); 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], - ); + const last = chain.at(-1); + if (last?.kind === 'update' && last.send_scheduling === flag) { + db.run(`UPDATE calendar_pending SET kind = 'destroy', patch = '{}', base = '{}', sent = NULL, rev = rev + 1 WHERE seq = ?`, [last.seq]); } else { - ctx.db.run( + 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()], + [jmap, id, flag, 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, - }); + rewriteShown(db, jmap, id, serverCopy(row), 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. + * Puts a write on the conflict list with every later write of that event, which + * were built on it. The event goes back to the server's copy plus what's left. A + * refused create leaves nothing behind. */ export function recordConflict( ctx: StoreContext, @@ -182,60 +223,158 @@ export function recordConflict( 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]); + const { jmap, record_id: id } = pending; + const row = eventRow(db, jmap, id); + const gone = reason === 'destroyedOnServer'; + const local = pending.kind === 'create' || (pending.kind === 'destroy' && pending.uid !== null); + const server = row && !local && !gone ? serverCopy(row) : null; + const title = row ? (parse(row.json).title as string | undefined) ?? null : null; + const chain = pendingChain(db, jmap, id).filter((other) => gone || other.seq >= pending.seq); + for (const other of chain) { + db.run( + `INSERT INTO calendar_conflict (jmap, record_id, kind, reason, error_type, description, title, local, server, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [jmap, id, other.kind, reason, error?.type ?? null, error?.description ?? null, title, other.patch, server ? JSON.stringify(server) : null, ctx.now()], + ); + db.run('DELETE FROM calendar_pending WHERE seq = ?', [other.seq]); + } if (row) { - if (server) writeEvent(db, pending.jmap, { shown: server, gen: row.gen }); - else deleteEventRow(db, pending.jmap, pending.record_id); + if (local || gone) deleteEventRow(db, jmap, id); + else rewriteShown(db, jmap, id, serverCopy(row), row.gen); } 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. */ +/** Before a row goes out: what it says may be on the server from now on. */ +export function markSent(ctx: StoreContext, row: PendingRow, sent: Record): void { + ctx.db.transaction(() => ctx.db.run('UPDATE calendar_pending SET attempted = 1, sent = ? WHERE seq = ?', [JSON.stringify(sent), row.seq])); +} + +/** + * The server's event for a create found again, or just made: the stand-in becomes + * it, and later rows go on under the real id. What the server doesn't hold of the + * create goes on as an update: what came in after the last send, or, when nothing + * of ours was sent, whatever differs. True when writes are left. + */ +export function adoptStandIn(ctx: StoreContext, row: PendingRow, event: CalendarObject): boolean { + const { db } = ctx; + const { jmap, record_id: standIn } = row; + const head = pendingBySeq(db, row.seq); + const gen = currentGen(db, jmap); + deleteEventRow(db, jmap, standIn); + db.run('INSERT OR REPLACE INTO calendar_id_map (jmap, local_id, server_id) VALUES (?, ?, ?)', [jmap, standIn, event.id]); + db.run('UPDATE calendar_pending SET record_id = ? WHERE jmap = ? AND record_id = ?', [event.id, jmap, standIn]); + if (head?.kind === 'destroy') { + db.run('UPDATE calendar_pending SET uid = NULL, attempted = 0, sent = NULL WHERE seq = ?', [head.seq]); + } else if (head) { + const payload = parse(head.patch); + const sent = head.sent ? parse(head.sent) : null; + const patch: Record = {}; + if (sent) { + for (const key of new Set([...Object.keys(sent), ...Object.keys(payload)])) { + if (stableJson(sent[key]) !== stableJson(payload[key])) patch[key] = payload[key] ?? null; + } + } else { + for (const [key, value] of Object.entries(payload)) { + if (key !== '@type' && key !== 'uid' && !holdsValue(event, key, value)) patch[key] = value; + } + } + const pointers = asOverridePointers(patch, event); + if (Object.keys(pointers).length === 0) { + db.run('DELETE FROM calendar_pending WHERE seq = ?', [head.seq]); + } else { + db.run( + `UPDATE calendar_pending SET kind = 'update', patch = ?, base = ?, uid = NULL, attempted = 0, sent = NULL WHERE seq = ?`, + [JSON.stringify(pointers), JSON.stringify(baseFor(event, pointers, {})), head.seq], + ); + } + } + const rest = pendingChain(db, jmap, event.id); + for (const later of rest) { + if (later.kind === 'update' && later.seq !== head?.seq) { + db.run('UPDATE calendar_pending SET base = ? WHERE seq = ?', [JSON.stringify(baseFor(event, parse(later.patch), {})), later.seq]); + } + } + rewriteShown(db, jmap, event.id, event, gen); + changed(ctx, 'CalendarEvent'); + return rest.length > 0; +} + +/** + * A write went through: `sent` is the server's now. True when this row has to go + * again, because a newer write merged into it during the send. Later rows read + * their overlap with `sent` as ours, not as someone's change. + */ +export function finishWrite(ctx: StoreContext, row: PendingRow, sent: Record): boolean { + const { db } = ctx; + const current = pendingBySeq(db, row.seq); + const event = eventRow(db, row.jmap, row.record_id); + if (!current || !event) return false; + const server = applyPatchObject(serverCopy(event), sent); + const stays = current.rev !== row.rev; + if (stays) { + db.run('UPDATE calendar_pending SET attempted = 0, sent = ? WHERE seq = ?', [JSON.stringify(sent), row.seq]); + } else { + db.run('DELETE FROM calendar_pending WHERE seq = ?', [row.seq]); + } + for (const later of pendingChain(db, row.jmap, row.record_id)) { + if (later.kind !== 'update') continue; + const earlierSent = later.sent ? parse(later.sent) : {}; + db.run( + 'UPDATE calendar_pending SET base = ?, sent = ? WHERE seq = ?', + [JSON.stringify(baseFor(server, parse(later.patch), {})), JSON.stringify({ ...earlierSent, ...sent }), later.seq], + ); + } + rewriteShown(db, row.jmap, row.record_id, server, event.gen); + changed(ctx, 'CalendarEvent'); + return stays; +} + +/** The server's copy of an event arriving during sync. Any pending writes go 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) { + if (typeof event.uid === 'string') { + // A create of ours that no answer told us about. + const waiting = db.get('SELECT * FROM calendar_pending WHERE jmap = ? AND uid = ? ORDER BY seq LIMIT 1', [jmap, event.uid]); + if (waiting && waiting.record_id !== event.id) { + adoptStandIn(ctx, waiting, event); + return; + } + } + const before = eventRow(db, jmap, event.id); + const previous = before ? stableJson(serverCopy(before)) : null; + const chain = pendingChain(db, jmap, event.id); + if (chain.length === 0) { 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)) { + writeEvent(db, jmap, { shown: event, server: event, gen }); + for (const pending of chain) { + if (pending.kind === 'destroy') { // 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 }); + if (previous !== null && previous !== stableJson(event)) recordConflict(ctx, pending, 'changedOnServer'); + break; + } + if (collides({ patch: parse(pending.patch), base: parse(pending.base), sent: pending.sent ? parse(pending.sent) : null }, event)) { recordConflict(ctx, pending, 'changedOnServer'); - return; + break; } - 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'); + rewriteShown(db, jmap, event.id, event, gen); } /** 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'); + const { db } = ctx; + const chain = pendingChain(db, jmap, id); + const edit = chain.find((row) => row.kind === 'update'); + if (edit) { + recordConflict(ctx, edit, 'destroyedOnServer'); return; } - if (pending) ctx.db.run('DELETE FROM calendar_pending WHERE seq = ?', [pending.seq]); - deleteEventRow(ctx.db, jmap, id); + db.run('DELETE FROM calendar_pending WHERE jmap = ? AND record_id = ?', [jmap, id]); + deleteEventRow(db, jmap, id); } export function listConflicts(db: StorageAdapter): CalendarConflict[] { diff --git a/locales/ar/common.json b/locales/ar/common.json index cc8420d14..0f4584faa 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "فيه أحداث", "mini_week_number": "الأسبوع {week}" }, + "calendar_offline": { + "split_needs_server": "يحتاج هذا التغيير إلى اتصال بالخادم. غيّر هذا الحدث فقط الآن، أو حاول مرة أخرى عند عودة الاتصال." + }, "calendar_event_notifications": { "someone": "شخص ما", "invited": "دعاك {name} إلى \"{title}\"", diff --git a/locales/ca/common.json b/locales/ca/common.json index 6e15f53fc..8a1852adb 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "té esdeveniments", "mini_week_number": "Setmana {week}" }, + "calendar_offline": { + "split_needs_server": "Aquest canvi necessita connexió amb el servidor. Canvia només aquest esdeveniment de moment, o torna-ho a provar quan tornis a estar connectat." + }, "calendar_event_notifications": { "someone": "Algú", "invited": "{name} t'ha convidat a \"{title}\"", diff --git a/locales/cs/common.json b/locales/cs/common.json index d8426dce2..a472929d9 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "má události", "mini_week_number": "Týden {week}" }, + "calendar_offline": { + "split_needs_server": "Tato změna vyžaduje připojení k serveru. Prozatím změňte jen tuto událost, nebo to zkuste znovu po obnovení připojení." + }, "advanced_search": { "title": "Pokročilé hledání", "from": "Od", diff --git a/locales/da/common.json b/locales/da/common.json index cf66a7294..f0283f148 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "har begivenheder", "mini_week_number": "Uge {week}" }, + "calendar_offline": { + "split_needs_server": "Denne ændring kræver forbindelse til serveren. Ret kun denne begivenhed for nu, eller prøv igen, når du er online." + }, "calendar_event_notifications": { "someone": "Nogen", "invited": "{name} har inviteret dig til \"{title}\"", diff --git a/locales/de/common.json b/locales/de/common.json index 3772dbab8..a4f6c5bc4 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "hat Termine", "mini_week_number": "Woche {week}" }, + "calendar_offline": { + "split_needs_server": "Für diese Änderung ist eine Verbindung zum Server nötig. Ändern Sie vorerst nur diesen Termin, oder versuchen Sie es erneut, wenn Sie wieder online sind." + }, "advanced_search": { "title": "Erweiterte Suche", "from": "Von", diff --git a/locales/en/common.json b/locales/en/common.json index 57a58dab4..998472204 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "has events", "mini_week_number": "Week {week}" }, + "calendar_offline": { + "split_needs_server": "This change needs a connection to the server. Change only this event for now, or try again when you're back online." + }, "calendar_event_notifications": { "someone": "Someone", "invited": "{name} invited you to \"{title}\"", diff --git a/locales/es/common.json b/locales/es/common.json index cc786a268..2ea1b27d5 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "tiene eventos", "mini_week_number": "Semana {week}" }, + "calendar_offline": { + "split_needs_server": "Este cambio necesita conexión con el servidor. Cambia solo este evento por ahora o inténtalo de nuevo cuando vuelvas a tener conexión." + }, "advanced_search": { "title": "Búsqueda avanzada", "from": "De", diff --git a/locales/et/common.json b/locales/et/common.json index 2743f7f83..fa2cb1fc4 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "on sündmusi", "mini_week_number": "Nädal {week}" }, + "calendar_offline": { + "split_needs_server": "See muudatus vajab ühendust serveriga. Muuda praegu ainult seda sündmust või proovi uuesti, kui oled taas võrgus." + }, "calendar_event_notifications": { "someone": "Keegi", "invited": "{name} kutsus sind sündmusele \"{title}\"", diff --git a/locales/fa/common.json b/locales/fa/common.json index 539f81c1a..1226e4fc0 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "رویداد دارد", "mini_week_number": "هفته {week}" }, + "calendar_offline": { + "split_needs_server": "این تغییر به اتصال به سرور نیاز دارد. فعلاً فقط همین رویداد را تغییر دهید، یا پس از برقراری اتصال دوباره تلاش کنید." + }, "calendar_event_notifications": { "someone": "کسی", "invited": "{name} شما را به «{title}» دعوت کرد", diff --git a/locales/fr/common.json b/locales/fr/common.json index fb7d148ec..2e72aa804 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "a des événements", "mini_week_number": "Semaine {week}" }, + "calendar_offline": { + "split_needs_server": "Cette modification nécessite une connexion au serveur. Modifiez seulement cet événement pour l'instant, ou réessayez une fois de nouveau en ligne." + }, "advanced_search": { "title": "Recherche avancée", "from": "De", diff --git a/locales/he/common.json b/locales/he/common.json index 73d75426a..8d1af0a1f 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -4828,6 +4828,9 @@ "mini_has_events": "יש אירועים", "mini_week_number": "שבוע {week}" }, + "calendar_offline": { + "split_needs_server": "שינוי זה דורש חיבור לשרת. שנו כרגע רק את האירוע הזה, או נסו שוב כשהחיבור יחזור." + }, "advanced_search": { "title": "חיפוש מתקדם", "from": "מ", diff --git a/locales/hu/common.json b/locales/hu/common.json index b45fe495e..df14d05f2 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "vannak események", "mini_week_number": "{week}. hét" }, + "calendar_offline": { + "split_needs_server": "Ehhez a módosításhoz kapcsolat kell a kiszolgálóval. Egyelőre csak ezt az eseményt módosítsa, vagy próbálja újra, ha ismét online lesz." + }, "calendar_event_notifications": { "someone": "Valaki", "invited": "{name} meghívott a(z) „{title}” eseményre", diff --git a/locales/it/common.json b/locales/it/common.json index a021b7397..c5979afbf 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "ha eventi", "mini_week_number": "Settimana {week}" }, + "calendar_offline": { + "split_needs_server": "Questa modifica richiede una connessione al server. Per ora modifica solo questo evento, oppure riprova quando sei di nuovo online." + }, "advanced_search": { "title": "Ricerca avanzata", "from": "Da", diff --git a/locales/ja/common.json b/locales/ja/common.json index f2ea41909..3433a57f1 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "イベントあり", "mini_week_number": "第{week}週" }, + "calendar_offline": { + "split_needs_server": "この変更にはサーバーへの接続が必要です。今はこの予定だけを変更するか、オンラインに戻ってからもう一度お試しください。" + }, "advanced_search": { "title": "詳細検索", "from": "差出人", diff --git a/locales/ko/common.json b/locales/ko/common.json index cbde91d32..c488ab3e1 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "일정 있음", "mini_week_number": "{week}주차" }, + "calendar_offline": { + "split_needs_server": "이 변경에는 서버 연결이 필요합니다. 지금은 이 일정만 변경하거나 다시 온라인이 되면 다시 시도하세요." + }, "advanced_search": { "title": "상세 검색", "from": "보낸 사람", diff --git a/locales/lv/common.json b/locales/lv/common.json index 02d30c2f9..6ecffaa24 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "ir notikumi", "mini_week_number": "{week}. nedēļa" }, + "calendar_offline": { + "split_needs_server": "Šīm izmaiņām nepieciešams savienojums ar serveri. Pagaidām mainiet tikai šo notikumu vai mēģiniet vēlreiz, kad būsiet tiešsaistē." + }, "advanced_search": { "title": "Izvērstā meklēšana", "from": "No", diff --git a/locales/mn/common.json b/locales/mn/common.json index 4dd86dea2..4d4bffd21 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "үйл явдалтай", "mini_week_number": "{week}-р долоо хоног" }, + "calendar_offline": { + "split_needs_server": "Энэ өөрчлөлтөд сервертэй холболт шаардлагатай. Одоохондоо зөвхөн энэ үйл явдлыг өөрчлөх эсвэл онлайн болсны дараа дахин оролдоно уу." + }, "calendar_event_notifications": { "someone": "Хэн нэгэн", "invited": "{name} таныг \"{title}\"-д урилаа", diff --git a/locales/nb/common.json b/locales/nb/common.json index d98bd1756..e11b16195 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "har hendelser", "mini_week_number": "Uke {week}" }, + "calendar_offline": { + "split_needs_server": "Denne endringen krever tilkobling til serveren. Endre bare denne hendelsen for nå, eller prøv igjen når du er tilkoblet." + }, "calendar_event_notifications": { "someone": "Noen", "invited": "{name} har invitert deg til «{title}»", diff --git a/locales/nl/common.json b/locales/nl/common.json index 4d541f358..5bdec152a 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "heeft afspraken", "mini_week_number": "Week {week}" }, + "calendar_offline": { + "split_needs_server": "Voor deze wijziging is een verbinding met de server nodig. Wijzig voorlopig alleen deze afspraak, of probeer het opnieuw zodra je weer online bent." + }, "advanced_search": { "title": "Geavanceerd zoeken", "from": "Van", diff --git a/locales/pl/common.json b/locales/pl/common.json index a6a7cd520..a2928872d 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "ma wydarzenia", "mini_week_number": "Tydzień {week}" }, + "calendar_offline": { + "split_needs_server": "Ta zmiana wymaga połączenia z serwerem. Na razie zmień tylko to wydarzenie albo spróbuj ponownie, gdy wrócisz do trybu online." + }, "advanced_search": { "title": "Wyszukiwanie zaawansowane", "from": "Od", diff --git a/locales/pt/common.json b/locales/pt/common.json index 685a98aea..3781a8db9 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "tem eventos", "mini_week_number": "Semana {week}" }, + "calendar_offline": { + "split_needs_server": "Esta alteração precisa de ligação ao servidor. Altere apenas este evento por agora, ou tente de novo quando voltar a estar online." + }, "advanced_search": { "title": "Pesquisa avançada", "from": "De", diff --git a/locales/ro/common.json b/locales/ro/common.json index 45c3fefed..738627497 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "are evenimente", "mini_week_number": "Săptămâna {week}" }, + "calendar_offline": { + "split_needs_server": "Această modificare are nevoie de conexiune la server. Modifică deocamdată doar acest eveniment sau încearcă din nou când ești din nou online." + }, "calendar_event_notifications": { "someone": "Cineva", "invited": "{name} te-a invitat la „{title}”", diff --git a/locales/ru/common.json b/locales/ru/common.json index b40069b61..4286f169b 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "есть события", "mini_week_number": "Неделя {week}" }, + "calendar_offline": { + "split_needs_server": "Для этого изменения нужно подключение к серверу. Пока измените только это событие или повторите попытку, когда снова будете в сети." + }, "advanced_search": { "title": "Расширенный поиск", "from": "От", diff --git a/locales/sk/common.json b/locales/sk/common.json index aa0304452..e257de452 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "má udalosti", "mini_week_number": "Týždeň {week}" }, + "calendar_offline": { + "split_needs_server": "Táto zmena vyžaduje pripojenie k serveru. Zatiaľ zmeňte iba túto udalosť alebo to skúste znova, keď budete online." + }, "calendar_event_notifications": { "someone": "Niekto", "invited": "{name} vás pozval(a) na „{title}“", diff --git a/locales/th/common.json b/locales/th/common.json index 4d2c52754..e4459eaec 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -4092,6 +4092,9 @@ "mini_has_events": "มีกิจกรรม", "mini_week_number": "สัปดาห์ที่ {week}" }, + "calendar_offline": { + "split_needs_server": "การเปลี่ยนแปลงนี้ต้องเชื่อมต่อกับเซิร์ฟเวอร์ ตอนนี้เปลี่ยนเฉพาะกิจกรรมนี้ หรือลองอีกครั้งเมื่อกลับมาออนไลน์" + }, "settings": { "title": "การตั้งค่า", "org_locked": "กำหนดโดยองค์กรของคุณ", diff --git a/locales/tr/common.json b/locales/tr/common.json index 4f25997fa..28f475c79 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "etkinlik var", "mini_week_number": "{week}. hafta" }, + "calendar_offline": { + "split_needs_server": "Bu değişiklik için sunucu bağlantısı gerekir. Şimdilik yalnızca bu etkinliği değiştirin veya yeniden çevrimiçi olduğunuzda tekrar deneyin." + }, "calendar_event_notifications": { "someone": "Birisi", "invited": "{name} sizi \"{title}\" etkinliğine davet etti", diff --git a/locales/uk/common.json b/locales/uk/common.json index 408e920da..ba1779c08 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "є події", "mini_week_number": "Тиждень {week}" }, + "calendar_offline": { + "split_needs_server": "Для цієї зміни потрібне з'єднання із сервером. Поки що змініть лише цю подію або спробуйте ще раз, коли знову будете в мережі." + }, "advanced_search": { "title": "Розширений пошук", "from": "Від", diff --git a/locales/vi/common.json b/locales/vi/common.json index 6952aec2e..0476c4f37 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -4921,6 +4921,9 @@ "mini_has_events": "Có sự kiện", "mini_week_number": "Tuần {week}" }, + "calendar_offline": { + "split_needs_server": "Thay đổi này cần kết nối với máy chủ. Hiện chỉ thay đổi sự kiện này, hoặc thử lại khi bạn trực tuyến trở lại." + }, "calendar_event_notifications": { "someone": "Ai đó", "invited": "{name} đã mời bạn tham gia \"{title}\"", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 1236173cf..2c58d84bd 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "有活動", "mini_week_number": "第 {week} 週" }, + "calendar_offline": { + "split_needs_server": "此變更需要連線到伺服器。請暫時只變更此活動,或在恢復連線後再試一次。" + }, "calendar_event_notifications": { "someone": "有人", "invited": "{name} 邀請您參加「{title}」", diff --git a/locales/zh/common.json b/locales/zh/common.json index f51d10c28..9b8794067 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -4909,6 +4909,9 @@ "mini_has_events": "有事件", "mini_week_number": "第 {week} 周" }, + "calendar_offline": { + "split_needs_server": "此更改需要连接到服务器。请暂时只更改此日程,或在恢复联网后重试。" + }, "advanced_search": { "title": "高级搜索", "from": "发件人", diff --git a/stores/__tests__/calendar-store-offline-occurrence.test.ts b/stores/__tests__/calendar-store-offline-occurrence.test.ts index 4cccb256a..84b926f51 100644 --- a/stores/__tests__/calendar-store-offline-occurrence.test.ts +++ b/stores/__tests__/calendar-store-offline-occurrence.test.ts @@ -6,6 +6,8 @@ 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 type { CalendarEvent } from '@/lib/jmap/types'; +import { saveOccurrenceEdit } from '@/lib/recurrence-scope-edit'; import { useCalendarStore } from '../calendar-store'; const NOW = Date.UTC(2026, 10, 3, 12); @@ -65,8 +67,8 @@ describe('an occurrence on screen from before the connection went', () => { 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'); + // As 1.0 reads it back: only what differs from the series. + expect(overrides(w)?.['2026-11-09T10:00:00']).toMatchObject({ 'participants/me/participationStatus': 'accepted' }); }); it('RSVP for the whole series: a pointer patch on the master, every occurrence shows it', async () => { @@ -115,3 +117,132 @@ describe('an occurrence on screen from before the connection went', () => { expect(overrides(w)?.['2026-11-09T10:00:00']).not.toHaveProperty('organizerCalendarAddress'); }); }); + +describe('a change that should reach everyone who answered', () => { + async function organizer() { + 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:a@example.org', + participants: { + me: { '@type': 'Participant', calendarAddress: 'mailto:a@example.org', participationStatus: 'accepted', roles: { owner: true } }, + bob: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.org', participationStatus: 'accepted', roles: { attendee: true } }, + }, + }); + 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 writes = (w: Wire) => w.server.calls.filter(([name, args]) => name === 'CalendarEvent/set' && !(args.update && 'h333333' in (args.update as object))); + + it('keeps its two steps: the override quietly first, then the change with messages', async () => { + const { w, client } = await organizer(); + w.online = false; + await useCalendarStore.getState().updateEvent(client, occurrenceOn('2026-11-09')!.id, { title: 'Moved room' }, true, { asOrganizer: true }); + expect(w.db.all('SELECT send_scheduling FROM calendar_pending ORDER BY seq').map((row) => row.send_scheduling)).toEqual([0, 1]); + expect(occurrenceOn('2026-11-09')?.title).toBe('Moved room'); + + w.online = true; + w.server.calls.length = 0; + await flush(w); + expect(await conflicts(w)).toEqual([]); + const flags = writes(w).map(([, args]) => args.sendSchedulingMessages); + expect(flags[0]).toBe(false); + expect(flags[flags.length - 1]).toBe(true); + const lastPatch = (writes(w).at(-1)?.[1].update as Record>).series; + expect(JSON.stringify(lastPatch)).toContain('Moved room'); + const firstPatch = (writes(w)[0][1].update as Record>).series; + expect(JSON.stringify(firstPatch)).not.toContain('Moved room'); + expect(overrides(w)?.['2026-11-09T10:00:00']).toMatchObject({ title: 'Moved room' }); + }); +}); + +describe('a stand-in id still held by the page once the server is back', () => { + async function created() { + const { w, client } = await online(); + w.online = false; + const event = await useCalendarStore.getState().createEvent(client, { + title: 'Offline', + start: '2026-11-11T08:00:00', + duration: 'PT1H', + timeZone: 'Europe/Berlin', + calendarIds: { cal1: true }, + } as Partial); + expect(event?.id).toMatch(/^local-/); + return { w, client, standIn: event!.id }; + } + const realId = (w: Wire) => [...w.server.events.values()].find((event) => event.title !== 'Weekly')!.id; + + it('an open dialog saves after the flush: the change reaches the real event', async () => { + const { w, client, standIn } = await created(); + w.online = true; + await flush(w); + const real = realId(w); + await useCalendarStore.getState().updateEvent(client, standIn, { title: 'Saved from the dialog' }); + expect(w.server.events.get(real)?.title).toBe('Saved from the dialog'); + expect(w.sent.flatMap(([, args]) => Object.keys((args.update ?? {}) as object))).not.toContain(`${standIn}x`); + const local = w.server.calls.filter(([, args]) => JSON.stringify(args).includes(standIn)); + expect(local).toEqual([]); + }); + + it('a delete and a read work too, and a refusal still reads as a refusal', async () => { + const { w, client, standIn } = await created(); + w.online = true; + await flush(w); + const real = realId(w); + expect((await client.getCalendarEvent(standIn))?.id).toBe(real); + w.server.refuse[real] = { type: 'forbidden', description: 'No rights' }; + await expect(client.updateCalendarEvent(standIn, { title: 'x' })).rejects.toThrow('No rights'); + await client.deleteCalendarEvent(standIn); + expect(w.server.events.has(real)).toBe(false); + }); + + it('before the flush the change joins the queue instead of asking the server for an unknown id', async () => { + const { w, client, standIn } = await created(); + w.online = true; + w.server.calls.length = 0; + await client.updateCalendarEvent(standIn, { title: 'Still queued' }); + expect(w.server.calls).toEqual([]); + await flush(w); + expect(w.server.events.get(realId(w))?.title).toBe('Still queued'); + }); +}); + +describe('this and future while offline', () => { + it('is refused before anything is written', async () => { + const { w, client } = await online(); + w.online = false; + await useCalendarStore.getState().fetchEvents(client, START, END); + const event = occurrenceOn('2026-11-09')!; + const updateEvent = useCalendarStore.getState().updateEvent; + const createEvent = useCalendarStore.getState().createEvent; + await expect(saveOccurrenceEdit('this_and_future', { + event, + updates: { title: 'From here on' }, + sendScheduling: false, + userEmails: ['a@example.org'], + }, { client, updateEvent, createEvent, findMasterEvent: async () => client.getCalendarEvent('series') })).rejects.toMatchObject({ name: 'SeriesSplitNeedsServerError' }); + expect(w.db.all('SELECT 1 FROM calendar_pending')).toEqual([]); + }); + + it('still works online', async () => { + const { w, client } = await online(); + const event = occurrenceOn('2026-11-09')!; + const { updateEvent, createEvent } = useCalendarStore.getState(); + await saveOccurrenceEdit('this_and_future', { + event, + updates: { title: 'From here on' }, + sendScheduling: false, + userEmails: ['a@example.org'], + }, { client, updateEvent, createEvent, findMasterEvent: async () => client.getCalendarEvent('series') }); + expect([...w.server.events.values()].some((e) => e.title === 'From here on')).toBe(true); + }); +});