mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
feat: merge offline calendar
# Conflicts: # lib/jmap/offline-hooks.ts
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 = `
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -640,6 +640,8 @@ export interface IJMAPClient {
|
||||
options?: CalendarEventUpdateOptions,
|
||||
): Promise<void>;
|
||||
deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<void>;
|
||||
/** 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<string, { type?: string; description?: string }> }>;
|
||||
queryCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, targetAccountId?: string, options?: CalendarQueryOptions): Promise<CalendarEvent[]>;
|
||||
queryAllCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, options?: CalendarQueryOptions): Promise<CalendarEvent[]>;
|
||||
|
||||
+39
-4
@@ -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";
|
||||
@@ -2198,9 +2199,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<string, string> | 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<string, string>;
|
||||
methodCalls = withRealIds(methodCalls, realIds);
|
||||
}
|
||||
const asAsked = (response: JMAPResponse) => (realIds ? withStandInsBack(response, realIds) : response);
|
||||
let answer: JMAPResponse;
|
||||
try {
|
||||
answer = await this.networkRequest(methodCalls, using, opts);
|
||||
@@ -2209,15 +2231,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<JMAPResponse> {
|
||||
@@ -9459,6 +9491,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<string, unknown>);
|
||||
// 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', {
|
||||
@@ -9565,6 +9599,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<string, unknown>);
|
||||
clean.uid ||= generateUUID();
|
||||
createMap[`new-${index}`] = clean;
|
||||
}
|
||||
|
||||
@@ -9989,7 +10024,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);
|
||||
|
||||
@@ -51,6 +51,12 @@ export interface OfflineHooks {
|
||||
* Left out when there is no store, and searches then go to the server alone.
|
||||
*/
|
||||
searchLocal?(client: OfflineClientRef, methodCalls: OfflineMethodCall[]): Promise<OfflineResponse | 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<Record<string, string | null>>;
|
||||
}
|
||||
|
||||
const KEY = '__bulwarkOfflineHooks';
|
||||
|
||||
@@ -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<string>();
|
||||
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<T>(map: Record<string, T>, rename: (id: string) => string): Record<string, T> {
|
||||
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<T extends OfflineMethodCall>(calls: readonly T[], real: Readonly<Record<string, string>>): 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<string, unknown>, 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<T extends OfflineResponse>(response: T, real: Readonly<Record<string, string>>): 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];
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -341,7 +341,7 @@ export function applyOverridePatch<T extends Partial<CalendarEvent>>(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<string, unknown>, updates: Record<string, unknown>): Record<string, unknown> {
|
||||
export function mergeOverrideEntry(entry: Record<string, unknown>, updates: Record<string, unknown>): Record<string, unknown> {
|
||||
const merged = { ...entry };
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
for (const existing of Object.keys(merged)) {
|
||||
|
||||
@@ -645,6 +645,19 @@ function raisesSequences(series: Partial<CalendarEvent>, 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;
|
||||
|
||||
@@ -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<CalendarEvent[]> {
|
||||
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<CalendarEvent>);
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<string[]> {
|
||||
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([]);
|
||||
}, 30_000);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,948 @@
|
||||
// @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), session = CALENDAR_SESSION) {
|
||||
const db = openMemoryStore();
|
||||
const events: StoreEvent[] = [];
|
||||
const engine = new AccountEngine({
|
||||
account: 'a@fake.test',
|
||||
db,
|
||||
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<typeof setup>;
|
||||
|
||||
async function call(s: Setup, name: string, args: Record<string, unknown>) {
|
||||
// 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<string, unknown>): Promise<string[]> {
|
||||
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<Record<string, unknown> | 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<string, unknown>) {
|
||||
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<CalendarConflict[]>;
|
||||
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('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');
|
||||
});
|
||||
|
||||
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<string, unknown> = {}) => (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<string, unknown>).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<string, { participationStatus: string }> };
|
||||
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('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');
|
||||
// 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([]);
|
||||
});
|
||||
|
||||
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({ pending: 0 });
|
||||
expect(s.server.events.size).toBe(1);
|
||||
expect(setCalls(s.server).filter(([, args]) => args.create)).toHaveLength(1);
|
||||
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 });
|
||||
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: `${JMAP_ACCOUNT}:series@example.org: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<FakeCalendarJmap['request']>;
|
||||
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]);
|
||||
});
|
||||
@@ -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<typeof setup>;
|
||||
|
||||
async function write(s: Setup, args: Record<string, unknown>, 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<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
async function query(s: Setup, args: Record<string, unknown> = {}) {
|
||||
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<Setup, 'engine'>) => s.engine.areaCall('calendar.flush', undefined) as Promise<{ sent: number; conflicts: number; pending: number }>;
|
||||
const conflicts = (s: Pick<Setup, 'engine'>) => s.engine.areaCall('calendar.conflicts', undefined) as Promise<CalendarConflict[]>;
|
||||
const sets = (server: FakeCalendarJmap) => server.calls.filter(([name, args]) => name === 'CalendarEvent/set' && !(args.update && 'h333333' in (args.update as object)));
|
||||
const pending = (s: Pick<Setup, 'db'>) => 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<string, unknown>) => 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<string, unknown>).e1).toEqual({ title: 'Quiet edit' });
|
||||
expect((sent[1][1].update as Record<string, unknown>).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<string, unknown>).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<string, Record<string, unknown>> } & Record<string, unknown>;
|
||||
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<string, { title: string }>)[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<string, { uid?: string }>) : []));
|
||||
|
||||
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<CalendarEvent>);
|
||||
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<CalendarEvent>, { ...NEW, title: 'Two' } as Partial<CalendarEvent>]);
|
||||
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<CalendarEvent>);
|
||||
expect(created()[0].uid).toBe('chosen@example.org');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 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 { setOfflineHooks, type OfflineMethodCall, type OfflineResponse } from '@/lib/jmap/offline-hooks';
|
||||
import { DEFAULT_SCOPE, type StoreArea } from '../../area';
|
||||
import { AccountEngine } from '../../engine/account-engine';
|
||||
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';
|
||||
|
||||
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<typeof openMemoryStore>;
|
||||
/** Everything the client sent, wherever it went. */
|
||||
sent: MethodCall[];
|
||||
online: boolean;
|
||||
newClient(): Promise<JMAPClient>;
|
||||
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();
|
||||
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);
|
||||
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<OfflineResponse | null>;
|
||||
},
|
||||
record: () => {},
|
||||
noteNetwork: () => {},
|
||||
serverGone: async () => true,
|
||||
blob: async () => null,
|
||||
recordBlob: () => {},
|
||||
holdUpload: async () => null,
|
||||
resolveStandIns: (_client, ids) => engine.areaCall('calendar.resolveStandIns', { ids }) as Promise<Record<string, string | null>>,
|
||||
});
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* 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.
|
||||
* - 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';
|
||||
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<string, unknown> & { 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<string, unknown>, tick: number): Record<string, unknown> {
|
||||
if (entry.excluded) return entry;
|
||||
if (version === '1.0') {
|
||||
const out: Record<string, unknown> = {};
|
||||
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<string, Record<string, unknown>>;
|
||||
for (const [id, person] of Object.entries(value as Record<string, Record<string, unknown>>)) {
|
||||
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<string, unknown> = { ...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<string, Record<string, unknown>>).map(([id, person]) => {
|
||||
const { roles, ...rest } = person;
|
||||
const attendeeOnly = roles && Object.keys(roles).length === 1 && (roles as Record<string, boolean>).attendee;
|
||||
return [id, attendeeOnly ? rest : person];
|
||||
}));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
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<string, FakeEvent>();
|
||||
readonly calendars = new Map<string, Record<string, unknown> & { id: string }>();
|
||||
readonly identities: Array<Record<string, unknown>> = [{ id: 'pi1', name: 'A', calendarAddress: 'mailto:a@example.org', isDefault: true }];
|
||||
readonly eventLog = new Collection();
|
||||
private readonly instances = new Map<string, Instance>();
|
||||
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<string, { type: string; description?: string; properties?: string[] }> = {};
|
||||
|
||||
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> = {}): 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<string, unknown>): void {
|
||||
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<string, Record<string, unknown>>)
|
||||
.map(([key, entry]) => [key, readBack(this.version, next, key, entry, tick)]));
|
||||
}
|
||||
this.events.set(id, next);
|
||||
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<string, Record<string, unknown>>)[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<string, unknown>): Record<string, unknown> {
|
||||
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<string, boolean>)[filter.inCalendar]) return false;
|
||||
return true;
|
||||
});
|
||||
return page(masters.map((event) => event.id), true);
|
||||
}
|
||||
|
||||
private get(args: Record<string, unknown>): Record<string, unknown> {
|
||||
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<string, unknown>[] = [];
|
||||
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<string, unknown> = { id };
|
||||
for (const name of properties) if (name in full) out[name] = (full as Record<string, unknown>)[name];
|
||||
list.push(out);
|
||||
}
|
||||
return { accountId: args.accountId, state: this.eventLog.state(), list, notFound };
|
||||
}
|
||||
|
||||
private seriesKeyInOverride(patch: Record<string, unknown>): 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<string, unknown>): { 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<string, Record<string, unknown>>;
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = { accountId: args.accountId, oldState: this.eventLog.state() };
|
||||
const created: Record<string, unknown> = {};
|
||||
const notCreated: Record<string, unknown> = {};
|
||||
for (const [key, event] of Object.entries((args.create ?? {}) as Record<string, Record<string, unknown>>)) {
|
||||
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<string, unknown> = {};
|
||||
const notUpdated: Record<string, unknown> = {};
|
||||
for (const [id, patch] of Object.entries((args.update ?? {}) as Record<string, Record<string, unknown>>)) {
|
||||
const refusal = this.update(id, patch);
|
||||
if (refusal) notUpdated[id] = refusal;
|
||||
else updated[id] = null;
|
||||
}
|
||||
const destroyed: string[] = [];
|
||||
const notDestroyed: Record<string, unknown> = {};
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
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<JmapResponse> {
|
||||
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<string, unknown> = {};
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+3
-2
@@ -96,9 +96,10 @@ export type MethodHandler = (ctx: MethodContext, args: Record<string, unknown>,
|
||||
/**
|
||||
* 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<unknown>;
|
||||
export type AreaCall = (ctx: SyncContext, params: never) => unknown | Promise<unknown>;
|
||||
|
||||
export interface StoreArea {
|
||||
name: string;
|
||||
|
||||
+2
-1
@@ -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];
|
||||
|
||||
@@ -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<unknown>, 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<string, boolean>;
|
||||
};
|
||||
|
||||
function participantsOf(object: CalendarObject): Participant[] {
|
||||
return Object.values((object.participants ?? {}) as Record<string, Participant>);
|
||||
}
|
||||
|
||||
function placeTexts(object: CalendarObject): unknown[] {
|
||||
const places = Object.values((object.locations ?? {}) as Record<string, { name?: unknown; description?: unknown }>);
|
||||
const virtual = Object.values((object.virtualLocations ?? {}) as Record<string, { name?: unknown; description?: unknown }>);
|
||||
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<string, boolean> | undefined)?.[value as string]) return false;
|
||||
break;
|
||||
case 'inCalendars':
|
||||
if (!(value as string[]).some((id) => (object.calendarIds as Record<string, boolean> | 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);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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 type { CalendarEvent } from '@/lib/jmap/types';
|
||||
import { isOverrideSeriesKeyRejection, withoutOverrideSeriesKeys } from '@/lib/recurrence-instances';
|
||||
import type { SyncContext } from '../area';
|
||||
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;
|
||||
conflicts: number;
|
||||
}
|
||||
|
||||
interface SetError {
|
||||
type?: string;
|
||||
description?: string;
|
||||
properties?: string[];
|
||||
}
|
||||
|
||||
interface SetResult {
|
||||
created?: Record<string, { id: string }> | null;
|
||||
updated?: Record<string, unknown> | null;
|
||||
destroyed?: string[] | null;
|
||||
notCreated?: Record<string, SetError> | null;
|
||||
notUpdated?: Record<string, SetError> | null;
|
||||
notDestroyed?: Record<string, SetError> | 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);
|
||||
}
|
||||
|
||||
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<string, unknown>): Promise<SetResult> {
|
||||
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<string | null> {
|
||||
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<CalendarObject | null> {
|
||||
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;
|
||||
}
|
||||
|
||||
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<Outcome> {
|
||||
const payload = JSON.parse(row.patch) as Record<string, unknown>;
|
||||
let realId = row.attempted ? await findByUid(ctx, row.jmap, row.uid) : null;
|
||||
if (!realId) {
|
||||
markSent(ctx, row, payload);
|
||||
let result = await sendSet(ctx, row, { create: { c: payload } });
|
||||
if (isSeriesKeyRefusal(result.notCreated?.c)) {
|
||||
result = await sendSet(ctx, row, { create: { c: withoutOverrideSeriesKeys(payload as Partial<CalendarEvent>) } });
|
||||
}
|
||||
realId = result.created?.c?.id ?? null;
|
||||
const refusal = result.notCreated?.c;
|
||||
if (!realId) {
|
||||
// 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);
|
||||
const left = ctx.db.transaction(() => adoptStandIn(ctx, row, event));
|
||||
return left ? 'again' : 'sent';
|
||||
}
|
||||
|
||||
async function sendUpdate(ctx: SyncContext, row: PendingRow): Promise<Outcome> {
|
||||
const id = row.record_id;
|
||||
const stored = eventRow(ctx.db, row.jmap, id);
|
||||
const server = JSON.parse(stored?.server_json ?? stored?.json ?? '{}') as Record<string, unknown>;
|
||||
const queued = JSON.parse(row.patch) as Record<string, unknown>;
|
||||
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<CalendarEvent>) as Record<string, unknown>;
|
||||
result = await sendSet(ctx, row, { update: { [id]: patch } });
|
||||
}
|
||||
const refusal = result.notUpdated?.[id];
|
||||
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<Outcome> {
|
||||
// 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 = (): Outcome => ctx.db.transaction(() => {
|
||||
ctx.db.run('DELETE FROM calendar_pending WHERE seq = ?', [row.seq]);
|
||||
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) return finish();
|
||||
const result = await sendSet(ctx, row, { destroy: [id] });
|
||||
const refusal = result.notDestroyed?.[id];
|
||||
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.
|
||||
*/
|
||||
export async function sendPending(ctx: SyncContext, jmap: string): Promise<FlushTally> {
|
||||
const tally: FlushTally = { sent: 0, conflicts: 0 };
|
||||
const finished = new Set<number>();
|
||||
// An event whose write has to wait: its later writes wait with it.
|
||||
const waiting = new Set<string>();
|
||||
for (;;) {
|
||||
if (ctx.stopped()) return tally;
|
||||
const row = ctx.db.all<PendingRow>('SELECT * FROM calendar_pending WHERE jmap = ? ORDER BY seq', [jmap])
|
||||
.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) {
|
||||
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 += conflictCount(ctx) - conflictsBefore;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { StoreArea } from '../area';
|
||||
import { CALENDAR_METHODS } from './methods';
|
||||
import { CALENDAR_MIGRATIONS } from './schema';
|
||||
import { calendarSyncer, flushCalendar } from './sync';
|
||||
import { listConflicts, resolveStandIns } 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.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]);
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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, unknown>): 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<string, unknown>>(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<string, unknown>[] = [];
|
||||
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<string, { id: string; uid: string }> = {};
|
||||
const updated: Record<string, null> = {};
|
||||
const destroyed: string[] = [];
|
||||
const notUpdated: Record<string, unknown> = {};
|
||||
const notDestroyed: Record<string, unknown> = {};
|
||||
let wrote = false;
|
||||
ctx.db.transaction(() => {
|
||||
for (const [key, event] of Object.entries((args.create ?? {}) as Record<string, Record<string, unknown>>)) {
|
||||
created[key] = queueCreate(ctx, jmap, event, send);
|
||||
wrote = true;
|
||||
}
|
||||
for (const [id, patch] of Object.entries((args.update ?? {}) as Record<string, Record<string, unknown>>)) {
|
||||
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<Record<string, MethodHandler>> = {
|
||||
'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: [] }),
|
||||
};
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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<string, unknown>)[name], value, name));
|
||||
}
|
||||
return actual === sent;
|
||||
}
|
||||
|
||||
function overrideEntry(server: Patch, key: string): Patch | undefined {
|
||||
return (server.recurrenceOverrides as Record<string, Patch> | 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<string, unknown>);
|
||||
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<string, unknown>;
|
||||
const known = (shown.recurrenceOverrides ?? {}) as Record<string, unknown>;
|
||||
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;
|
||||
}
|
||||
@@ -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<string, unknown> & { 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<string, unknown>): 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<string, unknown>)[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<string, unknown>, 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<string, unknown> {
|
||||
if (!properties) return { ...object };
|
||||
const out: Record<string, unknown> = { 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<EventRow>('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]);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* 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 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';
|
||||
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<string, CalendarEventAlert> | 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<string, boolean>)[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<string, UpcomingAlert>();
|
||||
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<string, Calendar[]>();
|
||||
for (const row of rows) {
|
||||
if (!calendarsOf.has(row.jmap)) calendarsOf.set(row.jmap, listObjects<Calendar>(db, row.jmap, 'Calendar'));
|
||||
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;
|
||||
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(stableId, 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(stableId, 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));
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
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);
|
||||
|
||||
-- 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,
|
||||
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 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
|
||||
);
|
||||
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 (
|
||||
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
|
||||
);
|
||||
`,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* 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, 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. */
|
||||
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<boolean> {
|
||||
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<ListCursor> {
|
||||
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) && pendingChain(ctx.db, jmap, event.id).length === 0
|
||||
&& !ctx.db.get('SELECT 1 AS x FROM calendar_pending WHERE jmap = ? AND uid = ?', [jmap, typeof event.uid === 'string' ? event.uid : ''])) 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<boolean> {
|
||||
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) 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'] });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<boolean> {
|
||||
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,
|
||||
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<boolean> {
|
||||
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);
|
||||
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 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<PendingRow>('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<StorageAdapter, Promise<unknown>>();
|
||||
|
||||
function oneAtATime<T>(db: StorageAdapter, work: () => Promise<T>): Promise<T> {
|
||||
const next = (queues.get(db) ?? Promise.resolve()).catch(() => {}).then(work);
|
||||
queues.set(db, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
async function syncAndSend(ctx: SyncContext, jmap: string): Promise<FlushTally> {
|
||||
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) {
|
||||
forgetUnlistedAccounts(ctx);
|
||||
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<FlushResult> {
|
||||
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 };
|
||||
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;
|
||||
}
|
||||
}
|
||||
const pending = Number(ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM calendar_pending')?.n ?? 0);
|
||||
return { ...total, pending };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* Calendar writes made while the server can't be reached.
|
||||
*
|
||||
* 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 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 {
|
||||
seq: number;
|
||||
jmap: string;
|
||||
record_id: string;
|
||||
kind: 'create' | 'update' | 'destroy';
|
||||
patch: string;
|
||||
base: string;
|
||||
uid: string | null;
|
||||
send_scheduling: number | null;
|
||||
attempted: number;
|
||||
sent: string | null;
|
||||
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<string, unknown>;
|
||||
/** The event as the server has it, when it still does. */
|
||||
server: Record<string, unknown> | null;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export const isStandIn = isStandInId;
|
||||
|
||||
/** An event's pending writes, in the order they go out. */
|
||||
export function pendingChain(db: StorageAdapter, jmap: string, id: string): PendingRow[] {
|
||||
return db.all<PendingRow>('SELECT * FROM calendar_pending WHERE jmap = ? AND record_id = ? ORDER BY seq', [jmap, id]);
|
||||
}
|
||||
|
||||
export function pendingBySeq(db: StorageAdapter, seq: number): PendingRow | undefined {
|
||||
return db.get<PendingRow>('SELECT * FROM calendar_pending WHERE seq = ?', [seq]);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** 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<string, string | null> {
|
||||
const out: Record<string, string | null> = {};
|
||||
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<string, unknown> {
|
||||
return JSON.parse(json) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
/** 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<string, unknown>, sendScheduling: boolean | undefined): { id: string; uid: string } {
|
||||
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, attempted, queued_at) VALUES (?, ?, 'create', ?, ?, ?, ?, ?)`,
|
||||
[jmap, id, JSON.stringify(payload), uid, flagOf(sendScheduling), given ? 1 : 0, ctx.now()],
|
||||
);
|
||||
rewriteShown(ctx.db, jmap, id, null, currentGen(ctx.db, jmap));
|
||||
return { id, uid };
|
||||
}
|
||||
|
||||
function baseFor(server: Record<string, unknown>, patch: Record<string, unknown>, earlier: Record<string, unknown>): Record<string, unknown> {
|
||||
const base: Record<string, unknown> = {};
|
||||
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, rawPatch: Record<string, unknown>, sendScheduling: boolean | undefined): boolean {
|
||||
const { db } = ctx;
|
||||
const row = eventRow(db, jmap, id);
|
||||
if (!row || row.hidden) return false;
|
||||
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],
|
||||
);
|
||||
rewriteShown(db, jmap, id, null, row.gen);
|
||||
return true;
|
||||
}
|
||||
const patch = asOverridePointers(rawPatch, JSON.parse(row.json) as Record<string, unknown>);
|
||||
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 {
|
||||
db.run(
|
||||
`INSERT INTO calendar_pending (jmap, record_id, kind, patch, base, send_scheduling, queued_at) VALUES (?, ?, 'update', ?, ?, ?, ?)`,
|
||||
[jmap, id, JSON.stringify(patch), JSON.stringify(baseFor(server ?? {}, patch, {})), flag, ctx.now()],
|
||||
);
|
||||
}
|
||||
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 { db } = ctx;
|
||||
const row = eventRow(db, jmap, id);
|
||||
if (!row || row.hidden) return false;
|
||||
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;
|
||||
}
|
||||
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 {
|
||||
db.run(
|
||||
`INSERT INTO calendar_pending (jmap, record_id, kind, patch, send_scheduling, queued_at) VALUES (?, ?, 'destroy', '{}', ?, ?)`,
|
||||
[jmap, id, flag, ctx.now()],
|
||||
);
|
||||
}
|
||||
rewriteShown(db, jmap, id, serverCopy(row), row.gen);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
pending: PendingRow,
|
||||
reason: ConflictReason,
|
||||
error?: { type?: string; description?: string },
|
||||
): void {
|
||||
const { db } = ctx;
|
||||
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 (local || gone) deleteEventRow(db, jmap, id);
|
||||
else rewriteShown(db, jmap, id, serverCopy(row), row.gen);
|
||||
}
|
||||
changed(ctx, 'CalendarEvent', 'CalendarConflict');
|
||||
}
|
||||
|
||||
/** 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<string, unknown>): 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<string, unknown> = {};
|
||||
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<string, unknown>): 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;
|
||||
if (typeof event.uid === 'string') {
|
||||
// A create of ours that no answer told us about.
|
||||
const waiting = db.get<PendingRow>('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;
|
||||
}
|
||||
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.
|
||||
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');
|
||||
break;
|
||||
}
|
||||
}
|
||||
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 { db } = ctx;
|
||||
const chain = pendingChain(db, jmap, id);
|
||||
const edit = chain.find((row) => row.kind === 'update');
|
||||
if (edit) {
|
||||
recordConflict(ctx, edit, 'destroyedOnServer');
|
||||
return;
|
||||
}
|
||||
db.run('DELETE FROM calendar_pending WHERE jmap = ? AND record_id = ?', [jmap, id]);
|
||||
deleteEventRow(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<string, unknown>,
|
||||
server: row.server ? JSON.parse(row.server) as Record<string, unknown> : null,
|
||||
createdAt: Number(row.created_at),
|
||||
}));
|
||||
}
|
||||
@@ -217,7 +217,7 @@ export class AccountEngine {
|
||||
async areaCall(name: string, params: unknown): Promise<unknown> {
|
||||
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. */
|
||||
|
||||
@@ -4909,6 +4909,9 @@
|
||||
"mini_has_events": "فيه أحداث",
|
||||
"mini_week_number": "الأسبوع {week}"
|
||||
},
|
||||
"calendar_offline": {
|
||||
"split_needs_server": "يحتاج هذا التغيير إلى اتصال بالخادم. غيّر هذا الحدث فقط الآن، أو حاول مرة أخرى عند عودة الاتصال."
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "شخص ما",
|
||||
"invited": "دعاك {name} إلى \"{title}\"",
|
||||
|
||||
@@ -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}\"",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}\"",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}\"",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}\"",
|
||||
|
||||
@@ -4909,6 +4909,9 @@
|
||||
"mini_has_events": "رویداد دارد",
|
||||
"mini_week_number": "هفته {week}"
|
||||
},
|
||||
"calendar_offline": {
|
||||
"split_needs_server": "این تغییر به اتصال به سرور نیاز دارد. فعلاً فقط همین رویداد را تغییر دهید، یا پس از برقراری اتصال دوباره تلاش کنید."
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "کسی",
|
||||
"invited": "{name} شما را به «{title}» دعوت کرد",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4828,6 +4828,9 @@
|
||||
"mini_has_events": "יש אירועים",
|
||||
"mini_week_number": "שבוע {week}"
|
||||
},
|
||||
"calendar_offline": {
|
||||
"split_needs_server": "שינוי זה דורש חיבור לשרת. שנו כרגע רק את האירוע הזה, או נסו שוב כשהחיבור יחזור."
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "חיפוש מתקדם",
|
||||
"from": "מ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4909,6 +4909,9 @@
|
||||
"mini_has_events": "イベントあり",
|
||||
"mini_week_number": "第{week}週"
|
||||
},
|
||||
"calendar_offline": {
|
||||
"split_needs_server": "この変更にはサーバーへの接続が必要です。今はこの予定だけを変更するか、オンラインに戻ってからもう一度お試しください。"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "詳細検索",
|
||||
"from": "差出人",
|
||||
|
||||
@@ -4909,6 +4909,9 @@
|
||||
"mini_has_events": "일정 있음",
|
||||
"mini_week_number": "{week}주차"
|
||||
},
|
||||
"calendar_offline": {
|
||||
"split_needs_server": "이 변경에는 서버 연결이 필요합니다. 지금은 이 일정만 변경하거나 다시 온라인이 되면 다시 시도하세요."
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "상세 검색",
|
||||
"from": "보낸 사람",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4909,6 +4909,9 @@
|
||||
"mini_has_events": "үйл явдалтай",
|
||||
"mini_week_number": "{week}-р долоо хоног"
|
||||
},
|
||||
"calendar_offline": {
|
||||
"split_needs_server": "Энэ өөрчлөлтөд сервертэй холболт шаардлагатай. Одоохондоо зөвхөн энэ үйл явдлыг өөрчлөх эсвэл онлайн болсны дараа дахин оролдоно уу."
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "Хэн нэгэн",
|
||||
"invited": "{name} таныг \"{title}\"-д урилаа",
|
||||
|
||||
@@ -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}»",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}”",
|
||||
|
||||
@@ -4909,6 +4909,9 @@
|
||||
"mini_has_events": "есть события",
|
||||
"mini_week_number": "Неделя {week}"
|
||||
},
|
||||
"calendar_offline": {
|
||||
"split_needs_server": "Для этого изменения нужно подключение к серверу. Пока измените только это событие или повторите попытку, когда снова будете в сети."
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Расширенный поиск",
|
||||
"from": "От",
|
||||
|
||||
@@ -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}“",
|
||||
|
||||
@@ -4092,6 +4092,9 @@
|
||||
"mini_has_events": "มีกิจกรรม",
|
||||
"mini_week_number": "สัปดาห์ที่ {week}"
|
||||
},
|
||||
"calendar_offline": {
|
||||
"split_needs_server": "การเปลี่ยนแปลงนี้ต้องเชื่อมต่อกับเซิร์ฟเวอร์ ตอนนี้เปลี่ยนเฉพาะกิจกรรมนี้ หรือลองอีกครั้งเมื่อกลับมาออนไลน์"
|
||||
},
|
||||
"settings": {
|
||||
"title": "การตั้งค่า",
|
||||
"org_locked": "กำหนดโดยองค์กรของคุณ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4909,6 +4909,9 @@
|
||||
"mini_has_events": "є події",
|
||||
"mini_week_number": "Тиждень {week}"
|
||||
},
|
||||
"calendar_offline": {
|
||||
"split_needs_server": "Для цієї зміни потрібне з'єднання із сервером. Поки що змініть лише цю подію або спробуйте ще раз, коли знову будете в мережі."
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Розширений пошук",
|
||||
"from": "Від",
|
||||
|
||||
@@ -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}\"",
|
||||
|
||||
@@ -4909,6 +4909,9 @@
|
||||
"mini_has_events": "有活動",
|
||||
"mini_week_number": "第 {week} 週"
|
||||
},
|
||||
"calendar_offline": {
|
||||
"split_needs_server": "此變更需要連線到伺服器。請暫時只變更此活動,或在恢復連線後再試一次。"
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "有人",
|
||||
"invited": "{name} 邀請您參加「{title}」",
|
||||
|
||||
@@ -4909,6 +4909,9 @@
|
||||
"mini_has_events": "有事件",
|
||||
"mini_week_number": "第 {week} 周"
|
||||
},
|
||||
"calendar_offline": {
|
||||
"split_needs_server": "此更改需要连接到服务器。请暂时只更改此日程,或在恢复联网后重试。"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "高级搜索",
|
||||
"from": "发件人",
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
// @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 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);
|
||||
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<string, Record<string, unknown>> | 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<CalendarConflict[]>;
|
||||
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([]);
|
||||
// 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 () => {
|
||||
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<string, { participationStatus: string }> };
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
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<string, Record<string, unknown>>).series;
|
||||
expect(JSON.stringify(lastPatch)).toContain('Moved room');
|
||||
const firstPatch = (writes(w)[0][1].update as Record<string, Record<string, unknown>>).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<CalendarEvent>);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user