feat: merge contacts and calendar sync with the phone on Android #34

Brings feat/device-sync into main: on Android, each account's address
books and calendars can sync both ways with the phone's Contacts and
Calendar apps (Settings -> Contacts / Calendar -> Sync to this
device), through a "Bulwark" Android account with a contacts and a
calendar sync adapter.

- native side: the account, both sync adapters, provider access scoped
  to the account, push routing to the syncs, sync problem notices
- the sync engine in `src/device-sync`, run as a headless task
- the settings sections, sign-out and removal handling, translations
- design, limitations and verification in `docs/device-sync.md`
This commit is contained in:
Linus Rath
2026-09-27 12:14:37 +02:00
219 changed files with 47834 additions and 219 deletions
@@ -40,6 +40,11 @@ const TRANSLATED_PANES: Record<string, string[]> = {
'settings/UpdatesSettings.tsx': [],
'settings/VacationSettings.tsx': ['YYYY-MM-DD HH:MM'],
'settings/settings-section.tsx': [],
// Device sync (Android, #34).
'settings/device-sync/DeviceSyncSection.tsx': [],
'settings/device-sync/DeviceSyncAccountRow.tsx': [],
'settings/device-sync/DeviceSyncCollectionsSheet.tsx': [],
'settings/device-sync/status.ts': [],
'filters/FilterRuleModal.tsx': [],
'filters/SieveEditorSheet.tsx': [],
};
+133 -114
View File
@@ -8,6 +8,9 @@ import {
} from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
import { AUTO_TIME_ZONE, getDeviceTimeZone, isValidTimeZone } from '../../lib/calendar-timezone';
import { deviceSyncAvailable } from '../../device-sync/app/available';
import { CALENDAR_AUTHORITY } from '../../device-sync/types';
import { DeviceSyncSection } from './device-sync/DeviceSyncSection';
// A compact list of IANA zones for the picker (#755). The device zone and
// any previously stored value are always offered too.
@@ -46,6 +49,9 @@ export function CalendarSettings() {
const tasksEnabled = useSettingsStore((s) => s.enableCalendarTasks);
const showTasksOnCal = useSettingsStore((s) => s.showTasksOnCalendar);
// Android with the native module only (#34).
const deviceSync = React.useMemo(() => deviceSyncAvailable(), []);
useEffect(() => {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
@@ -64,130 +70,143 @@ export function CalendarSettings() {
}, [deviceZone, timeZone, t]);
return (
<SettingsSection title={t('calendar.settings.title', 'Calendar')}>
<SettingItem label={t('calendar.settings.default_view', 'Default view')}>
<Select
value={viewMode}
onChange={(v) => update('calendarDefaultView', v as CalendarView)}
options={[
{ value: 'month', label: t('calendar.views.month', 'Month') },
{ value: 'week', label: t('calendar.views.week', 'Week') },
{ value: 'day', label: t('calendar.views.day', 'Day') },
{ value: 'agenda', label: t('calendar.views.agenda', 'Agenda') },
]}
/>
</SettingItem>
<>
<SettingsSection title={t('calendar.settings.title', 'Calendar')}>
<SettingItem label={t('calendar.settings.default_view', 'Default view')}>
<Select
value={viewMode}
onChange={(v) => update('calendarDefaultView', v as CalendarView)}
options={[
{ value: 'month', label: t('calendar.views.month', 'Month') },
{ value: 'week', label: t('calendar.views.week', 'Week') },
{ value: 'day', label: t('calendar.views.day', 'Day') },
{ value: 'agenda', label: t('calendar.views.agenda', 'Agenda') },
]}
/>
</SettingItem>
<SettingItem label={t('calendar.settings.week_starts_on', 'Week starts on')}>
<Select
value={String(firstDay)}
onChange={(v) => update('calendarFirstDayOfWeek', Number(v) as FirstDayOfWeek)}
options={[
{ value: '1', label: t('calendar.days.monday', 'Monday') },
{ value: '6', label: t('calendar.days.saturday', 'Saturday') },
{ value: '0', label: t('calendar.days.sunday', 'Sunday') },
]}
/>
</SettingItem>
<SettingItem label={t('calendar.settings.week_starts_on', 'Week starts on')}>
<Select
value={String(firstDay)}
onChange={(v) => update('calendarFirstDayOfWeek', Number(v) as FirstDayOfWeek)}
options={[
{ value: '1', label: t('calendar.days.monday', 'Monday') },
{ value: '6', label: t('calendar.days.saturday', 'Saturday') },
{ value: '0', label: t('calendar.days.sunday', 'Sunday') },
]}
/>
</SettingItem>
<SettingItem label={t('calendar.settings.time_format', 'Time format')}>
<RadioGroup
value={timeFormat}
onChange={(v) => update('calendarTimeFormat', v as TimeFormat)}
options={[
{ value: '12h', label: t('calendar.settings.time_format_12h', '12-hour') },
{ value: '24h', label: t('calendar.settings.time_format_24h', '24-hour') },
]}
/>
</SettingItem>
<SettingItem label={t('calendar.settings.time_format', 'Time format')}>
<RadioGroup
value={timeFormat}
onChange={(v) => update('calendarTimeFormat', v as TimeFormat)}
options={[
{ value: '12h', label: t('calendar.settings.time_format_12h', '12-hour') },
{ value: '24h', label: t('calendar.settings.time_format_24h', '24-hour') },
]}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.time_zone', 'Time zone')}
description={t(
'calendar.settings.time_zone_desc',
'Zone used for new events and for interpreting the calendar. "Device" follows the phone.',
)}
>
<Select
value={timeZone || AUTO_TIME_ZONE}
onChange={(v) => update('calendarTimeZone', v)}
options={timeZoneOptions}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.show_time_in_month_view', 'Show time in month view')}
description={t(
'calendar.settings.show_time_in_month_view_desc',
'Display event times in the month calendar view. On small screens this shows full event entries instead of dots.',
)}
>
<ToggleSwitch
checked={showTimeInMonth}
onChange={(v) => update('calendarShowTimeInMonth', v)}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.show_week_numbers', 'Show week numbers')}
description={t('calendar.settings.show_week_numbers_mobile_desc', 'Display week numbers in the month view.')}
>
<ToggleSwitch
checked={showWeekNumbers}
onChange={(v) => update('calendarShowWeekNumbers', v)}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.calendar_free_scroll', 'Free scrolling')}
description={t(
'calendar.settings.calendar_free_scroll_desc',
'Scroll continuously through months, weeks and days instead of one period at a time',
)}
>
<ToggleSwitch
checked={freeScroll}
onChange={(v) => update('calendarFreeScroll', v)}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.show_birthday_calendar', 'Contact birthday calendar')}
description={t(
'calendar.settings.show_birthday_calendar_desc',
'Show a virtual calendar with birthdays from your contacts',
)}
>
<ToggleSwitch
checked={birthdayCal}
onChange={(v) => update('showBirthdayCalendar', v)}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.enable_tasks', 'Enable tasks')}
description={t('calendar.settings.enable_tasks_desc', 'Show a tasks view in the calendar for managing to-dos')}
>
<ToggleSwitch
checked={tasksEnabled}
onChange={(v) => update('enableCalendarTasks', v)}
/>
</SettingItem>
{tasksEnabled && (
<SettingItem
label={t('calendar.settings.show_tasks_on_calendar', 'Show tasks on calendar')}
label={t('calendar.settings.time_zone', 'Time zone')}
description={t(
'calendar.settings.show_tasks_on_calendar_desc',
'Display task chips on the day and week calendar views',
'calendar.settings.time_zone_desc',
'Zone used for new events and for interpreting the calendar. "Device" follows the phone.',
)}
>
<Select
value={timeZone || AUTO_TIME_ZONE}
onChange={(v) => update('calendarTimeZone', v)}
options={timeZoneOptions}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.show_time_in_month_view', 'Show time in month view')}
description={t(
'calendar.settings.show_time_in_month_view_desc',
'Display event times in the month calendar view. On small screens this shows full event entries instead of dots.',
)}
>
<ToggleSwitch
checked={showTasksOnCal}
onChange={(v) => update('showTasksOnCalendar', v)}
checked={showTimeInMonth}
onChange={(v) => update('calendarShowTimeInMonth', v)}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.show_week_numbers', 'Show week numbers')}
description={t('calendar.settings.show_week_numbers_mobile_desc', 'Display week numbers in the month view.')}
>
<ToggleSwitch
checked={showWeekNumbers}
onChange={(v) => update('calendarShowWeekNumbers', v)}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.calendar_free_scroll', 'Free scrolling')}
description={t(
'calendar.settings.calendar_free_scroll_desc',
'Scroll continuously through months, weeks and days instead of one period at a time',
)}
>
<ToggleSwitch
checked={freeScroll}
onChange={(v) => update('calendarFreeScroll', v)}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.show_birthday_calendar', 'Contact birthday calendar')}
description={t(
'calendar.settings.show_birthday_calendar_desc',
'Show a virtual calendar with birthdays from your contacts',
)}
>
<ToggleSwitch
checked={birthdayCal}
onChange={(v) => update('showBirthdayCalendar', v)}
/>
</SettingItem>
<SettingItem
label={t('calendar.settings.enable_tasks', 'Enable tasks')}
description={t('calendar.settings.enable_tasks_desc', 'Show a tasks view in the calendar for managing to-dos')}
>
<ToggleSwitch
checked={tasksEnabled}
onChange={(v) => update('enableCalendarTasks', v)}
/>
</SettingItem>
{tasksEnabled && (
<SettingItem
label={t('calendar.settings.show_tasks_on_calendar', 'Show tasks on calendar')}
description={t(
'calendar.settings.show_tasks_on_calendar_desc',
'Display task chips on the day and week calendar views',
)}
>
<ToggleSwitch
checked={showTasksOnCal}
onChange={(v) => update('showTasksOnCalendar', v)}
/>
</SettingItem>
)}
</SettingsSection>
{deviceSync && (
<DeviceSyncSection
authority={CALENDAR_AUTHORITY}
title={t('calendar.settings.device_sync_title', 'Sync to this device')}
description={t(
'calendar.settings.device_sync_desc',
'Show your calendars in the Calendar app and other apps on this device. Changes sync both ways.',
)}
/>
)}
</SettingsSection>
</>
);
}
@@ -9,11 +9,14 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import Dialog from '../Dialog';
import { ContactImportSheet, AddressBookPickerSheet } from '../contacts';
import { DeviceSyncSection } from './device-sync/DeviceSyncSection';
import { useSettingsStore } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
import { useContactsStore, selectAddressBooksWithCount } from '../../stores/contacts-store';
import { contactsToVCard } from '../../lib/vcard';
import { isGroup } from '../../lib/contact-utils';
import { deviceSyncAvailable } from '../../device-sync/app/available';
import { CONTACTS_AUTHORITY } from '../../device-sync/types';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
@@ -53,6 +56,8 @@ export function ContactsSettings() {
const [newName, setNewName] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [deleteTarget, setDeleteTarget] = React.useState<{ id: string; name: string; count: number } | null>(null);
// Android with the native module only (#34).
const deviceSync = React.useMemo(() => deviceSyncAvailable(), []);
const exportable = React.useMemo(() => contacts.filter((cc) => !isGroup(cc)), [contacts]);
const exportLabel =
@@ -356,6 +361,17 @@ export function ContactsSettings() {
</View>
</SettingsSection>
{deviceSync && (
<DeviceSyncSection
authority={CONTACTS_AUTHORITY}
title={t('settings.contacts.device_sync_title', 'Sync to this device')}
description={t(
'settings.contacts.device_sync_desc',
'Show your address books in the Contacts app and other apps on this device. Changes sync both ways.',
)}
/>
)}
<ContactImportSheet
visible={importOpen}
onClose={() => setImportOpen(false)}
@@ -0,0 +1,532 @@
// One signed-in account in "Sync to this device" (docs/device-sync.md,
// "Settings sections"): the switch (Android's automatic sync for the
// authority), the status or the error with what to do about it, and, while
// sync is on, which collections sync, where new contacts go or who reminds of
// events, the interval, "Sync now" and Android's account settings.
import React from 'react';
import { Alert, StyleSheet, Text, View, ActivityIndicator } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { AlertTriangle, RefreshCw, Settings as SettingsIcon, XCircle } from 'lucide-react-native';
import { SettingItem, Select, RadioGroup, ToggleSwitch } from '../settings-section';
import Button from '../../Button';
import Dialog from '../../Dialog';
import { spacing, typography, type ThemePalette } from '../../../theme/tokens';
import { useColors } from '../../../theme/colors';
import { useLocaleStore, type TranslateFn } from '../../../stores/locale-store';
import { useSettingsStore } from '../../../stores/settings-store';
import type { AccountEntry } from '../../../stores/account-store';
import {
effectiveSelection,
emptyAccountDeviceSync,
intervalFor,
syncOnInApp,
useDeviceSyncStore,
} from '../../../stores/device-sync-store';
import {
changeReminderOwner,
changeSyncInterval,
disableDeviceSync,
enableDeviceSync,
openAndroidAccountSettings,
resolveDeletions,
resumeDeviceSync,
syncNow,
} from '../../../device-sync/app/lifecycle';
import { hasSyncPermissions, openAppSystemSettings, requestSyncPermissions } from '../../../device-sync/app/permissions';
import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY, type Authority, type ReminderOwner } from '../../../device-sync/types';
import type { RootStackParamList } from '../../../navigation/types';
import { DeviceSyncCollectionsSheet } from './DeviceSyncCollectionsSheet';
import { describeSyncStatus, type StatusAction } from './status';
import { useAndroidSync } from './use-android-sync';
import { useSyncCollections } from './use-sync-collections';
interface Props {
account: AccountEntry;
authority: Authority;
}
type Busy = 'on' | 'off' | 'sync' | null;
type Prompt =
| { kind: 'rationale' }
| { kind: 'blocked' }
| { kind: 'pending'; pending: number };
/** Asks who reminds of synced events; null when the user backed out. */
function askReminderOwner(t: TranslateFn): Promise<ReminderOwner | null> {
return new Promise((resolve) => {
let settled = false;
const answer = (owner: ReminderOwner | null) => {
if (settled) return;
settled = true;
resolve(owner);
};
Alert.alert(
t('settings.device_sync.reminder_question_title', 'Who should remind you?'),
t(
'settings.device_sync.reminder_question_message',
'Synced events can remind you from the calendar app on this device or from Bulwark. Pick one so you are not reminded twice. You can change it later.',
),
[
{ text: t('common.cancel', 'Cancel'), style: 'cancel', onPress: () => answer(null) },
{ text: t('settings.device_sync.reminder_owner_bulwark', 'Bulwark'), onPress: () => answer('bulwark') },
{ text: t('settings.device_sync.reminder_owner_device', 'Calendar app'), onPress: () => answer('device') },
],
{ cancelable: true, onDismiss: () => answer(null) },
);
});
}
/** How often the status line's "… ago" is brought up to date. */
const RELATIVE_TIME_REFRESH_MS = 30_000;
export function DeviceSyncAccountRow({ account, authority }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const storedEntry = useDeviceSyncStore((s) => s.accounts[account.id]);
const entry = React.useMemo(() => storedEntry ?? emptyAccountDeviceSync(), [storedEntry]);
const calendarReminders = useSettingsStore((s) => s.calendarNotificationsEnabled);
const isContacts = authority === CONTACTS_AUTHORITY;
const onInApp = syncOnInApp(entry, authority);
const { view, permitted, refresh } = useAndroidSync(entry.androidAccountName, authority);
const [busy, setBusy] = React.useState<Busy>(null);
// "Synced 2 min ago" keeps counting while the settings stay open.
const [now, setNow] = React.useState(() => Date.now());
React.useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), RELATIVE_TIME_REFRESH_MS);
return () => clearInterval(timer);
}, []);
const [prompt, setPrompt] = React.useState<Prompt | null>(null);
const [chooserOpen, setChooserOpen] = React.useState(false);
// The switch follows Android; until Android answered, what the app knows.
const androidOn = view ? view.exists && view.automatic : onInApp;
const checked = busy === 'on' ? true : busy === 'off' ? false : androidOn;
const paused = onInApp && !!view?.exists && !view.automatic;
const showDetails = (checked || paused) && busy !== 'off';
const { state: collectionsState } = useSyncCollections(account.id, authority, showDetails);
const collections = collectionsState.kind === 'loaded' ? collectionsState.collections : null;
const selectedKeys = React.useMemo(
() => (collections ? effectiveSelection(entry, authority, collections) : null),
[collections, entry, authority],
);
const accountLabel = account.email || account.username;
const status = describeSyncStatus(
{
authority,
on: checked,
paused,
removed: !!entry.removedInAndroidSettings,
permitted,
syncing: !!view?.active,
lastRun: entry.lastRun[authority],
now,
},
t,
);
const alertFailed = (message: string) => {
Alert.alert(
t('settings.device_sync.failed_title', 'Sync could not be turned on'),
message,
[{ text: t('settings.device_sync.ok', 'OK') }],
);
};
const alertDenied = () => {
Alert.alert(
t('settings.device_sync.denied_title', 'Sync stays off'),
isContacts
? t('settings.device_sync.denied_contacts', 'Without access to contacts, Bulwark cannot put your address books on this device.')
: t('settings.device_sync.denied_calendar', 'Without access to calendars, Bulwark cannot put your calendars on this device.'),
[{ text: t('settings.device_sync.ok', 'OK') }],
);
};
const turnOn = async () => {
setPrompt(null);
setBusy('on');
try {
const permission = await requestSyncPermissions(authority);
if (permission === 'blocked') {
setPrompt({ kind: 'blocked' });
return;
}
if (permission === 'denied') {
alertDenied();
return;
}
if (authority === CALENDAR_AUTHORITY && entry.reminderOwner == null) {
// Bulwark reminds nobody while its event notifications are off: the
// calendar app does, without asking.
const owner = calendarReminders ? await askReminderOwner(t) : 'device';
if (!owner) return;
await changeReminderOwner(account.id, owner);
}
const outcome = await enableDeviceSync(account.id, authority);
if (outcome.kind === 'permission') alertDenied();
else if (outcome.kind === 'failed') alertFailed(outcome.message);
} finally {
setBusy(null);
void refresh();
}
};
const startTurnOn = async () => {
// Explain first, unless access was granted before (another account).
if (await hasSyncPermissions(authority)) void turnOn();
else setPrompt({ kind: 'rationale' });
};
const turnOff = async (force: boolean) => {
setPrompt(null);
setBusy('off');
try {
const result = await disableDeviceSync(account.id, authority, { force });
if (result.done) return;
if (!force) {
setPrompt({ kind: 'pending', pending: result.pending });
return;
}
Alert.alert(
t('settings.device_sync.turn_off_failed_title', 'Sync could not be turned off'),
t(
'settings.device_sync.turn_off_failed',
'The synced data could not be removed from this device. Sync is paused; try again later.',
),
[{ text: t('settings.device_sync.ok', 'OK') }],
);
} finally {
setBusy(null);
void refresh();
}
};
const keepSyncing = async () => {
setPrompt(null);
await resumeDeviceSync(account.id, authority);
void refresh();
};
const handleSyncNow = async () => {
setBusy('sync');
try {
await syncNow(account.id, authority);
} catch (err) {
alertFailed(err instanceof Error ? err.message : String(err));
} finally {
setBusy(null);
void refresh();
}
};
const grantAccess = async () => {
const permission = await requestSyncPermissions(authority);
if (permission === 'blocked') setPrompt({ kind: 'blocked' });
else if (permission === 'denied') alertDenied();
else if (checked) await syncNow(account.id, authority).catch(() => false);
void refresh();
};
const reviewDeletions = () => {
const resolve = (choice: 'delete' | 'restore') => {
void resolveDeletions(account.id, authority, choice)
.catch((err: unknown) => alertFailed(err instanceof Error ? err.message : String(err)))
.finally(() => { void refresh(); });
};
Alert.alert(
t('settings.device_sync.deletions_title', 'Delete them on the server too?'),
t(
'settings.device_sync.deletions_message',
'Many items were deleted on this device at once, so they were not deleted on the server. Delete them there as well, or bring them back on this device.',
),
[
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
{ text: t('settings.device_sync.deletions_restore', 'Bring them back'), onPress: () => resolve('restore') },
{ text: t('settings.device_sync.deletions_delete', 'Delete on server'), style: 'destructive', onPress: () => resolve('delete') },
],
);
};
const runAction = (action: StatusAction) => {
if (action === 'signIn') navigation.navigate('AddAccount');
else if (action === 'grantAccess') void grantAccess();
else reviewDeletions();
};
const closeChooser = (changed: boolean) => {
setChooserOpen(false);
// A new selection is loaded (or dropped) by the next sync: run it now.
// (A sync Android paused would only report that it is off.)
if (changed && checked) void syncNow(account.id, authority).catch(() => false);
};
const intervalOptions = [
{ value: '900', label: t('settings.device_sync.interval_15m', 'Every 15 minutes') },
{ value: '1800', label: t('settings.device_sync.interval_30m', 'Every 30 minutes') },
{ value: '3600', label: t('settings.device_sync.interval_1h', 'Every hour') },
{ value: '21600', label: t('settings.device_sync.interval_6h', 'Every 6 hours') },
{ value: '0', label: t('settings.device_sync.interval_manual', 'No regular sync') },
];
const writableSelected = (collections ?? []).filter((col) => !col.readOnly && selectedKeys?.includes(col.key));
const newContactsOptions = [
{ value: '', label: t('settings.device_sync.new_contacts_default', 'Default address book') },
...writableSelected.map((col) => ({
value: col.key,
label: col.isPersonal ? col.name : `${col.name} (${col.accountName})`,
})),
];
const newContactsValue = entry.newContactsAddressBook && newContactsOptions.some((o) => o.value === entry.newContactsAddressBook)
? entry.newContactsAddressBook
: '';
const collectionsDescription = collections && selectedKeys
? t('settings.device_sync.collections_count', '{selected, plural, one {# of {total} syncs} other {# of {total} sync}}', {
selected: selectedKeys.length,
total: collections.length,
})
: isContacts
? t('settings.device_sync.books_default', 'Your own address books sync; shared ones only when you pick them.')
: t('settings.device_sync.calendars_default', 'Your own calendars sync; shared ones only when you pick them.');
const statusStyle = status.tone === 'error' ? styles.statusError : status.tone === 'warning' ? styles.statusWarning : styles.statusMuted;
const busyText = busy === 'on'
? t('settings.device_sync.turning_on', 'Setting up…')
: busy === 'off'
? t('settings.device_sync.turning_off', 'Uploading changes, then removing the data from this device…')
: null;
return (
<View style={styles.block}>
<SettingItem label={accountLabel} description={busyText ?? undefined} noBorder>
<View style={styles.row}>
{(busy === 'on' || busy === 'off') && <ActivityIndicator size="small" color={c.primary} />}
<ToggleSwitch
checked={checked}
onChange={(next) => {
if (next) void startTurnOn();
else void turnOff(false);
}}
disabled={busy !== null}
accessibilityLabel={t('settings.device_sync.switch_label', 'Sync {account} to this device', { account: accountLabel })}
/>
</View>
</SettingItem>
{!busyText && (
<View style={styles.statusRow} accessibilityRole={status.tone === 'error' ? 'alert' : undefined}>
{status.tone === 'error' && <XCircle size={14} color={c.error} />}
{status.tone === 'warning' && <AlertTriangle size={14} color={c.warning} />}
<Text style={[styles.statusText, statusStyle]}>{status.text}</Text>
</View>
)}
{!busyText && status.action && (
<View style={styles.actions}>
<Button variant="outline" size="sm" onPress={() => runAction(status.action!)}>
{status.action === 'signIn'
? t('settings.device_sync.sign_in_again', 'Sign in again')
: status.action === 'grantAccess'
? t('settings.device_sync.grant_access', 'Grant access')
: t('settings.device_sync.review_deletions', 'Review deletions')}
</Button>
</View>
)}
{showDetails && view && !view.masterAutomatic && (
<Text style={[styles.statusText, styles.statusWarning]}>
{t(
'settings.device_sync.master_off',
'Auto-sync is off on this device, so syncs only run when you tap Sync now.',
)}
</Text>
)}
{showDetails && (
<View style={styles.details}>
<SettingItem
label={isContacts
? t('settings.device_sync.books_label', 'Address books')
: t('settings.device_sync.calendars_label', 'Calendars')}
description={collectionsDescription}
>
<Button variant="outline" size="sm" onPress={() => setChooserOpen(true)}>
{t('settings.device_sync.choose', 'Choose')}
</Button>
</SettingItem>
{isContacts ? (
<SettingItem
label={t('settings.device_sync.new_contacts_label', 'New contacts go to')}
description={t(
'settings.device_sync.new_contacts_desc',
'The address book for contacts created in other apps on this device.',
)}
>
<Select
value={newContactsValue}
onChange={(v) => useDeviceSyncStore.getState().setNewContactsAddressBook(account.id, v || null)}
options={newContactsOptions}
/>
</SettingItem>
) : (
<SettingItem
label={t('settings.device_sync.reminders_label', 'Reminders')}
description={entry.reminderOwner === 'bulwark' && !calendarReminders
? t(
'settings.device_sync.reminders_nobody',
"Bulwark's event notifications are off, so nothing reminds you of synced events.",
)
: t(
'settings.device_sync.reminders_desc',
'Who reminds you of synced events. The other stays quiet, so you are not reminded twice.',
)}
>
<RadioGroup
value={entry.reminderOwner ?? 'device'}
onChange={(v) => { void changeReminderOwner(account.id, v === 'bulwark' ? 'bulwark' : 'device'); }}
options={[
{ value: 'device', label: t('settings.device_sync.reminder_owner_device', 'Calendar app') },
{ value: 'bulwark', label: t('settings.device_sync.reminder_owner_bulwark', 'Bulwark') },
]}
/>
</SettingItem>
)}
<SettingItem
label={t('settings.device_sync.interval_label', 'Sync interval')}
description={t(
'settings.device_sync.interval_desc',
'How often Android syncs on its own. Edits in Bulwark and pushed changes sync sooner.',
)}
noBorder
>
<Select
value={String(intervalFor(entry, authority))}
onChange={(v) => { void changeSyncInterval(account.id, authority, Number(v)); }}
options={intervalOptions}
/>
</SettingItem>
<View style={styles.actions}>
<Button
variant="outline"
size="sm"
onPress={() => { void handleSyncNow(); }}
disabled={busy !== null || !checked}
loading={busy === 'sync'}
icon={<RefreshCw size={14} color={c.text} />}
>
{t('settings.device_sync.sync_now', 'Sync now')}
</Button>
<Button
variant="outline"
size="sm"
onPress={() => { void openAndroidAccountSettings(account.id); }}
disabled={!view?.exists}
icon={<SettingsIcon size={14} color={c.text} />}
>
{t('settings.device_sync.android_settings', 'Android account settings')}
</Button>
</View>
</View>
)}
<DeviceSyncCollectionsSheet
visible={chooserOpen}
onClose={closeChooser}
registryId={account.id}
authority={authority}
accountLabel={accountLabel}
/>
<Dialog
visible={prompt?.kind === 'rationale'}
title={isContacts
? t('settings.device_sync.rationale_title_contacts', 'Allow access to contacts')
: t('settings.device_sync.rationale_title_calendar', 'Allow access to calendars')}
message={isContacts
? t(
'settings.device_sync.rationale_contacts',
'To show your address books in the Contacts app, Bulwark needs access to the contacts on this device. It only changes the contacts of its own account.',
)
: t(
'settings.device_sync.rationale_calendar',
'To show your calendars in the Calendar app, Bulwark needs access to the calendars on this device. It only changes the calendars of its own account.',
)}
confirmText={t('settings.device_sync.continue', 'Continue')}
onConfirm={() => { void turnOn(); }}
onCancel={() => setPrompt(null)}
/>
<Dialog
visible={prompt?.kind === 'blocked'}
title={t('settings.device_sync.blocked_title', 'Access is turned off')}
message={isContacts
? t(
'settings.device_sync.blocked_contacts',
"Android no longer asks for access to contacts. Allow it in the app's settings, then turn sync on again.",
)
: t(
'settings.device_sync.blocked_calendar',
"Android no longer asks for access to calendars. Allow it in the app's settings, then turn sync on again.",
)}
confirmText={t('settings.device_sync.open_app_settings', 'Open app settings')}
onConfirm={() => {
setPrompt(null);
void openAppSystemSettings();
}}
onCancel={() => setPrompt(null)}
/>
<Dialog
visible={prompt?.kind === 'pending'}
variant="destructive"
title={t('settings.device_sync.turn_off_title', 'Turn off anyway?')}
message={prompt?.kind === 'pending' && prompt.pending > 0
? t(
'settings.device_sync.turn_off_pending',
"{count, plural, one {# change made on this device hasn't reached the server yet. It is lost if you turn sync off now.} other {# changes made on this device haven't reached the server yet. They are lost if you turn sync off now.}}",
{ count: prompt.pending },
)
: t(
'settings.device_sync.turn_off_unknown',
'Bulwark could not check whether changes made on this device reached the server. They are lost if you turn sync off now.',
)}
confirmText={t('settings.device_sync.turn_off_anyway', 'Turn off')}
cancelText={t('settings.device_sync.keep_syncing', 'Keep syncing')}
onConfirm={() => { void turnOff(true); }}
onCancel={() => { void keepSyncing(); }}
/>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
block: {
borderBottomWidth: 1,
borderBottomColor: c.border,
paddingBottom: spacing.md,
},
row: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
statusRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 6 },
statusText: { ...typography.caption, flexShrink: 1 },
statusMuted: { color: c.mutedForeground },
statusWarning: { color: c.warning },
statusError: { color: c.error },
details: {
marginTop: spacing.sm,
marginStart: spacing.sm,
paddingStart: spacing.md,
borderStartWidth: 2,
borderStartColor: c.muted,
},
actions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginTop: spacing.sm },
});
}
@@ -0,0 +1,310 @@
// Which address books or calendars of one account sync to this device
// (docs/device-sync.md, "Settings sections"). The account's own collections
// are on unless turned off, shared ones off unless turned on; read-only ones
// are marked, and a collection another signed-in account already syncs gets
// a warning (it would show twice on the phone).
import React from 'react';
import {
ActivityIndicator, Animated, Easing, Modal, Pressable, ScrollView, StyleSheet, Text, View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { AlertTriangle, Check, X } from 'lucide-react-native';
import Button from '../../Button';
import { spacing, radius, typography, type ThemePalette } from '../../../theme/tokens';
import { useColors } from '../../../theme/colors';
import { useSheetDrag } from '../../../lib/use-sheet-drag';
import { useLocaleStore } from '../../../stores/locale-store';
import { useAccountStore } from '../../../stores/account-store';
import {
isCollectionSelected,
selectionFor,
useDeviceSyncStore,
emptyAccountDeviceSync,
} from '../../../stores/device-sync-store';
import { otherAccountsSyncing, type SyncCollection } from '../../../device-sync/app/collections';
import { CONTACTS_AUTHORITY, type Authority } from '../../../device-sync/types';
import { useSyncCollections } from './use-sync-collections';
interface Props {
visible: boolean;
/** `changed`: the selection was edited while the sheet was open. */
onClose: (changed: boolean) => void;
registryId: string;
authority: Authority;
/** The account's address, under the title. */
accountLabel: string;
}
export function DeviceSyncCollectionsSheet({ visible, onClose, registryId, authority, accountLabel }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const insets = useSafeAreaInsets();
const { state, reload } = useSyncCollections(registryId, authority, visible);
const entry = useDeviceSyncStore((s) => s.accounts[registryId]);
const allEntries = useDeviceSyncStore((s) => s.accounts);
const setCollectionSelected = useDeviceSyncStore((s) => s.setCollectionSelected);
const registry = useAccountStore((s) => s.accounts);
const changed = React.useRef(false);
const slideY = React.useRef(new Animated.Value(600)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const close = React.useCallback(() => {
const wasChanged = changed.current;
changed.current = false;
onClose(wasChanged);
}, [onClose]);
const dragHandlers = useSheetDrag({ slideY, closedY: 600, onClose: close });
React.useEffect(() => {
if (visible) {
changed.current = false;
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideY, { toValue: 600, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
]).start();
}
}, [visible, slideY, overlayOpacity]);
const selection = selectionFor(entry ?? emptyAccountDeviceSync(), authority);
const collections = state.kind === 'loaded' || state.kind === 'loading' ? state.collections ?? [] : [];
const groups = React.useMemo(() => {
const out: Array<{ jmapAccountId: string; isPersonal: boolean; accountName: string; items: SyncCollection[] }> = [];
for (const collection of collections) {
let group = out.find((g) => g.jmapAccountId === collection.jmapAccountId);
if (!group) {
group = {
jmapAccountId: collection.jmapAccountId,
isPersonal: collection.isPersonal,
accountName: collection.accountName,
items: [],
};
out.push(group);
}
group.items.push(collection);
}
return out;
}, [collections]);
const toggle = (collection: SyncCollection) => {
const on = isCollectionSelected(selection, authority, collection);
setCollectionSelected(registryId, authority, collection.key, !on);
changed.current = true;
};
const errorText = state.kind !== 'error'
? ''
: state.error === 'signedOut'
? t('settings.device_sync.list_error_signed_out', 'This account is not signed in on this device.')
: state.error === 'auth'
? t('settings.device_sync.list_error_auth', 'The server did not accept the sign-in.')
: state.error === 'network'
? t('settings.device_sync.list_error_network', 'Could not reach the server.')
: t('settings.device_sync.list_error_server', 'The server could not list them.');
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={close} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={close} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
]}
>
<View {...dragHandlers}>
<View style={styles.handleHit}>
<View style={styles.handle} />
</View>
<View style={styles.header}>
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={styles.title}>
{authority === CONTACTS_AUTHORITY
? t('settings.device_sync.choose_books_title', 'Address books to sync')
: t('settings.device_sync.choose_calendars_title', 'Calendars to sync')}
</Text>
<Text style={styles.subtitle} numberOfLines={1}>{accountLabel}</Text>
</View>
<Pressable
onPress={close}
hitSlop={8}
style={styles.close}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
<X size={18} color={c.textSecondary} />
</Pressable>
</View>
</View>
<ScrollView contentContainerStyle={styles.body}>
{state.kind === 'error' && (
<View style={styles.message} accessibilityRole="alert">
<Text style={styles.errorText}>{errorText}</Text>
<Button variant="outline" size="sm" onPress={reload}>
{t('common.retry', 'Retry')}
</Button>
</View>
)}
{(state.kind === 'loading' || state.kind === 'idle') && collections.length === 0 && (
<View style={styles.message}>
<ActivityIndicator size="small" color={c.primary} />
</View>
)}
{state.kind === 'loaded' && collections.length === 0 && (
<View style={styles.message}>
<Text style={styles.muted}>
{authority === CONTACTS_AUTHORITY
? t('settings.device_sync.no_books', 'This account has no address books.')
: t('settings.device_sync.no_calendars', 'This account has no calendars to sync.')}
</Text>
</View>
)}
{groups.map((group) => (
<View key={group.jmapAccountId} style={styles.group}>
<Text style={styles.groupTitle}>
{group.isPersonal
? t('settings.device_sync.own_collections', 'Your own')
: t('settings.device_sync.shared_by', 'Shared by {name}', { name: group.accountName })}
</Text>
{group.items.map((collection) => {
const on = isCollectionSelected(selection, authority, collection);
const others = on ? otherAccountsSyncing(registryId, authority, collection, registry, allEntries) : [];
const tint = collection.color ?? c.primary;
return (
<Pressable
key={collection.key}
onPress={() => toggle(collection)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
accessibilityRole="checkbox"
accessibilityState={{ checked: on }}
accessibilityLabel={collection.name}
>
<View style={[styles.box, { borderColor: tint, backgroundColor: on ? tint : 'transparent' }]}>
{on && <Check size={12} color={c.textInverse} />}
</View>
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={[styles.rowName, !on && styles.rowNameOff]} numberOfLines={1}>
{collection.name}
</Text>
{(collection.readOnly || collection.isDefault) && (
<Text style={styles.muted}>
{[
collection.isDefault ? t('contacts.address_books.default', 'Default') : null,
collection.readOnly ? t('settings.device_sync.read_only', 'Read-only') : null,
].filter(Boolean).join(' · ')}
</Text>
)}
{others.length > 0 && (
<View style={styles.warningRow}>
<AlertTriangle size={12} color={c.warning} />
<Text style={styles.warningText}>
{t(
'settings.device_sync.also_synced_by',
'Also synced for {accounts}, so it shows twice on this device.',
{ accounts: others.join(', ') },
)}
</Text>
</View>
)}
</View>
</Pressable>
);
})}
</View>
))}
<Text style={styles.hint}>
{authority === CONTACTS_AUTHORITY
? t(
'settings.device_sync.books_hint',
'Your own address books sync unless you turn them off; shared ones only when you turn them on.',
)
: t(
'settings.device_sync.calendars_hint',
'Your own calendars sync unless you turn them off; shared ones only when you turn them on. Task lists stay in Bulwark.',
)}
</Text>
</ScrollView>
</Animated.View>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
maxHeight: '80%',
backgroundColor: c.popover,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderTopWidth: 1,
borderColor: c.border,
paddingTop: spacing.sm,
},
handleHit: { alignItems: 'center', paddingTop: spacing.xs, paddingBottom: spacing.sm },
handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.border },
header: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
title: { ...typography.bodySemibold, color: c.text },
subtitle: { ...typography.caption, color: c.mutedForeground },
close: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: radius.xs },
body: { paddingBottom: spacing.md },
message: { alignItems: 'center', gap: spacing.sm, padding: spacing.lg },
errorText: { ...typography.caption, color: c.error, textAlign: 'center' },
muted: { ...typography.caption, color: c.mutedForeground },
group: { paddingTop: spacing.sm },
groupTitle: {
...typography.captionMedium,
color: c.mutedForeground,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.xs,
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm + 2,
minHeight: 44,
},
rowPressed: { backgroundColor: c.surfaceHover },
box: {
width: 18,
height: 18,
borderRadius: radius.xs,
borderWidth: 2,
alignItems: 'center',
justifyContent: 'center',
},
rowName: { ...typography.body, color: c.text },
rowNameOff: { color: c.textSecondary },
warningRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 4, marginTop: 2 },
warningText: { ...typography.caption, color: c.warning, flex: 1 },
hint: {
...typography.caption,
color: c.mutedForeground,
paddingHorizontal: spacing.lg,
paddingTop: spacing.md,
},
});
}
@@ -0,0 +1,118 @@
// "Sync to this device" in Settings → Contacts and Settings → Calendar
// (Android only, #34; docs/device-sync.md "Settings sections"): one block per
// signed-in account, then the accounts the app signed out while they synced
// (their data stays until the user signs in again or removes it). The panes
// pass the title and description, so the settings search indexes them where
// they are rendered.
import React from 'react';
import { StyleSheet, View } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { SettingsSection, SettingItem } from '../settings-section';
import Button from '../../Button';
import Dialog from '../../Dialog';
import { spacing, type ThemePalette } from '../../../theme/tokens';
import { useColors } from '../../../theme/colors';
import { useAccountStore } from '../../../stores/account-store';
import { useDeviceSyncStore } from '../../../stores/device-sync-store';
import { useLocaleStore } from '../../../stores/locale-store';
import { removeSuspendedAccount } from '../../../device-sync/app/lifecycle';
import type { Authority } from '../../../device-sync/types';
import type { RootStackParamList } from '../../../navigation/types';
import { DeviceSyncAccountRow } from './DeviceSyncAccountRow';
interface Props {
authority: Authority;
title: string;
description: string;
}
export function DeviceSyncSection({ authority, title, description }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const accounts = useAccountStore((s) => s.accounts);
const syncEntries = useDeviceSyncStore((s) => s.accounts);
const suspended = React.useMemo(
() => Object.keys(syncEntries)
.filter((id) => syncEntries[id].suspended && !accounts.some((a) => a.id === id))
.sort(),
[syncEntries, accounts],
);
if (accounts.length === 0 && suspended.length === 0) return null;
return (
<View style={styles.section}>
<SettingsSection title={title} description={description}>
{accounts.map((account) => (
<DeviceSyncAccountRow key={account.id} account={account} authority={authority} />
))}
{suspended.map((registryId) => (
<SuspendedAccountRow key={registryId} registryId={registryId} styles={styles} />
))}
</SettingsSection>
</View>
);
}
// An account the app signed out (its sign-in failed) while it synced: sync is
// off, the data stays. Signing in again resumes it; removing drops it.
function SuspendedAccountRow({ registryId, styles }: { registryId: string; styles: ReturnType<typeof makeStyles> }) {
const t = useLocaleStore((s) => s.t);
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const [confirming, setConfirming] = React.useState(false);
const [removing, setRemoving] = React.useState(false);
const remove = async () => {
setConfirming(false);
setRemoving(true);
try {
await removeSuspendedAccount(registryId);
} finally {
setRemoving(false);
}
};
return (
<View style={styles.block}>
<SettingItem
label={registryId}
description={t(
'settings.device_sync.suspended_desc',
'Signed out. Its contacts and calendars stay on this device, with the changes made here, and sync again once you sign in.',
)}
noBorder
/>
<View style={styles.actions}>
<Button variant="outline" size="sm" onPress={() => navigation.navigate('AddAccount')}>
{t('settings.device_sync.sign_in_again', 'Sign in again')}
</Button>
<Button variant="outline" size="sm" onPress={() => setConfirming(true)} loading={removing} disabled={removing}>
{t('settings.device_sync.remove_from_device', 'Remove from this device')}
</Button>
</View>
<Dialog
visible={confirming}
variant="destructive"
title={t('settings.device_sync.remove_title', 'Remove from this device?')}
message={t(
'settings.device_sync.remove_message',
"The account's contacts and calendars are removed from this device. Changes made here that haven't reached the server are lost.",
)}
confirmText={t('settings.device_sync.remove', 'Remove')}
onConfirm={() => { void remove(); }}
onCancel={() => setConfirming(false)}
/>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
section: { marginTop: spacing.xxxl },
block: {
borderBottomWidth: 1,
borderBottomColor: c.border,
paddingBottom: spacing.md,
},
actions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm },
});
}
@@ -0,0 +1,81 @@
import { describe, it, expect } from 'vitest';
import { translate, type MessageParams } from '../../../../i18n';
import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY, type RunStatus } from '../../../../device-sync/types';
import { describeSyncStatus, relativeTime, type StatusInput } from '../status';
const t = (key: string, fallback?: string, params?: MessageParams) => translate('en', key, fallback, params);
const NOW = Date.UTC(2026, 8, 27, 12, 0, 0);
const run = (outcome: RunStatus['outcome'], extra: Partial<RunStatus> = {}): RunStatus => ({
at: NOW - 5 * 60_000,
outcome,
durationMs: 100,
conflicts: 0,
itemErrors: 0,
stats: {
downloaded: { created: 0, updated: 0, deleted: 0 },
uploaded: { created: 0, updated: 0, deleted: 0 },
entries: 0,
skipped: 0,
},
...extra,
});
const base: StatusInput = {
authority: CONTACTS_AUTHORITY,
on: true,
paused: false,
removed: false,
permitted: true,
syncing: false,
now: NOW,
};
describe('describeSyncStatus', () => {
it('says off, or that the account was removed in Android settings', () => {
expect(describeSyncStatus({ ...base, on: false }, t)).toEqual({ text: 'Off', tone: 'muted' });
expect(describeSyncStatus({ ...base, on: false, removed: true }, t).tone).toBe('warning');
});
it('puts a revoked permission first, with a way to grant it again', () => {
const line = describeSyncStatus({ ...base, permitted: false, lastRun: run('ok') }, t);
expect(line).toMatchObject({ tone: 'error', action: 'grantAccess' });
expect(line.text).toContain('contacts');
expect(describeSyncStatus({ ...base, authority: CALENDAR_AUTHORITY, permitted: false }, t).text).toContain('calendars');
});
it('says when Android paused the account', () => {
expect(describeSyncStatus({ ...base, on: false, paused: true }, t))
.toEqual({ text: 'Paused in Android settings', tone: 'warning' });
});
it('shows a running sync, then when it last synced', () => {
expect(describeSyncStatus({ ...base, syncing: true, lastRun: run('ok') }, t).text).toBe('Syncing…');
expect(describeSyncStatus({ ...base, lastRun: run('ok') }, t)).toEqual({ text: 'Synced 5 min ago', tone: 'muted' });
expect(describeSyncStatus({ ...base, lastRun: run('ok', { itemErrors: 2 }) }, t)).toEqual({
text: 'Synced 5 min ago · 2 items could not be synced.',
tone: 'warning',
});
expect(describeSyncStatus(base, t).text).toBe('Waiting for the first sync');
});
it('offers what fixes a failed run', () => {
expect(describeSyncStatus({ ...base, lastRun: run('auth') }, t)).toMatchObject({ tone: 'error', action: 'signIn' });
expect(describeSyncStatus({ ...base, lastRun: run('tooManyDeletions') }, t)).toMatchObject({ action: 'reviewDeletions' });
expect(describeSyncStatus({ ...base, lastRun: run('permission') }, t)).toMatchObject({ action: 'grantAccess' });
expect(describeSyncStatus({ ...base, lastRun: run('io') }, t).text).toBe('Could not reach the server 5 min ago. Android tries again.');
expect(describeSyncStatus({ ...base, lastRun: run('internal', { message: 'Boom' }) }, t).text)
.toBe('The last sync failed: Boom');
// A run that found sync off says nothing new.
expect(describeSyncStatus({ ...base, lastRun: run('disabled') }, t).text).toBe('Waiting for the next sync');
});
});
describe('relativeTime', () => {
it('counts minutes, hours and days', () => {
expect(relativeTime(NOW - 10_000, NOW, t)).toBe('just now');
expect(relativeTime(NOW - 3 * 3600_000, NOW, t)).toBe('3 h ago');
expect(relativeTime(NOW - 2 * 86_400_000, NOW, t)).toBe('2 d ago');
});
});
@@ -0,0 +1,133 @@
// The status line of one account's device sync in Settings: off, paused in
// Android settings, syncing, when it last synced, or what went wrong and what
// the user can do about it (docs/device-sync.md, "Settings sections",
// "Failure matrix").
import { CONTACTS_AUTHORITY, type Authority, type RunStatus } from '../../../device-sync/types';
import type { TranslateFn } from '../../../stores/locale-store';
/** What the status line offers to do. */
export type StatusAction = 'signIn' | 'grantAccess' | 'reviewDeletions';
export interface StatusLine {
text: string;
tone: 'muted' | 'warning' | 'error';
action?: StatusAction;
}
export interface StatusInput {
authority: Authority;
/** The switch: Android syncs this authority automatically. */
on: boolean;
/** On in the app but off in Android's settings for the account. */
paused: boolean;
/** The Android account was removed in Android Settings. */
removed: boolean;
/** The runtime permissions; null while unknown. */
permitted: boolean | null;
syncing: boolean;
lastRun?: RunStatus;
now: number;
}
/** "just now", "5 min ago", … */
export function relativeTime(at: number, now: number, t: TranslateFn): string {
const diff = Math.max(0, now - at);
if (diff < 60_000) return t('settings.offline.just_now', 'just now');
if (diff < 60 * 60_000) return t('settings.offline.minutes_ago', '{count} min ago', { count: Math.round(diff / 60_000) });
if (diff < 24 * 60 * 60_000) {
return t('settings.offline.hours_ago', '{count} h ago', { count: Math.round(diff / (60 * 60_000)) });
}
return t('settings.offline.days_ago', '{count} d ago', { count: Math.round(diff / (24 * 60 * 60_000)) });
}
function permissionLine(authority: Authority, t: TranslateFn): StatusLine {
return {
text: authority === CONTACTS_AUTHORITY
? t('settings.device_sync.status_permission_contacts', 'Access to contacts was turned off, so nothing syncs.')
: t('settings.device_sync.status_permission_calendar', 'Access to calendars was turned off, so nothing syncs.'),
tone: 'error',
action: 'grantAccess',
};
}
function runLine(run: RunStatus, input: StatusInput, t: TranslateFn): StatusLine | null {
const when = relativeTime(run.at, input.now, t);
switch (run.outcome) {
case 'ok': {
const synced = t('settings.device_sync.status_synced', 'Synced {when}', { when });
if (run.itemErrors <= 0) return { text: synced, tone: 'muted' };
const skipped = t(
'settings.device_sync.status_item_errors',
'{count, plural, one {# item could not be synced.} other {# items could not be synced.}}',
{ count: run.itemErrors },
);
return { text: `${synced} · ${skipped}`, tone: 'warning' };
}
case 'auth':
return {
text: t('settings.device_sync.status_auth', 'The server no longer accepts the sign-in. Sign in again to keep syncing.'),
tone: 'error',
action: 'signIn',
};
case 'permission':
return permissionLine(input.authority, t);
case 'tooManyDeletions':
return {
text: t(
'settings.device_sync.status_too_many_deletions',
'Many items were deleted on this device. They are not deleted on the server until you confirm.',
),
tone: 'warning',
action: 'reviewDeletions',
};
case 'io':
return {
text: t('settings.device_sync.status_io', 'Could not reach the server {when}. Android tries again.', { when }),
tone: 'warning',
};
case 'unsupported':
return {
text: input.authority === CONTACTS_AUTHORITY
? t('settings.device_sync.status_unsupported_contacts', 'The server offers no address books for this account.')
: t('settings.device_sync.status_unsupported_calendar', 'The server offers no calendars for this account.'),
tone: 'error',
};
case 'safetyAbort':
return {
text: t(
'settings.device_sync.status_safety_abort',
'Stopped: the server returned nothing while this device still holds items. Check the server, then sync again.',
),
tone: 'error',
};
case 'cancelled':
return { text: t('settings.device_sync.status_cancelled', 'The last sync stopped early. It continues next time.'), tone: 'muted' };
case 'internal':
return {
text: run.message
? t('settings.device_sync.status_failed', 'The last sync failed: {message}', { message: run.message })
: t('settings.device_sync.status_failed_plain', 'The last sync failed.'),
tone: 'error',
};
case 'disabled':
default:
return null;
}
}
export function describeSyncStatus(input: StatusInput, t: TranslateFn): StatusLine {
if (!input.on && !input.paused) {
return input.removed
? {
text: t('settings.device_sync.status_removed', 'Removed in Android settings. Turn on to add the account again.'),
tone: 'warning',
}
: { text: t('settings.device_sync.status_off', 'Off'), tone: 'muted' };
}
if (input.permitted === false) return permissionLine(input.authority, t);
if (input.paused) return { text: t('settings.device_sync.status_paused', 'Paused in Android settings'), tone: 'warning' };
if (input.syncing) return { text: t('settings.device_sync.status_syncing', 'Syncing…'), tone: 'muted' };
if (!input.lastRun) return { text: t('settings.device_sync.status_waiting', 'Waiting for the first sync'), tone: 'muted' };
return runLine(input.lastRun, input, t)
?? { text: t('settings.device_sync.status_waiting_next', 'Waiting for the next sync'), tone: 'muted' };
}
@@ -0,0 +1,101 @@
// Android's side of one account's device sync for the settings: whether the
// Android account exists, its automatic sync for the authority (the switch),
// the device's master auto-sync, whether a sync runs or waits, and whether
// the runtime permissions are still granted. Re-read on mount, when the app
// returns to the foreground, when accounts of our type change, and every few
// seconds while a sync runs or waits.
import React from 'react';
import { AppState } from 'react-native';
import { getSyncSettings, listAndroidAccounts, onAccountsChanged } from '../../../device-sync/native';
import { hasSyncPermissions } from '../../../device-sync/app/permissions';
import type { Authority } from '../../../device-sync/types';
export interface AndroidSyncView {
/** The Android account exists. */
exists: boolean;
/** Automatic sync for the authority: the toggle in Settings → Accounts. */
automatic: boolean;
/** The device's "Auto-sync data". */
masterAutomatic: boolean;
active: boolean;
pending: boolean;
}
const POLL_MS = 3000;
export function useAndroidSync(accountName: string | undefined, authority: Authority): {
view: AndroidSyncView | null;
permitted: boolean | null;
refresh: () => Promise<void>;
} {
const [view, setView] = React.useState<AndroidSyncView | null>(null);
const [permitted, setPermitted] = React.useState<boolean | null>(null);
const alive = React.useRef(true);
React.useEffect(() => {
alive.current = true;
return () => { alive.current = false; };
}, []);
// Read at call time: turning sync on creates the account while the
// caller still holds the refresh of the render before.
const nameRef = React.useRef(accountName);
nameRef.current = accountName;
// Only the latest read may land; an older one resolving late is stale.
const reads = React.useRef(0);
const refresh = React.useCallback(async () => {
const read = ++reads.current;
const accountName = nameRef.current;
const granted = await hasSyncPermissions(authority);
let next: AndroidSyncView = { exists: false, automatic: false, masterAutomatic: true, active: false, pending: false };
if (accountName) {
try {
const accounts = await listAndroidAccounts();
if (accounts.some((a) => a.name === accountName)) {
const settings = await getSyncSettings(accountName);
const own = settings.authorities?.[authority];
next = {
exists: true,
automatic: !!own?.automatic,
masterAutomatic: settings.masterAutomatic !== false,
active: !!own?.active,
pending: !!own?.pending,
};
}
} catch {
// Shown as off until the next read.
}
}
if (!alive.current || read !== reads.current) return;
setPermitted(granted);
setView(next);
}, [authority]);
React.useEffect(() => {
void refresh();
}, [refresh, accountName]);
React.useEffect(() => {
const subscription = AppState.addEventListener('change', (state) => {
if (state === 'active') void refresh();
});
let unsubscribe: () => void = () => undefined;
try {
unsubscribe = onAccountsChanged(() => { void refresh(); });
} catch {
// Foreground reads still catch up.
}
return () => {
subscription.remove();
unsubscribe();
};
}, [refresh]);
const busy = !!view?.active || !!view?.pending;
React.useEffect(() => {
if (!busy) return undefined;
const timer = setInterval(() => { void refresh(); }, POLL_MS);
return () => clearInterval(timer);
}, [busy, refresh]);
return { view, permitted, refresh };
}
@@ -0,0 +1,70 @@
// The address books or calendars of one account for the device sync settings,
// listed through the account's own client (device-sync/app/collections) and
// cached for a little while, so the chooser, the counts and the "new contacts
// go to" picker share one request.
import React from 'react';
import {
cachedSyncCollections,
CollectionsError,
listSyncCollections,
onSyncCollectionsLoaded,
type CollectionsErrorKind,
type SyncCollection,
} from '../../../device-sync/app/collections';
import { CALENDAR_AUTHORITY, type Authority } from '../../../device-sync/types';
import { useAuthStore } from '../../../stores/auth-store';
import { useCalendarSubscriptionsStore } from '../../../stores/calendar-subscriptions-store';
export type CollectionsState =
| { kind: 'idle' }
| { kind: 'loading'; collections: SyncCollection[] | null }
| { kind: 'loaded'; collections: SyncCollection[] }
| { kind: 'error'; error: CollectionsErrorKind };
export function useSyncCollections(registryId: string, authority: Authority, active: boolean): {
state: CollectionsState;
reload: () => void;
} {
const [state, setState] = React.useState<CollectionsState>(() => {
const cached = cachedSyncCollections(registryId, authority);
return cached ? { kind: 'loaded', collections: cached } : { kind: 'idle' };
});
const [attempt, setAttempt] = React.useState(0);
// Calendars mirroring subscribed feeds are read-only on the device. Only
// the active account's subscriptions are known here.
const isActive = useAuthStore((s) => s.activeAccountId === registryId);
const subscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions);
const feeds = React.useMemo(
() => (authority === CALENDAR_AUTHORITY && isActive
? subscriptions.map((sub) => ({ calendarId: sub.calendarId, accountId: sub.accountId }))
: []),
[authority, isActive, subscriptions],
);
React.useEffect(() => {
if (!active) return undefined;
let cancelled = false;
const force = attempt > 0;
setState((prev) => ({ kind: 'loading', collections: prev.kind === 'loaded' ? prev.collections : null }));
listSyncCollections(registryId, authority, { force, feeds })
.then((collections) => {
if (!cancelled) setState({ kind: 'loaded', collections });
})
.catch((err: unknown) => {
if (cancelled) return;
setState({ kind: 'error', error: err instanceof CollectionsError ? err.kind : 'server' });
});
return () => { cancelled = true; };
}, [registryId, authority, active, attempt, feeds]);
// The chooser loads its own, fresher list: the row's counts follow it.
React.useEffect(
() => onSyncCollectionsLoaded((id, auth, collections) => {
if (id === registryId && auth === authority) setState({ kind: 'loaded', collections });
}),
[registryId, authority],
);
const reload = React.useCallback(() => setAttempt((n) => n + 1), []);
return { state, reload };
}
@@ -0,0 +1,244 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
CollectionsError,
forgetSyncCollections,
listSyncCollections,
onSyncCollectionsLoaded,
otherAccountsSyncing,
type CollectionsClient,
} from '../../app/collections';
import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY, JMAP_CALENDARS, JMAP_CONTACTS } from '../../types';
import { useDeviceSyncStore, waitForDeviceSyncHydration } from '../../../stores/device-sync-store';
import type { JMAPMethodCall } from '../../../api/types';
type Handler = (args: Record<string, any>, calls: JMAPMethodCall[]) => any;
// A client for one account with the personal JMAP account `c` and a shared
// account `team`, answering the calls the listing makes.
function fakeClient(handlers: Record<string, Handler>, options: { loaded?: boolean | Error; maxCalls?: number } = {}) {
const requests: JMAPMethodCall[][] = [];
const client: CollectionsClient = {
async loadAccount() {
if (options.loaded instanceof Error) throw options.loaded;
return options.loaded ?? true;
},
get currentSession() {
return {
primaryAccounts: { [JMAP_CONTACTS]: 'c', [JMAP_CALENDARS]: 'c' },
accounts: {
c: { name: 'alice@example.org', isPersonal: true, accountCapabilities: { [JMAP_CONTACTS]: {}, [JMAP_CALENDARS]: {} } },
team: { name: 'Team', isPersonal: false, accountCapabilities: { [JMAP_CONTACTS]: {}, [JMAP_CALENDARS]: {} } },
mailonly: { name: 'Archive', isPersonal: false, accountCapabilities: {} },
},
};
},
async request(calls) {
requests.push(calls);
const responses: Array<[string, any, string]> = [];
for (const [name, args, id] of calls) {
const handler = handlers[`${name} ${args.accountId}`] ?? handlers[name];
if (!handler) {
responses.push(['error', { type: 'unknownMethod' }, id]);
continue;
}
const body = handler(args, calls);
responses.push(body === null ? ['error', { type: 'forbidden' }, id] : [name, body, id]);
}
return { methodResponses: responses };
},
getMaxCallsInRequest: () => options.maxCalls ?? 16,
};
return { client, requests };
}
beforeEach(async () => {
await waitForDeviceSyncHydration();
useDeviceSyncStore.setState({ accounts: {} });
forgetSyncCollections();
});
describe('listSyncCollections', () => {
it('lists the personal address books first, then the shared ones, and marks read-only ones', async () => {
const { client, requests } = fakeClient({
'AddressBook/get c': () => ({ list: [
{ id: 'b2', name: 'Work', sortOrder: 2, myRights: { mayWrite: true } },
{ id: 'b1', name: 'Personal', isDefault: true, sortOrder: 1, myRights: { mayWrite: true } },
] }),
'AddressBook/get team': () => ({ list: [{ id: 'b1', name: 'Team contacts', myRights: { mayWrite: false } }] }),
});
const list = await listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client });
expect(list.map((c) => [c.key, c.name, c.isPersonal, c.readOnly, c.isDefault])).toEqual([
['c/b1', 'Personal', true, false, true],
['c/b2', 'Work', true, false, false],
['team/b1', 'Team contacts', false, true, false],
]);
expect(list[2].accountName).toBe('Team');
// Explicit properties, the contacts capability, and never the mail-only account.
const [calls] = requests;
expect(calls.map(([name, args]) => `${name} ${args.accountId}`)).toEqual(['AddressBook/get c', 'AddressBook/get team']);
expect(calls[0][1].properties).toEqual(expect.arrayContaining(['id', 'name', 'myRights']));
});
it('tells every view of the account when a fresh list arrived', async () => {
const { client } = fakeClient({
'AddressBook/get c': () => ({ list: [{ id: 'b1', name: 'Personal' }] }),
'AddressBook/get team': () => ({ list: [] }),
});
const seen: string[][] = [];
const stop = onSyncCollectionsLoaded((id, authority, list) => seen.push([id, authority, ...list.map((c) => c.key)]));
await listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client });
stop();
await listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client, force: true });
expect(seen).toEqual([['alice@x', CONTACTS_AUTHORITY, 'c/b1']]);
});
it('remembers the personal JMAP account for the push routes', async () => {
const { client } = fakeClient({ 'AddressBook/get': () => ({ list: [] }) });
await listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client });
expect(useDeviceSyncStore.getState().accounts['alice@x']?.primaryJmapAccounts).toEqual({ [CONTACTS_AUTHORITY]: 'c' });
});
it('keeps the list when a shared account refuses', async () => {
const { client } = fakeClient({
'AddressBook/get c': () => ({ list: [{ id: 'b1', name: 'Personal' }] }),
'AddressBook/get team': () => null,
});
const list = await listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client });
expect(list.map((c) => c.key)).toEqual(['c/b1']);
});
it('leaves out calendars it may not read and calendars holding only tasks', async () => {
// What each calendar's scan finds: `todo` holds only tasks, `empty` nothing.
const scans: Record<string, any[]> = {
cal1: [{ id: 'e', '@type': 'Event' }],
todo: [{ id: 't', '@type': 'Task' }, { id: 'u', due: '2026-10-01T00:00:00' }],
empty: [],
};
const { client } = fakeClient({
'Calendar/get c': () => ({ list: [
{ id: 'cal1', name: 'Personal', color: '#ff0000', myRights: { mayReadItems: true, mayWriteAll: true } },
{ id: 'todo', name: 'Tasks', myRights: { mayReadItems: true, mayWriteAll: true } },
{ id: 'empty', name: 'New', myRights: { mayReadItems: true, mayWriteAll: true } },
{ id: 'hidden', name: 'Free/busy only', myRights: { mayReadItems: false } },
] }),
'Calendar/get team': () => ({ list: [
{ id: 'cal1', name: 'Team', myRights: { mayReadItems: true, mayWriteAll: false, mayWriteOwn: false } },
] }),
'CalendarEvent/query': (args) => ({ ids: (scans[args.filter.inCalendar] ?? []).map((o) => o.id) }),
'CalendarEvent/get': (args, calls) => {
// The get reads the ids of the query it references.
const query = calls.find(([, , id]) => id === args['#ids'].resultOf)!;
return { list: scans[(query[1].filter as { inCalendar: string }).inCalendar] ?? [] };
},
});
const list = await listSyncCollections('alice@x', CALENDAR_AUTHORITY, { client });
// By name within an account (no sort order given).
expect(list.map((c) => [c.key, c.readOnly])).toEqual([
['c/empty', false],
['c/cal1', false],
['team/cal1', true],
]);
expect(list.find((c) => c.key === 'c/cal1')?.color).toBe('#ff0000');
});
it('marks calendars mirroring a subscribed feed read-only', async () => {
const { client } = fakeClient({
'Calendar/get c': () => ({ list: [
{ id: 'feed', name: 'Holidays', myRights: { mayReadItems: true, mayWriteAll: true } },
] }),
'Calendar/get team': () => ({ list: [] }),
'CalendarEvent/query': () => ({ ids: [] }),
'CalendarEvent/get': () => ({ list: [] }),
});
const list = await listSyncCollections('alice@x', CALENDAR_AUTHORITY, { client, feeds: [{ calendarId: 'feed' }] });
expect(list.map((c) => [c.key, c.readOnly])).toEqual([['c/feed', true]]);
});
it('keeps a query and the get that reads it in one request', async () => {
const { client, requests } = fakeClient({
'Calendar/get': () => ({ list: [
{ id: 'a', name: 'A' }, { id: 'b', name: 'B' }, { id: 'c', name: 'C' },
] }),
'CalendarEvent/query': () => ({ ids: [] }),
'CalendarEvent/get': () => ({ list: [] }),
}, { maxCalls: 5 });
await listSyncCollections('alice@x', CALENDAR_AUTHORITY, { client });
const scanRequests = requests.filter((calls) => calls[0][0] === 'CalendarEvent/query');
for (const calls of scanRequests) {
expect(calls.length % 2).toBe(0);
expect(calls.length).toBeLessThanOrEqual(5);
}
});
it('says why the list could not be loaded', async () => {
const signedOut = fakeClient({}, { loaded: false }).client;
await expect(listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client: signedOut }))
.rejects.toMatchObject({ kind: 'signedOut' });
const auth = Object.assign(new Error('Invalid credentials'), { name: 'AuthenticationError' });
await expect(listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client: fakeClient({}, { loaded: auth }).client }))
.rejects.toBeInstanceOf(CollectionsError);
await expect(listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client: fakeClient({}, { loaded: auth }).client }))
.rejects.toMatchObject({ kind: 'auth' });
const offline = Object.assign(new Error('Server unreachable'), { name: 'NetworkError' });
await expect(listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client: fakeClient({}, { loaded: offline }).client }))
.rejects.toMatchObject({ kind: 'network' });
});
});
describe('otherAccountsSyncing', () => {
const registry = [
{ id: 'alice@mail.example.org', serverUrl: 'https://mail.example.org', email: 'alice@example.org', username: 'alice' },
{ id: 'bob@mail.example.org', serverUrl: 'https://mail.example.org/', email: 'bob@example.org', username: 'bob' },
{ id: 'bob@other.example.net', serverUrl: 'https://other.example.net', email: 'bob@example.net', username: 'bob' },
];
it('names the other accounts on the same server that sync the same collection', () => {
const accounts = {
'bob@mail.example.org': {
contactsSelection: { 'team/b1': true },
calendarSelection: {},
reminderOwner: null,
intervalSeconds: {},
lastRun: {},
enabled: { [CONTACTS_AUTHORITY]: true },
},
// Another server: the same JMAP ids mean other data.
'bob@other.example.net': {
contactsSelection: { 'team/b1': true },
calendarSelection: {},
reminderOwner: null,
intervalSeconds: {},
lastRun: {},
enabled: { [CONTACTS_AUTHORITY]: true },
},
};
const collection = { key: 'team/b1', jmapAccountId: 'team' };
expect(otherAccountsSyncing('alice@mail.example.org', CONTACTS_AUTHORITY, collection, registry, accounts))
.toEqual(['bob@example.org']);
// Not for another authority, nor once Bob's contacts sync is off.
expect(otherAccountsSyncing('alice@mail.example.org', CALENDAR_AUTHORITY, collection, registry, accounts)).toEqual([]);
accounts['bob@mail.example.org'].enabled = { [CONTACTS_AUTHORITY]: false };
expect(otherAccountsSyncing('alice@mail.example.org', CONTACTS_AUTHORITY, collection, registry, accounts)).toEqual([]);
});
it("counts the other account's own collections, which sync by default", () => {
const accounts = {
'bob@mail.example.org': {
contactsSelection: {},
calendarSelection: {},
reminderOwner: null,
intervalSeconds: {},
lastRun: {},
enabled: { [CALENDAR_AUTHORITY]: true },
primaryJmapAccounts: { [CALENDAR_AUTHORITY]: 'bobacct' },
},
};
expect(otherAccountsSyncing(
'alice@mail.example.org',
CALENDAR_AUTHORITY,
{ key: 'bobacct/cal1', jmapAccountId: 'bobacct' },
registry,
accounts,
)).toEqual(['bob@example.org']);
});
});
@@ -0,0 +1,591 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import AsyncStorage from '@react-native-async-storage/async-storage';
import {
CALENDAR_AUTHORITY,
CONTACTS_AUTHORITY,
type Authority,
} from '../../types';
// A fake of the native module: Android's accounts of our type and their sync
// settings, with every call logged in order.
const h = vi.hoisted(() => {
type Settings = { automatic: boolean; periodicSeconds: number };
const state = {
available: true,
permitted: true,
master: true,
accounts: [] as Array<{ name: string; registryId: string | null; sync: Record<string, Settings> }>,
log: [] as string[],
failSetSyncEnabled: false,
};
const find = (name: string) => state.accounts.find((a) => a.name === name);
const settingsOf = (name: string, authority: string) => {
const account = find(name);
if (!account) return { automatic: false, periodicSeconds: 0 };
account.sync[authority] ??= { automatic: false, periodicSeconds: 0 };
return account.sync[authority];
};
const teardown = async (
_registryId: string,
name: string,
authority: string,
options?: { uploadFirst?: boolean; timeoutMs?: number; force?: boolean },
) => {
state.log.push(`teardown ${name} ${authority} ${JSON.stringify(options ?? {})}`);
return { pending: 0 };
};
return { state, find, settingsOf, teardown };
});
vi.mock('../../native', () => ({
isDeviceSyncAvailable: () => h.state.available,
listAndroidAccounts: vi.fn(async () => h.state.accounts.map((a) => ({ name: a.name, registryId: a.registryId }))),
ensureAndroidAccount: vi.fn(async (name: string, registryId: string) => {
h.state.log.push(`ensure ${name}`);
const existing = h.find(name);
// As the native module rejects (DeviceSyncAccounts.ensure → AccountConflictException).
if (existing && existing.registryId !== registryId) throw Object.assign(new Error(`${name} belongs to another app account`), { code: 'conflict' });
if (existing) return false;
h.state.accounts.push({ name, registryId, sync: {} });
return true;
}),
removeAndroidAccount: vi.fn(async (name: string) => {
h.state.log.push(`remove ${name}`);
h.state.accounts = h.state.accounts.filter((a) => a.name !== name);
return true;
}),
getSyncSettings: vi.fn(async (name: string) => ({
masterAutomatic: h.state.master,
authorities: {
'com.android.contacts': { syncable: 1, active: false, pending: false, ...h.settingsOf(name, 'com.android.contacts') },
'com.android.calendar': { syncable: 1, active: false, pending: false, ...h.settingsOf(name, 'com.android.calendar') },
},
})),
setSyncEnabled: vi.fn(async (name: string, authority: string, enabled: boolean) => {
if (h.state.failSetSyncEnabled) throw new Error('boom');
h.state.log.push(`auto ${name} ${authority} ${enabled}`);
h.settingsOf(name, authority).automatic = enabled;
}),
setPeriodicSync: vi.fn(async (name: string, authority: string, seconds: number) => {
h.state.log.push(`periodic ${name} ${authority} ${seconds}`);
h.settingsOf(name, authority).periodicSeconds = seconds;
}),
requestSync: vi.fn(async (name: string, authority: string, options?: Record<string, unknown>) => {
h.state.log.push(`sync ${name} ${authority} ${JSON.stringify(options ?? {})}`);
}),
openAccountSettings: vi.fn(async () => undefined),
setPushRoutes: vi.fn(async () => undefined),
showSyncProblem: vi.fn(async (name: string) => { h.state.log.push(`problem ${name}`); }),
clearSyncProblem: vi.fn(async (name: string) => { h.state.log.push(`clear ${name}`); }),
onAccountsChanged: vi.fn(() => () => undefined),
}));
vi.mock('../../task', () => ({ teardownAuthority: vi.fn(h.teardown) }));
vi.mock('../../app/permissions', () => ({
hasSyncPermissions: vi.fn(async () => h.state.permitted),
}));
vi.mock('../../../lib/push-notifications', () => ({
refreshPushSubscriptionTypes: vi.fn(async () => undefined),
}));
import * as native from '../../native';
import * as task from '../../task';
import { refreshPushSubscriptionTypes } from '../../../lib/push-notifications';
import { useAccountStore, type AccountEntry } from '../../../stores/account-store';
import {
accountDeviceSync,
useDeviceSyncStore,
waitForDeviceSyncHydration,
} from '../../../stores/device-sync-store';
import {
changeReminderOwner,
changeSyncInterval,
deviceSyncSignedIn,
disableDeviceSync,
enableDeviceSync,
preferredAndroidAccountName,
reconcileDeviceAccounts,
releaseDeviceSyncBeforeSignOut,
removeSuspendedAccount,
resolveDeletions,
resumeDeviceSync,
TEARDOWN_TIMEOUT_MS,
} from '../../app/lifecycle';
const ALICE = 'alice@mail.example.org';
const ALICE2 = 'alice@other.example.net';
const BOB = 'bob@mail.example.org';
function entry(id: string, username: string): AccountEntry {
return {
id,
serverUrl: `https://${id.split('@').pop()}`,
username,
displayName: username,
email: `${username}@example.org`,
avatarColor: '#000',
lastLoginAt: 0,
isConnected: true,
hasError: false,
isDefault: false,
};
}
async function ready(registry: AccountEntry[]): Promise<void> {
await waitForDeviceSyncHydration();
if (!useAccountStore.persist.hasHydrated()) {
await new Promise<void>((resolve) => {
const off = useAccountStore.persist.onFinishHydration(() => { off(); resolve(); });
});
}
useAccountStore.setState({ accounts: registry, activeAccountId: registry[0]?.id ?? null });
}
const teardown = vi.mocked(task.teardownAuthority);
const setSyncEnabled = vi.mocked(native.setSyncEnabled);
beforeEach(async () => {
vi.clearAllMocks();
h.state.available = true;
h.state.permitted = true;
h.state.master = true;
h.state.accounts = [];
h.state.log = [];
h.state.failSetSyncEnabled = false;
teardown.mockImplementation(h.teardown);
await AsyncStorage.clear();
useDeviceSyncStore.setState({ accounts: {} });
await ready([entry(ALICE, 'alice'), entry(BOB, 'bob')]);
});
describe('enable', () => {
it('creates the Android account under the login, turns automatic and periodic sync on and syncs', async () => {
useDeviceSyncStore.getState().setIntervalSeconds(ALICE, CONTACTS_AUTHORITY, 1800);
const outcome = await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
expect(outcome).toEqual({ kind: 'enabled', accountName: 'alice' });
expect(h.state.log).toEqual([
'ensure alice',
`auto alice ${CONTACTS_AUTHORITY} true`,
`periodic alice ${CONTACTS_AUTHORITY} 1800`,
`sync alice ${CONTACTS_AUTHORITY} {"manual":true}`,
]);
const stored = accountDeviceSync(ALICE);
expect(stored.androidAccountName).toBe('alice');
expect(stored.enabled).toEqual({ [CONTACTS_AUTHORITY]: true });
await vi.waitFor(() => expect(refreshPushSubscriptionTypes).toHaveBeenCalledWith(ALICE));
});
it('uses the default hourly interval', async () => {
await enableDeviceSync(ALICE, CALENDAR_AUTHORITY);
expect(h.state.log).toContain(`periodic alice ${CALENDAR_AUTHORITY} 3600`);
});
it('does nothing without the runtime permissions', async () => {
h.state.permitted = false;
expect(await enableDeviceSync(ALICE, CONTACTS_AUTHORITY)).toEqual({ kind: 'permission' });
expect(h.state.log).toEqual([]);
expect(accountDeviceSync(ALICE).enabled).toBeUndefined();
});
it('reuses the Android account of the app account', async () => {
h.state.accounts.push({ name: 'alice', registryId: ALICE, sync: {} });
await enableDeviceSync(ALICE, CALENDAR_AUTHORITY);
expect(h.state.log.filter((l) => l.startsWith('ensure'))).toEqual([]);
expect(accountDeviceSync(ALICE).androidAccountName).toBe('alice');
});
it('names the account after its registry id when another app account has the login', async () => {
await ready([entry(ALICE, 'alice'), entry(ALICE2, 'alice')]);
h.state.accounts.push({ name: 'alice', registryId: ALICE, sync: {} });
expect(preferredAndroidAccountName(ALICE2, await native.listAndroidAccounts())).toBe(ALICE2);
const outcome = await enableDeviceSync(ALICE2, CONTACTS_AUTHORITY);
expect(outcome).toEqual({ kind: 'enabled', accountName: ALICE2 });
});
it('falls back to the registry id when the login turns out to be taken', async () => {
const list = vi.mocked(native.listAndroidAccounts);
// A stale list: the clash only shows when the account is added.
list.mockResolvedValueOnce([]);
h.state.accounts.push({ name: 'alice', registryId: ALICE2, sync: {} });
const outcome = await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
expect(outcome).toEqual({ kind: 'enabled', accountName: ALICE });
});
it('adds no second account named after the registry id when adding the account fails for another reason', async () => {
for (const code of ['failed', 'permission']) {
vi.mocked(native.ensureAndroidAccount).mockClear();
vi.mocked(native.ensureAndroidAccount).mockRejectedValueOnce(Object.assign(new Error(`AccountManager said no (${code})`), { code }));
const outcome = await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
expect(outcome).toEqual({ kind: 'failed', message: `AccountManager said no (${code})` });
expect(native.ensureAndroidAccount).toHaveBeenCalledTimes(1);
expect(h.state.accounts).toEqual([]);
expect(accountDeviceSync(ALICE).androidAccountName).toBeUndefined();
}
});
it('leaves no account behind when turning sync on fails', async () => {
h.state.failSetSyncEnabled = true;
const outcome = await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
expect(outcome.kind).toBe('failed');
expect(h.state.accounts).toEqual([]);
expect(accountDeviceSync(ALICE).enabled).toBeUndefined();
});
it('keeps the account of the other authority when turning one on fails', async () => {
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
vi.mocked(native.setPeriodicSync).mockRejectedValueOnce(new Error('boom'));
expect((await enableDeviceSync(ALICE, CALENDAR_AUTHORITY)).kind).toBe('failed');
expect(h.find('alice')?.sync[CALENDAR_AUTHORITY].automatic).toBe(false);
expect(h.find('alice')?.sync[CONTACTS_AUTHORITY].automatic).toBe(true);
expect(accountDeviceSync(ALICE)).toMatchObject({
androidAccountName: 'alice',
enabled: { [CONTACTS_AUTHORITY]: true },
});
});
it('is a no-op where device sync is not available', async () => {
h.state.available = false;
expect((await enableDeviceSync(ALICE, CONTACTS_AUTHORITY)).kind).toBe('failed');
expect(h.state.log).toEqual([]);
});
});
describe('disable', () => {
beforeEach(async () => {
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
await enableDeviceSync(ALICE, CALENDAR_AUTHORITY);
await changeReminderOwner(ALICE, 'bulwark');
h.state.log = [];
});
it('turns automatic sync off before the teardown uploads and deletes', async () => {
const result = await disableDeviceSync(ALICE, CALENDAR_AUTHORITY);
expect(result).toEqual({ done: true, pending: 0 });
expect(h.state.log).toEqual([
`auto alice ${CALENDAR_AUTHORITY} false`,
`teardown alice ${CALENDAR_AUTHORITY} {"uploadFirst":true,"timeoutMs":${TEARDOWN_TIMEOUT_MS}}`,
]);
const stored = accountDeviceSync(ALICE);
expect(stored.enabled).toEqual({ [CONTACTS_AUTHORITY]: true, [CALENDAR_AUTHORITY]: false });
// Contacts still sync: the account stays; the reminder question comes again next time.
expect(h.find('alice')).toBeDefined();
expect(stored.reminderOwner).toBeNull();
});
it('lets a turn-on wait for a turn-off that is still tearing down, instead of being undone by it', async () => {
await disableDeviceSync(ALICE, CALENDAR_AUTHORITY);
let finishTeardown: () => void = () => undefined;
teardown.mockImplementationOnce(async (_registryId, name, authority, options) => {
h.state.log.push(`teardown ${name} ${authority} ${JSON.stringify(options ?? {})}`);
await new Promise<void>((resolve) => { finishTeardown = resolve; });
return { pending: 0 };
});
const turningOff = disableDeviceSync(ALICE, CONTACTS_AUTHORITY);
await vi.waitFor(() => expect(h.state.log).toContain(`teardown alice ${CONTACTS_AUTHORITY} {"uploadFirst":true,"timeoutMs":${TEARDOWN_TIMEOUT_MS}}`));
// The user opens the settings again and turns contacts sync back on.
const turningOn = enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
finishTeardown();
expect(await turningOff).toEqual({ done: true, pending: 0 });
expect(await turningOn).toMatchObject({ kind: 'enabled' });
expect(h.find('alice')?.sync[CONTACTS_AUTHORITY]?.automatic).toBe(true);
expect(accountDeviceSync(ALICE).enabled?.[CONTACTS_AUTHORITY]).toBe(true);
expect(accountDeviceSync(ALICE).androidAccountName).toBe('alice');
});
it('removes the Android account once both authorities are off', async () => {
await disableDeviceSync(ALICE, CALENDAR_AUTHORITY);
await disableDeviceSync(ALICE, CONTACTS_AUTHORITY);
expect(h.state.accounts).toEqual([]);
expect(h.state.log).toContain('remove alice');
expect(accountDeviceSync(ALICE).androidAccountName).toBeUndefined();
});
it('deletes nothing and reports the waiting changes when they could not be uploaded', async () => {
teardown.mockResolvedValueOnce({ pending: 3 });
const result = await disableDeviceSync(ALICE, CONTACTS_AUTHORITY);
expect(result).toEqual({ done: false, pending: 3 });
expect(accountDeviceSync(ALICE).enabled?.[CONTACTS_AUTHORITY]).toBe(true);
// The user keeps syncing: automatic sync comes back.
h.state.log = [];
await resumeDeviceSync(ALICE, CONTACTS_AUTHORITY);
expect(h.state.log).toEqual([
`auto alice ${CONTACTS_AUTHORITY} true`,
`sync alice ${CONTACTS_AUTHORITY} {"manual":true}`,
]);
});
it('deletes on the user\'s word with force', async () => {
teardown.mockResolvedValueOnce({ pending: 2 });
await disableDeviceSync(ALICE, CONTACTS_AUTHORITY);
h.state.log = [];
const result = await disableDeviceSync(ALICE, CONTACTS_AUTHORITY, { force: true });
expect(result.done).toBe(true);
expect(h.state.log).toContain(`teardown alice ${CONTACTS_AUTHORITY} {"force":true,"timeoutMs":5000}`);
expect(accountDeviceSync(ALICE).enabled?.[CONTACTS_AUTHORITY]).toBe(false);
});
it('reports a failed teardown as unknown changes', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
teardown.mockRejectedValueOnce(new Error('provider gone'));
expect(await disableDeviceSync(ALICE, CONTACTS_AUTHORITY)).toEqual({ done: false, pending: 0, failed: true });
warn.mockRestore();
});
});
describe('sign-out', () => {
it('lets an account without device sync go at once', async () => {
const confirm = vi.fn(async () => true);
expect(await releaseDeviceSyncBeforeSignOut([BOB], confirm)).toBe(true);
expect(teardown).not.toHaveBeenCalled();
expect(confirm).not.toHaveBeenCalled();
});
it('uploads, deletes and removes the Android account before the credentials go', async () => {
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
h.state.log = [];
const confirm = vi.fn(async () => true);
expect(await releaseDeviceSyncBeforeSignOut([ALICE], confirm)).toBe(true);
expect(confirm).not.toHaveBeenCalled();
expect(teardown).toHaveBeenCalledWith(ALICE, 'alice', CONTACTS_AUTHORITY, { uploadFirst: true, timeoutMs: TEARDOWN_TIMEOUT_MS });
expect(teardown).toHaveBeenCalledWith(ALICE, 'alice', CALENDAR_AUTHORITY, { uploadFirst: true, timeoutMs: TEARDOWN_TIMEOUT_MS });
expect(h.state.accounts).toEqual([]);
expect(useDeviceSyncStore.getState().accounts[ALICE]).toBeUndefined();
// Automatic sync went off before the first teardown.
expect(h.state.log.indexOf(`auto alice ${CONTACTS_AUTHORITY} false`))
.toBeLessThan(h.state.log.findIndex((l) => l.startsWith('teardown')));
});
it('asks before losing changes, and keeps syncing when the user stays', async () => {
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
teardown.mockImplementation(async (_r: string, _n: string, authority: string) => ({ pending: authority === CONTACTS_AUTHORITY ? 4 : 0 }));
const confirm = vi.fn(async () => false);
expect(await releaseDeviceSyncBeforeSignOut([ALICE], confirm)).toBe(false);
expect(confirm).toHaveBeenCalledWith(4);
expect(h.find('alice')?.sync[CONTACTS_AUTHORITY].automatic).toBe(true);
expect(accountDeviceSync(ALICE).androidAccountName).toBe('alice');
});
it('deletes what is left once the user agrees', async () => {
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
teardown.mockImplementation(async (_r: string, _n: string, authority: string, options?: { force?: boolean }) =>
({ pending: authority === CONTACTS_AUTHORITY && !options?.force ? 4 : 0 }));
const confirm = vi.fn(async () => true);
expect(await releaseDeviceSyncBeforeSignOut([ALICE], confirm)).toBe(true);
expect(teardown).toHaveBeenCalledWith(ALICE, 'alice', CONTACTS_AUTHORITY, { force: true, timeoutMs: 5_000 });
expect(h.state.accounts).toEqual([]);
});
it('asks once for several accounts', async () => {
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
await enableDeviceSync(BOB, CALENDAR_AUTHORITY);
teardown.mockImplementation(async (_r: string, _n: string, _a: string, options?: { force?: boolean }) => ({ pending: options?.force ? 0 : 1 }));
const confirm = vi.fn(async () => true);
expect(await releaseDeviceSyncBeforeSignOut([ALICE, BOB], confirm)).toBe(true);
expect(confirm).toHaveBeenCalledTimes(1);
expect(confirm).toHaveBeenCalledWith(4);
expect(h.state.accounts).toEqual([]);
});
it('waits for a turn-on in progress, then releases what it set up', async () => {
let finishEnsure: () => void = () => undefined;
vi.mocked(native.ensureAndroidAccount).mockImplementationOnce(async (name: string, registryId: string) => {
h.state.log.push(`ensure ${name}`);
await new Promise<void>((resolve) => { finishEnsure = resolve; });
h.state.accounts.push({ name, registryId, sync: {} });
return true;
});
const turningOn = enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
await vi.waitFor(() => expect(h.state.log).toContain('ensure alice'));
const signingOut = releaseDeviceSyncBeforeSignOut([ALICE], vi.fn(async () => true));
finishEnsure();
expect(await signingOut).toBe(true);
await turningOn;
expect(teardown).toHaveBeenCalledWith(ALICE, 'alice', CONTACTS_AUTHORITY, { uploadFirst: true, timeoutMs: TEARDOWN_TIMEOUT_MS });
expect(h.state.accounts).toEqual([]);
expect(useDeviceSyncStore.getState().accounts[ALICE]).toBeUndefined();
});
it('lets a turn-on or turn-off asked for during the sign-out set nothing up for the account', async () => {
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
let finishTeardown: () => void = () => undefined;
teardown.mockImplementationOnce(async () => {
await new Promise<void>((resolve) => { finishTeardown = resolve; });
return { pending: 0 };
});
const signingOut = releaseDeviceSyncBeforeSignOut([ALICE], vi.fn(async () => true));
await vi.waitFor(() => expect(teardown).toHaveBeenCalled());
const turningOn = enableDeviceSync(ALICE, CALENDAR_AUTHORITY);
const turningOff = disableDeviceSync(ALICE, CONTACTS_AUTHORITY);
finishTeardown();
expect(await signingOut).toBe(true);
expect((await turningOn).kind).toBe('failed');
expect(await turningOff).toEqual({ done: true, pending: 0 });
expect(h.state.accounts).toEqual([]);
expect(h.state.log.filter((l) => l.startsWith('ensure') || l.startsWith('sync'))).toEqual(['ensure alice', `sync alice ${CONTACTS_AUTHORITY} {"manual":true}`]);
expect(useDeviceSyncStore.getState().accounts[ALICE]).toBeUndefined();
});
it('lets a turn-on asked for during the sign-out go ahead once the user chose to stay signed in', async () => {
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
teardown.mockImplementation(async (_r: string, _n: string, authority: string) => ({ pending: authority === CONTACTS_AUTHORITY ? 1 : 0 }));
let answer: (signOut: boolean) => void = () => undefined;
const confirm = vi.fn(() => new Promise<boolean>((resolve) => { answer = resolve; }));
const signingOut = releaseDeviceSyncBeforeSignOut([ALICE], confirm);
await vi.waitFor(() => expect(confirm).toHaveBeenCalled());
const turningOn = enableDeviceSync(ALICE, CALENDAR_AUTHORITY);
answer(false);
expect(await signingOut).toBe(false);
expect(await turningOn).toEqual({ kind: 'enabled', accountName: 'alice' });
expect(h.find('alice')?.sync[CALENDAR_AUTHORITY].automatic).toBe(true);
});
});
describe('reconcile', () => {
it('suspends an Android account whose app account the registry dropped, once', async () => {
h.state.accounts.push({
name: 'carol',
registryId: 'carol@mail.example.org',
sync: { [CONTACTS_AUTHORITY]: { automatic: true, periodicSeconds: 3600 } },
});
await reconcileDeviceAccounts();
expect(h.find('carol')?.sync[CONTACTS_AUTHORITY].automatic).toBe(false);
expect(h.find('carol')).toBeDefined();
expect(native.showSyncProblem).toHaveBeenCalledTimes(1);
expect(vi.mocked(native.showSyncProblem).mock.calls[0][3]).toBe('bulwarkmobile://settings/account');
const carol = accountDeviceSync('carol@mail.example.org');
expect(carol.suspended).toBe(true);
expect(carol.enabled).toEqual({ [CONTACTS_AUTHORITY]: true });
await reconcileDeviceAccounts();
expect(native.showSyncProblem).toHaveBeenCalledTimes(1);
});
it('drops a suspended account and its data when the user removes it', async () => {
h.state.accounts.push({ name: 'carol', registryId: 'carol@mail.example.org', sync: {} });
await reconcileDeviceAccounts();
h.state.log = [];
await removeSuspendedAccount('carol@mail.example.org');
expect(h.state.log).toEqual(['remove carol', 'clear carol']);
expect(useDeviceSyncStore.getState().accounts['carol@mail.example.org']).toBeUndefined();
});
it('never acts on an empty registry', async () => {
await ready([]);
h.state.accounts.push({ name: 'carol', registryId: 'carol@mail.example.org', sync: {} });
await reconcileDeviceAccounts();
expect(native.showSyncProblem).not.toHaveBeenCalled();
expect(setSyncEnabled).not.toHaveBeenCalled();
});
it('shows sync off for an account removed in Android Settings and never recreates it', async () => {
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
h.state.accounts = [];
vi.mocked(native.ensureAndroidAccount).mockClear();
await reconcileDeviceAccounts();
const alice = accountDeviceSync(ALICE);
expect(alice.removedInAndroidSettings).toBe(true);
expect(alice.androidAccountName).toBeUndefined();
expect(alice.enabled).toEqual({});
await reconcileDeviceAccounts();
expect(native.ensureAndroidAccount).not.toHaveBeenCalled();
expect(h.state.accounts).toEqual([]);
});
it('forgets an account that is neither signed in nor on the device', async () => {
useDeviceSyncStore.getState().update('gone@mail.example.org', { androidAccountName: 'gone', enabled: { [CONTACTS_AUTHORITY]: true } });
await reconcileDeviceAccounts();
expect(useDeviceSyncStore.getState().accounts['gone@mail.example.org']).toBeUndefined();
});
it('takes an authority turned on in Android Settings as on', async () => {
h.state.accounts.push({
name: 'alice',
registryId: ALICE,
sync: { [CALENDAR_AUTHORITY]: { automatic: true, periodicSeconds: 3600 } },
});
await reconcileDeviceAccounts();
const alice = accountDeviceSync(ALICE);
expect(alice.androidAccountName).toBe('alice');
expect(alice.enabled).toEqual({ [CALENDAR_AUTHORITY]: true });
});
it('hands the push router the accounts that feed each Android account', async () => {
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
useDeviceSyncStore.getState().rememberPrimaryJmapAccount(ALICE, CONTACTS_AUTHORITY, 'c');
await reconcileDeviceAccounts();
expect(native.setPushRoutes).toHaveBeenLastCalledWith({
c: [{ accountName: 'alice', authorities: [CONTACTS_AUTHORITY] }],
});
});
});
describe('sign-in', () => {
it('clears the sign-in notice and resumes a suspended account where it synced', async () => {
h.state.accounts.push({
name: 'alice',
registryId: ALICE,
sync: { [CONTACTS_AUTHORITY]: { automatic: false, periodicSeconds: 3600 } },
});
useDeviceSyncStore.getState().update(ALICE, {
androidAccountName: 'alice',
suspended: true,
enabled: { [CONTACTS_AUTHORITY]: true },
});
await deviceSyncSignedIn(ALICE);
expect(h.state.log).toEqual([
'clear alice',
`auto alice ${CONTACTS_AUTHORITY} true`,
`sync alice ${CONTACTS_AUTHORITY} {"manual":true}`,
]);
expect(accountDeviceSync(ALICE).suspended).toBeUndefined();
});
it('does nothing for an account without an Android account', async () => {
await deviceSyncSignedIn(BOB);
expect(h.state.log).toEqual([]);
});
});
describe('settings actions', () => {
it('deletes held-back deletions on the server, or brings the items back, with a sync', async () => {
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
h.state.log = [];
expect(await resolveDeletions(ALICE, CONTACTS_AUTHORITY, 'delete')).toBe(true);
expect(await resolveDeletions(ALICE, CONTACTS_AUTHORITY, 'restore')).toBe(true);
expect(h.state.log).toEqual([
`sync alice ${CONTACTS_AUTHORITY} {"manual":true,"overrideTooManyDeletions":true}`,
`sync alice ${CONTACTS_AUTHORITY} {"manual":true,"discardLocalDeletions":true}`,
]);
// Without an Android account there is nothing to resolve.
expect(await resolveDeletions(BOB, CONTACTS_AUTHORITY, 'delete')).toBe(false);
});
it('applies a new interval to Android while the authority syncs', async () => {
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
h.state.log = [];
await changeSyncInterval(ALICE, CONTACTS_AUTHORITY, 0);
expect(h.state.log).toEqual([`periodic alice ${CONTACTS_AUTHORITY} 0`]);
await changeSyncInterval(ALICE, CALENDAR_AUTHORITY, 900);
expect(h.state.log).toEqual([`periodic alice ${CONTACTS_AUTHORITY} 0`]);
expect(accountDeviceSync(ALICE).intervalSeconds[CALENDAR_AUTHORITY as Authority]).toBe(900);
});
it('syncs the calendar when the reminder owner changes', async () => {
await enableDeviceSync(ALICE, CALENDAR_AUTHORITY);
h.state.log = [];
await changeReminderOwner(ALICE, 'device');
expect(h.state.log).toEqual([`sync alice ${CALENDAR_AUTHORITY} {"manual":true}`]);
h.state.log = [];
await changeReminderOwner(ALICE, 'device');
expect(h.state.log).toEqual([]);
});
});
@@ -0,0 +1,152 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Android's side: the device's auto-sync, the account's calendar sync and the calendar permission.
const h = vi.hoisted(() => ({ available: true, master: true, automatic: true, permitted: true }));
vi.mock('../../native', () => ({
isDeviceSyncAvailable: () => h.available,
getSyncSettings: vi.fn(async () => ({
masterAutomatic: h.master,
authorities: {
'com.android.contacts': { syncable: 1, automatic: false, periodicSeconds: 0, active: false, pending: false },
'com.android.calendar': { syncable: 1, automatic: h.automatic, periodicSeconds: 3600, active: false, pending: false },
},
})),
}));
vi.mock('../../app/permissions', () => ({
hasSyncPermissions: vi.fn(async () => h.permitted),
}));
import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY } from '../../types';
import type { CalendarEvent } from '../../../api/types';
import { useDeviceSyncStore, waitForDeviceSyncHydration } from '../../../stores/device-sync-store';
import { onDeviceRemindersChange, remindedOnDevice } from '../../app/reminders';
const ALICE = 'alice@mail.example.org';
function event(id: string, calendarIds: Record<string, boolean>): CalendarEvent {
return { id, calendarIds, title: id, start: '2026-09-24T12:00:00', duration: 'PT1H' } as CalendarEvent;
}
function calendarSync(extra: Record<string, unknown> = {}): void {
useDeviceSyncStore.setState({
accounts: {
[ALICE]: {
contactsSelection: {},
calendarSelection: { 'c/work': false, 'team/shared-on': true },
reminderOwner: 'device',
intervalSeconds: {},
lastRun: {},
androidAccountName: 'alice',
enabled: { [CALENDAR_AUTHORITY]: true },
...extra,
},
},
});
}
/** Waits until the calendar app reminds of Alice's synced events (Android's side is read when the filter is asked for). */
async function remindedByCalendarApp(): Promise<void> {
await vi.waitFor(() => expect(remindedOnDevice(ALICE, 'c')).not.toBeNull());
}
beforeEach(async () => {
await waitForDeviceSyncHydration();
Object.assign(h, { available: true, master: true, automatic: true, permitted: true });
calendarSync();
await remindedByCalendarApp();
});
describe('remindedOnDevice', () => {
it("picks the events of the account's synced calendars", () => {
const onDevice = remindedOnDevice(ALICE, 'c')!;
// Own calendars sync unless turned off.
expect(onDevice(event('a', { personal: true }))).toBe(true);
expect(onDevice(event('b', { work: true }))).toBe(false);
expect(onDevice(event('c', { work: true, personal: true }))).toBe(true);
// Shared calendars: namespaced ids in calendarIds, raw ones in originalCalendarIds.
expect(onDevice({
...event('d', { 'team:shared-on': true }),
accountId: 'team',
originalCalendarIds: { 'shared-on': true },
})).toBe(true);
expect(onDevice({
...event('e', { 'team:other': true }),
accountId: 'team',
originalCalendarIds: { other: true },
})).toBe(false);
});
it('leaves everything to Bulwark when Bulwark reminds, or calendars do not sync', () => {
calendarSync({ reminderOwner: 'bulwark' });
expect(remindedOnDevice(ALICE, 'c')).toBeNull();
calendarSync({ enabled: { [CONTACTS_AUTHORITY]: true } });
expect(remindedOnDevice(ALICE, 'c')).toBeNull();
calendarSync({ removedInAndroidSettings: true });
expect(remindedOnDevice(ALICE, 'c')).toBeNull();
expect(remindedOnDevice('someone@else', 'c')).toBeNull();
expect(remindedOnDevice(null, 'c')).toBeNull();
});
it('counts the calendar app as the owner until the user chose', () => {
calendarSync({ reminderOwner: null });
expect(remindedOnDevice(ALICE, 'c')?.(event('a', { personal: true }))).toBe(true);
});
it('does nothing where device sync is not available', () => {
h.available = false;
expect(remindedOnDevice(ALICE, 'c')).toBeNull();
});
});
describe("remindedOnDevice while the device calendar can't receive events", () => {
it('leaves the reminders to Bulwark while calendar sync is paused in Android settings', async () => {
h.automatic = false;
await vi.waitFor(() => expect(remindedOnDevice(ALICE, 'c')).toBeNull());
});
it("leaves them to Bulwark while Android's auto-sync is off: Android then runs manual syncs only", async () => {
h.master = false;
await vi.waitFor(() => expect(remindedOnDevice(ALICE, 'c')).toBeNull());
});
it('leaves them to Bulwark while the calendar permission is revoked', async () => {
h.permitted = false;
await vi.waitFor(() => expect(remindedOnDevice(ALICE, 'c')).toBeNull());
});
it('hands them back to the calendar app once it syncs again, and tells the scheduler both times', async () => {
const listener = vi.fn();
const off = onDeviceRemindersChange(listener);
h.automatic = false;
await vi.waitFor(() => expect(listener).toHaveBeenCalledTimes(1));
expect(remindedOnDevice(ALICE, 'c')).toBeNull();
h.automatic = true;
await vi.waitFor(() => expect(listener).toHaveBeenCalledTimes(2));
expect(remindedOnDevice(ALICE, 'c')).not.toBeNull();
off();
});
});
describe('onDeviceRemindersChange', () => {
it('fires for the owner and the calendar selection, not for sync bookkeeping', () => {
const listener = vi.fn();
const off = onDeviceRemindersChange(listener);
const store = useDeviceSyncStore.getState();
store.recordKnownState(ALICE, 'c', 'CalendarEvent', 's1');
store.setIntervalSeconds(ALICE, CALENDAR_AUTHORITY, 900);
expect(listener).not.toHaveBeenCalled();
store.setReminderOwner(ALICE, 'bulwark');
expect(listener).toHaveBeenCalledTimes(1);
store.setReminderOwner(ALICE, 'device');
store.setCollectionSelected(ALICE, CALENDAR_AUTHORITY, 'c/work', true);
expect(listener).toHaveBeenCalledTimes(3);
off();
store.setEnabled(ALICE, CALENDAR_AUTHORITY, false);
expect(listener).toHaveBeenCalledTimes(3);
});
});
@@ -0,0 +1,285 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import AsyncStorage from '@react-native-async-storage/async-storage';
const h = vi.hoisted(() => ({
available: true,
appState: [] as Array<(state: string) => void>,
accountsChanged: [] as Array<() => void>,
}));
vi.mock('react-native', () => ({
Platform: { OS: 'android', Version: 34, select: <T,>(s: { android?: T; default?: T }) => s.android ?? s.default },
NativeModules: {},
NativeEventEmitter: class {
addListener() {
return { remove: () => undefined };
}
},
AppState: {
currentState: 'active',
addEventListener: (_type: string, listener: (state: string) => void) => {
h.appState.push(listener);
return { remove: () => { h.appState = h.appState.filter((l) => l !== listener); } };
},
},
}));
vi.mock('../../native', () => ({
isDeviceSyncAvailable: () => h.available,
requestSync: vi.fn(async () => undefined),
onAccountsChanged: vi.fn((listener: () => void) => {
h.accountsChanged.push(listener);
return () => { h.accountsChanged = h.accountsChanged.filter((l) => l !== listener); };
}),
}));
vi.mock('../../app/lifecycle', () => ({
reconcileDeviceAccounts: vi.fn(async () => undefined),
refreshPushRoutes: vi.fn(async () => undefined),
}));
import * as native from '../../native';
import * as lifecycle from '../../app/lifecycle';
import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY } from '../../types';
import { dispatchStateChange } from '../../../lib/state-change-bus';
import { useAccountStore, type AccountEntry } from '../../../stores/account-store';
import { useDeviceSyncStore, waitForDeviceSyncHydration } from '../../../stores/device-sync-store';
import { cancelDeviceSyncRequests, requestDeviceSync, TRIGGER_DELAY_MS } from '../../app/request';
import {
FOREGROUND_MIN_INTERVAL_MS,
handleForeground,
handleStateChange,
startDeviceSyncTriggers,
} from '../../app/triggers';
const ALICE = 'alice@mail.example.org';
const BOB = 'bob@mail.example.org';
const requestSync = vi.mocked(native.requestSync);
function account(id: string): AccountEntry {
return {
id,
serverUrl: 'https://mail.example.org',
username: id.split('@')[0],
displayName: id,
email: id,
avatarColor: '#000',
lastLoginAt: 0,
isConnected: true,
hasError: false,
isDefault: false,
};
}
// Alice syncs contacts and calendars; Bob only calendars.
function syncing(): void {
useDeviceSyncStore.setState({
accounts: {
[ALICE]: {
contactsSelection: {},
calendarSelection: { 'team/cal': true },
reminderOwner: null,
intervalSeconds: {},
lastRun: {},
androidAccountName: 'alice',
enabled: { [CONTACTS_AUTHORITY]: true, [CALENDAR_AUTHORITY]: true },
},
[BOB]: {
contactsSelection: {},
calendarSelection: {},
reminderOwner: null,
intervalSeconds: {},
lastRun: {},
androidAccountName: 'bob',
enabled: { [CALENDAR_AUTHORITY]: true },
},
},
});
}
let stop: (() => void) | null = null;
beforeEach(async () => {
await AsyncStorage.clear();
await waitForDeviceSyncHydration();
vi.clearAllMocks();
vi.useFakeTimers();
h.available = true;
cancelDeviceSyncRequests();
useAccountStore.setState({ accounts: [account(ALICE), account(BOB)], activeAccountId: ALICE });
syncing();
});
afterEach(() => {
stop?.();
stop = null;
cancelDeviceSyncRequests();
vi.useRealTimers();
});
describe('requestDeviceSync', () => {
it('makes one sync of the edits and echoes of 5 seconds', async () => {
requestDeviceSync(CONTACTS_AUTHORITY);
await vi.advanceTimersByTimeAsync(2000);
requestDeviceSync(CONTACTS_AUTHORITY, ALICE);
requestDeviceSync(CONTACTS_AUTHORITY, ALICE, { jmapAccountId: 'c', type: 'ContactCard', state: 's2' });
expect(requestSync).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS - 2000);
expect(requestSync).toHaveBeenCalledTimes(1);
expect(requestSync).toHaveBeenCalledWith('alice', CONTACTS_AUTHORITY, {});
// The next change arms a new request.
requestDeviceSync(CONTACTS_AUTHORITY);
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
expect(requestSync).toHaveBeenCalledTimes(2);
});
it('keeps accounts and authorities apart', async () => {
requestDeviceSync(CONTACTS_AUTHORITY, ALICE);
requestDeviceSync(CALENDAR_AUTHORITY, ALICE);
requestDeviceSync(CALENDAR_AUTHORITY, BOB);
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
expect(requestSync.mock.calls.map(([name, authority]) => `${name} ${authority}`).sort()).toEqual([
`alice ${CALENDAR_AUTHORITY}`,
`alice ${CONTACTS_AUTHORITY}`,
`bob ${CALENDAR_AUTHORITY}`,
]);
});
it('asks nothing for an authority the account does not sync, or without device sync', async () => {
requestDeviceSync(CONTACTS_AUTHORITY, BOB);
h.available = false;
requestDeviceSync(CALENDAR_AUTHORITY, BOB);
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
expect(requestSync).not.toHaveBeenCalled();
});
it('drops a request when sync was turned off meanwhile', async () => {
requestDeviceSync(CONTACTS_AUTHORITY, ALICE);
useDeviceSyncStore.getState().setEnabled(ALICE, CONTACTS_AUTHORITY, false);
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
expect(requestSync).not.toHaveBeenCalled();
});
it('can be cancelled', async () => {
requestDeviceSync(CONTACTS_AUTHORITY, ALICE);
requestDeviceSync(CALENDAR_AUTHORITY, ALICE);
cancelDeviceSyncRequests(ALICE, CONTACTS_AUTHORITY);
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
expect(requestSync).toHaveBeenCalledTimes(1);
expect(requestSync).toHaveBeenCalledWith('alice', CALENDAR_AUTHORITY, {});
});
});
describe('StateChanges of the live stream', () => {
it('syncs the active account on a contact or calendar change', async () => {
handleStateChange(CALENDAR_AUTHORITY, 'CalendarEvent', 'c', 'e7');
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
expect(requestSync).toHaveBeenCalledWith('alice', CALENDAR_AUTHORITY, {});
});
it('skips the echo of what the engine synced itself', async () => {
useDeviceSyncStore.getState().recordKnownState(ALICE, 'c', 'ContactCard', 's9');
handleStateChange(CONTACTS_AUTHORITY, 'ContactCard', 'c', 's9');
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
expect(requestSync).not.toHaveBeenCalled();
});
it('skips an echo that arrived before the engine recorded its state', async () => {
handleStateChange(CONTACTS_AUTHORITY, 'ContactCard', 'c', 's10');
// The engine's upload returns and records the state it moved the account to.
useDeviceSyncStore.getState().recordKnownState(ALICE, 'c', 'ContactCard', 's10');
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
expect(requestSync).not.toHaveBeenCalled();
});
it('still syncs when an edit in the app joined the echo', async () => {
handleStateChange(CONTACTS_AUTHORITY, 'ContactCard', 'c', 's11');
requestDeviceSync(CONTACTS_AUTHORITY);
useDeviceSyncStore.getState().recordKnownState(ALICE, 'c', 'ContactCard', 's11');
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
expect(requestSync).toHaveBeenCalledTimes(1);
});
it('ignores shared accounts nothing is synced from, once the personal account is known', async () => {
handleStateChange(CALENDAR_AUTHORITY, 'CalendarEvent', 'other', 'x1');
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
// Unknown yet: anything may feed the device.
expect(requestSync).toHaveBeenCalledTimes(1);
useDeviceSyncStore.getState().rememberPrimaryJmapAccount(ALICE, CALENDAR_AUTHORITY, 'c');
handleStateChange(CALENDAR_AUTHORITY, 'CalendarEvent', 'other', 'x2');
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
expect(requestSync).toHaveBeenCalledTimes(1);
// The shared account a calendar was picked in does feed it.
handleStateChange(CALENDAR_AUTHORITY, 'Calendar', 'team', 'x3');
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
expect(requestSync).toHaveBeenCalledTimes(2);
});
it('does nothing for an authority the active account does not sync', async () => {
useAccountStore.setState({ activeAccountId: BOB });
handleStateChange(CONTACTS_AUTHORITY, 'AddressBook', 'b', 'x');
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
expect(requestSync).not.toHaveBeenCalled();
});
});
describe('foreground', () => {
it('reconciles the accounts, then syncs everything that syncs, at most once a minute', async () => {
await handleForeground(1_000_000);
expect(lifecycle.reconcileDeviceAccounts).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
expect(requestSync.mock.calls.map(([name, authority]) => `${name} ${authority}`).sort()).toEqual([
`alice ${CALENDAR_AUTHORITY}`,
`alice ${CONTACTS_AUTHORITY}`,
`bob ${CALENDAR_AUTHORITY}`,
]);
await handleForeground(1_000_000 + FOREGROUND_MIN_INTERVAL_MS - 1);
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
expect(requestSync).toHaveBeenCalledTimes(3);
await handleForeground(1_000_000 + FOREGROUND_MIN_INTERVAL_MS);
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
expect(requestSync).toHaveBeenCalledTimes(6);
});
});
describe('startDeviceSyncTriggers', () => {
it('listens to the stream, the foreground, account changes and the preferences', async () => {
stop = startDeviceSyncTriggers();
// Launch counts as coming to the foreground.
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
expect(lifecycle.reconcileDeviceAccounts).toHaveBeenCalledTimes(1);
requestSync.mockClear();
dispatchStateChange({ '@type': 'StateChange', changed: { c: { AddressBook: 'a2', Email: 'm1' } } });
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
expect(requestSync).toHaveBeenCalledWith('alice', CONTACTS_AUTHORITY, {});
for (const listener of h.appState) listener('background');
for (const listener of h.appState) listener('active');
await vi.advanceTimersByTimeAsync(0);
expect(lifecycle.reconcileDeviceAccounts).toHaveBeenCalledTimes(2);
for (const listener of h.accountsChanged) listener();
expect(lifecycle.reconcileDeviceAccounts).toHaveBeenCalledTimes(3);
useDeviceSyncStore.getState().rememberPrimaryJmapAccount(ALICE, CONTACTS_AUTHORITY, 'c');
await vi.advanceTimersByTimeAsync(1000);
expect(lifecycle.refreshPushRoutes).toHaveBeenCalled();
// Started once only.
expect(startDeviceSyncTriggers()).toBe(stop);
});
it('starts nothing without device sync', async () => {
h.available = false;
startDeviceSyncTriggers()();
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
expect(lifecycle.reconcileDeviceAccounts).not.toHaveBeenCalled();
expect(native.onAccountsChanged).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,226 @@
// Big meetings (many attendees on many overrides): a write into such an event
// has to fit one provider transaction (Binder: about 1 MB; a Parcel carries
// the JSON as UTF-16, estimated at 2 bytes per character plus ~300 per op).
import { describe, expect, it } from 'vitest';
import { Attendees, Events } from '../../android-columns';
import { calendarPlanner } from '../../calendar/planner';
import { contactsPlanner } from '../../contacts/planner';
import type { OpGroup } from '../../planner';
import { CALENDAR_AUTHORITY } from '../../types';
import type { CalendarEventWire } from '../../wire';
import { createHarness, rowWrites, type Harness as EngineHarness } from '../engine/harness';
import { Harness, ME, serverApply } from './harness';
const TRANSACTION = 1_000_000;
const estimate = (group: OpGroup) => 2 * JSON.stringify(group.ops).length + 300 * group.ops.length;
/** A plan's groups: the group and the ones applied after it. */
const chain = (group: OpGroup): OpGroup[] => [group, ...(group.next ?? [])];
/** A daily meeting with `attendees` guests whose `overrides` occurrences each carry the whole participants map. */
function meeting(attendees: number, overrides: number, me = ME, calendarId = 'b'): CalendarEventWire {
const participants: Record<string, unknown> = {
me: { '@type': 'Participant', calendarAddress: `mailto:${me}`, roles: { owner: true, attendee: true }, participationStatus: 'accepted' },
};
for (let i = 0; i < attendees; i++) {
participants[`p${i}`] = { '@type': 'Participant', calendarAddress: `mailto:person${i}@example.com`, name: `Person ${i}`, roles: { attendee: true }, participationStatus: 'needs-action', expectReply: true };
}
const recurrenceOverrides: Record<string, unknown> = {};
for (let i = 0; i < overrides; i++) {
const day = new Date(Date.UTC(2026, 9, 6) + i * 86_400_000).toISOString().slice(0, 10);
recurrenceOverrides[`${day}T09:00:00`] = { title: `Standup #${i}`, participants };
}
return {
id: 'big',
'@type': 'Event',
uid: 'big@example.org',
calendarIds: { [calendarId]: true },
title: 'Standup',
description: 'Daily standup',
start: '2026-10-05T09:00:00',
timeZone: 'Europe/Berlin',
duration: 'PT15M',
recurrenceRule: { frequency: 'daily', count: 300 },
organizerCalendarAddress: `mailto:${me}`,
participants,
recurrenceOverrides,
} as unknown as CalendarEventWire;
}
/** The engine with the real planners (the fake provider refuses a batch over one Binder transaction, as Android does). */
function engineWithRealPlanners(): EngineHarness {
const h = createHarness();
h.deps.planners = { contacts: contactsPlanner, calendar: calendarPlanner };
return h;
}
/** The big meeting's rows on the engine's device: one master, one exception row per override, every attendee once. */
function expectWholeMeeting(h: EngineHarness, id: string, attendees: number, overrides: number, context = ''): void {
const rows = h.events();
const masters = rows.filter((e) => e[Events._SYNC_ID] === `a/${id}`);
expect(masters, context).toHaveLength(1);
const exceptions = rows.filter((e) => e[Events.ORIGINAL_SYNC_ID] === `a/${id}`);
expect(exceptions, context).toHaveLength(overrides);
expect(new Set(exceptions.map((x) => x[Events._SYNC_ID])).size, context).toBe(overrides);
expect(exceptions.every((x) => Number(x[Events.ORIGINAL_ID]) === Number(masters[0]._id)), context).toBe(true);
const attendeesOf = (eventId: unknown) => h.device.rows('attendees').filter((a) => Number(a.event_id) === Number(eventId)).length;
expect([masters[0], ...exceptions].every((row) => attendeesOf(row._id) === attendees + 1), context).toBe(true);
expect(Object.keys(JSON.parse(String(masters[0][Events.SYNC_DATA1])).recurrenceOverrides), context).toHaveLength(overrides);
expect(rows.filter((e) => e[Events.ORIGINAL_SYNC_ID] === null), context).toHaveLength(1);
}
async function synced(event: CalendarEventWire) {
const h = await new Harness().setup();
await h.download(event);
return { h, id: Number(h.masterRow(event.id)._id) };
}
describe('calendar planner: big meetings', () => {
it('fits the writes after an edit of a big meeting into one transaction', async () => {
const event = meeting(25, 52);
const { h, id } = await synced(event);
// Only the series row is edited; its 52 occurrences with 26 attendees each stay clean.
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Daily standup' });
const local = (await h.local('big'))!;
const plan = calendarPlanner.planUpload(local, h.ctx);
if (plan.kind !== 'upload' || plan.actions[0].kind !== 'update') throw new Error(plan.kind);
const server = serverApply(event, plan.actions[0].patch);
const accepted = calendarPlanner.planAccepted(local, server, h.ctx);
expect(estimate(accepted.ops)).toBeLessThan(TRANSACTION);
expect(estimate(accepted.keepDirtyOps)).toBeLessThan(TRANSACTION);
// A download into the edited event (the server changed one occurrence meanwhile).
const remote = { ...event, recurrenceOverrides: { ...event.recurrenceOverrides, '2026-10-09T09:00:00': { title: 'Moved to Friday' } } } as CalendarEventWire;
expect(estimate(calendarPlanner.planDownload(remote, local, h.ctx).ops)).toBeLessThan(TRANSACTION);
// A save that changed nothing mapped is cleared the same way.
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Standup' });
const clean = calendarPlanner.planUpload((await h.local('big'))!, h.ctx);
expect(clean.kind).toBe('clean');
if (clean.kind === 'clean') expect(estimate(clean.ops)).toBeLessThan(TRANSACTION);
}, 60_000);
it('still fails a write when an app changed a clean occurrence\'s attendees meanwhile', async () => {
const event = meeting(3, 4);
const { h, id } = await synced(event);
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Daily standup' });
const local = (await h.local('big'))!;
const x = local.exceptions[0];
const plan = calendarPlanner.planUpload(local, h.ctx);
if (plan.kind !== 'upload' || plan.actions[0].kind !== 'update') throw new Error(plan.kind);
const accepted = calendarPlanner.planAccepted(local, serverApply(event, plan.actions[0].patch), h.ctx);
// The occurrence's attendees are not asserted one by one: CalendarProvider marks the event DIRTY on any app write to them.
h.fake.beforeNextBatch(() => h.fake.user.setAttendeeStatus(x.eventId, 'person1@example.com', 1));
expect(await h.tryApply(accepted.ops)).toMatchObject({ ok: false, reason: 'assert' });
expect(h.fake.rows('attendees', `${Attendees.EVENT_ID} = ? AND ${Attendees.ATTENDEE_EMAIL} = ?`, [x.eventId, 'person1@example.com'])[0][Attendees.ATTENDEE_STATUS]).toBe(1);
});
it.each([
[25, 52],
[100, 12],
])('writes the first download of %i attendees on %i overrides in groups that each fit one transaction', async (attendees, overrides) => {
const h = await new Harness().setup();
const event = meeting(attendees, overrides);
const plan = calendarPlanner.planDownload(event, null, h.ctx);
expect(plan.effect).toBe('insert');
expect(chain(plan.ops).length).toBeGreaterThan(1);
for (const group of chain(plan.ops)) expect(estimate(group)).toBeLessThan(TRANSACTION);
await h.apply(plan.ops);
const local = (await h.local('big'))!;
expect(local.shadow).toEqual(event);
expect(local.exceptions.map((x) => x.recurrenceId).sort()).toEqual(Object.keys(event.recurrenceOverrides!).sort());
expect(local.exceptions.every((x) => x.attendees.length === attendees + 1 && !x.dirty)).toBe(true);
// Its echo writes nothing.
expect(calendarPlanner.planDownload(event, local, h.ctx).effect).toBe('none');
}, 60_000);
it('finishes a first download cut off between its groups, without writing a row twice', async () => {
const event = meeting(25, 52);
const groups = chain(calendarPlanner.planDownload(event, null, (await new Harness().setup()).ctx).ops).length;
for (let cut = 1; cut < groups; cut++) {
const h = await new Harness().setup();
for (const group of chain(calendarPlanner.planDownload(event, null, h.ctx).ops).slice(0, cut)) await h.apply({ ref: group.ref, ops: group.ops });
// Planned again from what the first groups wrote.
const again = calendarPlanner.planDownload(event, await h.local('big'), h.ctx);
for (const group of chain(again.ops)) expect(estimate(group)).toBeLessThan(TRANSACTION);
await h.apply(again.ops);
const local = (await h.local('big'))!;
expect(local.shadow, `cut after ${cut}`).toEqual(event);
expect(new Set(local.exceptions.map((x) => x.recurrenceId)).size, `cut after ${cut}`).toBe(52);
expect(local.exceptions, `cut after ${cut}`).toHaveLength(52);
expect(local.exceptions.every((x) => x.attendees.length === 26), `cut after ${cut}`).toBe(true);
expect(calendarPlanner.planDownload(event, local, h.ctx).effect, `cut after ${cut}`).toBe('none');
}
// Every cut point writes a 2 MB meeting again.
}, 60_000);
it('writes a change of every occurrence in groups, the series with its new shadow last', async () => {
const h = await new Harness().setup();
const event = meeting(25, 52);
await h.download(event);
// Another client adds a guest to the meeting and to every occurrence.
const guest = { '@type': 'Participant', calendarAddress: 'mailto:guest@example.com', name: 'Guest', roles: { attendee: true }, participationStatus: 'needs-action' };
const changed = JSON.parse(JSON.stringify(event)) as CalendarEventWire;
(changed.participants as Record<string, unknown>).guest = guest;
for (const override of Object.values(changed.recurrenceOverrides!)) (override as { participants: Record<string, unknown> }).participants.guest = guest;
const plan = calendarPlanner.planDownload(changed, await h.local('big'), h.ctx);
const groups = chain(plan.ops);
expect(groups.length).toBeGreaterThan(1);
for (const group of groups) expect(estimate(group)).toBeLessThan(TRANSACTION);
const writesShadow = (group: OpGroup) => group.ops.some((op) => op.op === 'update' && Events.SYNC_DATA1 in op.values);
expect(groups.map(writesShadow)).toEqual(groups.map((_, i) => i === groups.length - 1));
// Cut off after the first group: planned again from what it wrote, the rest follows.
await h.apply({ ref: groups[0].ref, ops: groups[0].ops });
await h.apply(calendarPlanner.planDownload(changed, await h.local('big'), h.ctx).ops);
const local = (await h.local('big'))!;
expect(local.shadow).toEqual(changed);
expect(local.exceptions.every((x) => x.attendees.length === 27)).toBe(true);
expect(calendarPlanner.planDownload(changed, local, h.ctx).effect).toBe('none');
}, 60_000);
});
describe('device sync engine: big meetings', () => {
it.each([
[25, 52],
[100, 12],
])('downloads a meeting of %i attendees on %i overrides in several transactions', async (attendees, overrides) => {
const h = engineWithRealPlanners();
const { id: _drop, ...event } = meeting(attendees, overrides, 'alice@example.com', h.calendar) as unknown as Record<string, unknown>;
const id = h.server.addEvent('a', event);
const report = await h.run(CALENDAR_AUTHORITY);
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { downloaded: { created: 1 } } });
expectWholeMeeting(h, id, attendees, overrides);
const before = h.batches.log.length;
expect((await h.run(CALENDAR_AUTHORITY)).outcome).toBe('ok');
expect(rowWrites(h.batches.log.slice(before))).toEqual([]);
}, 60_000);
it('finishes a big meeting cut off between its groups by a crash, without duplicating rows', async () => {
const setup = () => {
const h = engineWithRealPlanners();
const { id: _drop, ...event } = meeting(25, 52, 'alice@example.com', h.calendar) as unknown as Record<string, unknown>;
return { h, id: h.server.addEvent('a', event) };
};
const probe = setup();
await probe.h.run(CALENDAR_AUTHORITY);
const batches = probe.h.batches.applied;
for (let n = 1; n < batches; n++) {
const { h, id } = setup();
h.batches.crashAfter = n;
await h.run(CALENDAR_AUTHORITY);
h.batches.crashAfter = null;
expect((await h.run(CALENDAR_AUTHORITY)).outcome, `crash after batch ${n}`).toBe('ok');
expectWholeMeeting(h, id, 25, 52, `crash after batch ${n}`);
}
// Two runs of a 2 MB meeting per crash point.
}, 60_000);
});
@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest';
import { CalendarAccess, Calendars } from '../../android-columns';
import { calendarPlanner } from '../../calendar/planner';
import { CAN_PARTIALLY_UPDATE } from '../../calendar/columns';
import { cssColorToArgb } from '../../calendar/values';
import { BIRTHDAY_CALENDAR_ID } from '../../../lib/birthday-calendar';
import type { CalendarLike } from '../../wire';
import { ACCT, CALENDARS, Harness, ME } from './harness';
const personal = (calendar: CalendarLike, readOnly = false) => ({ jmapAccountId: ACCT, calendar, readOnly, accountName: ME });
describe('calendar planner: calendar rows', () => {
it('inserts every column the provider and the calendar apps need', async () => {
const h = await new Harness().setup(['b']);
const [row] = await h.calendars();
expect(row.syncId).toBe(`${ACCT}/b`);
expect(row.cells).toMatchObject({
[Calendars.NAME]: 'Personal',
[Calendars.CALENDAR_DISPLAY_NAME]: 'Personal',
[Calendars.CALENDAR_COLOR]: cssColorToArgb('#3b82f6'),
[Calendars.CALENDAR_ACCESS_LEVEL]: CalendarAccess.OWNER,
[Calendars.OWNER_ACCOUNT]: ME,
[Calendars.SYNC_EVENTS]: 1,
[Calendars.VISIBLE]: 1,
[Calendars.CALENDAR_TIME_ZONE]: 'Europe/Berlin',
[Calendars.ALLOWED_REMINDERS]: '1,2',
[Calendars.ALLOWED_AVAILABILITY]: '0,1',
[Calendars.ALLOWED_ATTENDEE_TYPES]: '0,1,2,3',
[Calendars.MAX_REMINDERS]: 5,
[Calendars.CAN_ORGANIZER_RESPOND]: 0,
[CAN_PARTIALLY_UPDATE]: 0,
});
// CAL_SYNC1 would become the `feed` extra of every sync the provider requests for the calendar.
expect(h.fake.row('calendars', row.calendarRowId)).toMatchObject({ [Calendars.CAL_SYNC1]: null, [Calendars.DIRTY]: 0 });
expect(row.shadow).toEqual(CALENDARS.b);
expect(row.flags).toEqual({});
});
it('writes nothing when nothing changed, and only what changed otherwise', async () => {
const h = await new Harness().setup(['b']);
const local = await h.calendars();
expect(calendarPlanner.planCalendars([personal(CALENDARS.b)], local, h.ctx)).toEqual({ groups: [], deleteCalendarRows: [] });
const renamed = { ...CALENDARS.b, name: 'Private', color: 'tomato' } as CalendarLike;
const plan = calendarPlanner.planCalendars([personal(renamed)], local, h.ctx);
expect(plan.groups).toHaveLength(1);
const [op] = plan.groups[0].ops;
expect(op).toMatchObject({ op: 'update', table: 'calendars', id: local[0].calendarRowId, expectCount: 1 });
expect(Object.keys((op as { values: object }).values).sort()).toEqual(
[Calendars.NAME, Calendars.CALENDAR_DISPLAY_NAME, Calendars.CALENDAR_COLOR, Calendars.CAL_SYNC2].sort(),
);
// VISIBLE is the user's once the row exists.
expect((op as { values: object }).values).not.toHaveProperty(Calendars.VISIBLE);
});
it('keeps VISIBLE as the user set it', async () => {
const h = await new Harness().setup(['b']);
const [row] = await h.calendars();
await h.apply({ ref: 'x', ops: [{ op: 'update', table: 'calendars', id: row.calendarRowId, values: { [Calendars.VISIBLE]: 0 } }] });
expect(calendarPlanner.planCalendars([personal(CALENDARS.b)], await h.calendars(), h.ctx).groups).toEqual([]);
});
it('maps rights to access levels, and a writable read-only calendar is a subscribed feed', () => {
const level = (rights: CalendarLike['myRights'], readOnly = false) => {
const plan = calendarPlanner.planCalendars([personal({ id: 'z', name: 'Z', myRights: rights } as CalendarLike, readOnly)], [], new Harness().ctx);
const values = (plan.groups[0].ops[0] as { values: Record<string, unknown> }).values;
return [values[Calendars.CALENDAR_ACCESS_LEVEL], JSON.parse(String(values[Calendars.CAL_SYNC3]))];
};
expect(level({ mayWriteAll: true, mayWriteOwn: true })).toEqual([CalendarAccess.OWNER, {}]);
expect(level({ mayWriteOwn: true })).toEqual([CalendarAccess.CONTRIBUTOR, {}]);
expect(level({ mayRSVP: true }, true)).toEqual([CalendarAccess.RESPOND, { readOnly: 'rights' }]);
expect(level({ mayReadItems: true }, true)).toEqual([CalendarAccess.READ, { readOnly: 'rights' }]);
expect(level({ mayWriteAll: true }, true)).toEqual([CalendarAccess.READ, { readOnly: 'subscription' }]);
});
it('offers no reminder editor when Bulwark owns reminders', async () => {
const h = await new Harness({ reminderOwner: 'bulwark' }).setup(['b']);
expect((await h.calendars())[0].cells[Calendars.MAX_REMINDERS]).toBe(0);
h.ctx = { ...h.ctx, reminderOwner: 'device' };
const plan = calendarPlanner.planCalendars([personal(CALENDARS.b)], await h.calendars(), h.ctx);
expect((plan.groups[0].ops[0] as { values: object }).values).toEqual({ [Calendars.MAX_REMINDERS]: 5 });
});
it('names shared accounts\' calendars after their account and colours calendars without a colour', () => {
const ctx = new Harness().ctx;
const plan = calendarPlanner.planCalendars(
[{ jmapAccountId: 'd', calendar: { id: 'x', name: 'Team', myRights: { mayWriteAll: true } } as CalendarLike, readOnly: false, accountName: 'Bob Builder' }],
[],
ctx,
);
const values = (plan.groups[0].ops[0] as { values: Record<string, unknown> }).values;
expect(values[Calendars._SYNC_ID]).toBe('d/x');
expect(values[Calendars.CALENDAR_DISPLAY_NAME]).toBe('Team (Bob Builder)');
expect(typeof values[Calendars.CALENDAR_COLOR]).toBe('number');
expect(values[Calendars.OWNER_ACCOUNT]).toBe(ME);
});
it('deletes rows of deselected calendars and never syncs the birthday calendar', async () => {
const h = await new Harness().setup(['b', 'w']);
const local = await h.calendars();
const plan = calendarPlanner.planCalendars(
[personal(CALENDARS.b), personal({ id: BIRTHDAY_CALENDAR_ID, name: 'Birthdays' } as CalendarLike)],
local,
h.ctx,
);
expect(plan.groups).toEqual([]);
expect(plan.deleteCalendarRows).toEqual([local.find((c) => c.syncId === `${ACCT}/w`)!.calendarRowId]);
});
it('reads calendar rows defensively', () => {
const cal = calendarPlanner.decodeCalendar({ _id: '4', _sync_id: 'c/b', cal_sync2: 'garbage', cal_sync3: '{"readOnly":"subscription","taskOnly":true}' });
expect(cal).toMatchObject({ calendarRowId: 4, syncId: 'c/b', shadow: null, flags: { readOnly: 'subscription', taskOnly: true } });
expect(calendarPlanner.decodeCalendar({ _id: 5, _sync_id: null, cal_sync3: '[1]' }).flags).toEqual({});
});
});
@@ -0,0 +1,270 @@
import { describe, expect, it } from 'vitest';
import { Attendees, Calendars, Events, Reminders } from '../../android-columns';
import { calendarPlanner } from '../../calendar/planner';
import { cssColorToArgb } from '../../calendar/values';
import { ACCT, CALENDARS, Harness, ME, single, utc, weekly } from './harness';
import type { CalendarEventWire } from '../../wire';
describe('calendar planner: downloads', () => {
it('writes a recurring event with its exception, exclusion, attendees and reminders', async () => {
const h = await new Harness().setup();
const plan = await h.download(weekly());
expect(plan.effect).toBe('insert');
const master = h.masterRow('bl');
expect(master).toMatchObject({
[Events.TITLE]: 'Probe weekly',
[Events.DESCRIPTION]: 'desc',
[Events.EVENT_LOCATION]: 'Room 1',
[Events.STATUS]: 0,
[Events.AVAILABILITY]: 1,
[Events.ACCESS_LEVEL]: 2,
[Events.EVENT_COLOR]: cssColorToArgb('steelblue'),
[Events.DTSTART]: utc('2026-10-05T13:00:00Z'),
[Events.DTEND]: null,
[Events.DURATION]: 'P5400S',
[Events.EVENT_TIMEZONE]: 'America/New_York',
[Events.ALL_DAY]: 0,
[Events.RRULE]: 'FREQ=WEEKLY;COUNT=10;BYDAY=MO,WE',
[Events.EXDATE]: '20261012T130000Z',
[Events.RDATE]: null,
[Events.UID_2445]: 'probe-rec@example.org',
[Events.ORGANIZER]: ME,
[Events.HAS_ATTENDEE_DATA]: 1,
[Events.DIRTY]: 0,
});
expect(JSON.parse(String(master[Events.SYNC_DATA1]))).toEqual(weekly());
expect(master[Events.SELF_ATTENDEE_STATUS] ?? null).toBeNull();
const exceptions = h.fake.rows('events', `${Events.ORIGINAL_SYNC_ID} = ?`, [`${ACCT}/bl`]);
expect(exceptions).toHaveLength(1);
expect(exceptions[0]).toMatchObject({
[Events._SYNC_ID]: `${ACCT}/bl#2026-10-07T09:00:00`,
[Events.SYNC_DATA2]: '2026-10-07T09:00:00',
[Events.ORIGINAL_ID]: master._id,
// Exact to the millisecond, or the provider shows the original instance as well.
[Events.ORIGINAL_INSTANCE_TIME]: utc('2026-10-07T13:00:00Z'),
[Events.ORIGINAL_ALL_DAY]: 0,
[Events.TITLE]: 'Moved Wednesday',
[Events.DTSTART]: utc('2026-10-07T15:00:00Z'),
[Events.DTEND]: utc('2026-10-07T16:30:00Z'),
[Events.DURATION]: null,
[Events.RRULE]: null,
[Events.STATUS]: 0,
});
const attendees = h.fake.rows('attendees', `${Attendees.EVENT_ID} = ?`, [Number(master._id)]);
expect(attendees.map((a) => [a[Attendees.ATTENDEE_EMAIL], a[Attendees.ATTENDEE_RELATIONSHIP], a[Attendees.ATTENDEE_STATUS]])).toEqual([
[ME, 2, 1],
['guest@example.net', 1, 3],
]);
const reminders = h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [Number(master._id)]);
// Only the start-relative offset alert has a Reminders row; the others stay on the server.
expect(reminders.map((r) => [r[Reminders.MINUTES], r[Reminders.METHOD]])).toEqual([[15, 1]]);
});
it('writes nothing for an echo of what the rows already hold', async () => {
const h = await new Harness().setup();
await h.download(weekly());
const batches = h.fake.batches.length;
const plan = calendarPlanner.planDownload(weekly(), await h.local('bl'), h.ctx);
expect(plan).toMatchObject({ effect: 'none', writes: 0, conflicts: 0, stillDirty: false });
expect(plan.ops.ops).toEqual([]);
expect(h.fake.batches.length).toBe(batches);
});
it('writes only what the server changed, behind asserts', async () => {
const h = await new Harness().setup();
await h.download(weekly());
const plan = await h.download({ ...weekly(), title: 'Renamed', updated: '2026-09-27T08:00:00Z' });
expect(plan.effect).toBe('update');
const ops = plan.ops.ops;
const firstWrite = ops.findIndex((op) => op.op !== 'assert');
expect(ops.slice(0, firstWrite).every((op) => op.op === 'assert')).toBe(true);
expect(ops.slice(firstWrite).every((op) => op.op !== 'assert')).toBe(true);
const updates = ops.filter((op) => op.op === 'update');
expect(updates).toHaveLength(1);
expect(Object.keys((updates[0] as { values: object }).values).sort()).toEqual([Events.SYNC_DATA1, Events.SYNC_DATA4, Events.TITLE].sort());
expect(h.masterRow('bl')[Events.TITLE]).toBe('Renamed');
});
it('updates only the shadow when the server changed a property the device does not show', async () => {
const h = await new Harness().setup();
await h.download(weekly());
const plan = await h.download({ ...weekly(), priority: 1, keywords: { other: true } });
expect(plan).toMatchObject({ effect: 'update', writes: 1 });
expect(JSON.parse(String(h.masterRow('bl')[Events.SYNC_DATA1])).priority).toBe(1);
});
it('keeps floating events on the wall clock of the device zone', async () => {
const h = await new Harness({ deviceZone: 'America/New_York' }).setup();
await h.download(single({ id: 'f1', timeZone: null, start: '2026-10-06T14:00:00', duration: 'PT45M' }));
expect(h.masterRow('f1')).toMatchObject({
[Events.EVENT_TIMEZONE]: 'America/New_York',
[Events.DTSTART]: utc('2026-10-06T18:00:00Z'),
[Events.DTEND]: utc('2026-10-06T18:45:00Z'),
});
});
it('writes a timed event in its own zone, not the device one', async () => {
const h = await new Harness({ deviceZone: 'Europe/Berlin' }).setup();
await h.download(single({ id: 'tk', timeZone: 'Asia/Tokyo', start: '2026-10-06T09:00:00' }));
expect(h.masterRow('tk')).toMatchObject({ [Events.EVENT_TIMEZONE]: 'Asia/Tokyo', [Events.DTSTART]: utc('2026-10-06T00:00:00Z') });
});
it('writes all-day events as UTC midnights, multi-day and in weeks too', async () => {
const h = await new Harness({ deviceZone: 'Pacific/Auckland' }).setup();
await h.download(single({ id: 'ad', start: '2026-10-08T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P2D' }));
expect(h.masterRow('ad')).toMatchObject({
[Events.ALL_DAY]: 1,
[Events.EVENT_TIMEZONE]: 'UTC',
[Events.DTSTART]: utc('2026-10-08T00:00:00Z'),
[Events.DTEND]: utc('2026-10-10T00:00:00Z'),
});
// CalDAV DTENDs arrive as weeks.
await h.download(single({ id: 'wk', start: '2026-10-12T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P1W' }));
expect(h.masterRow('wk')[Events.DTEND]).toBe(utc('2026-10-19T00:00:00Z'));
// A recurring all-day event gets a DURATION in days (the provider throws on seconds).
await h.download(single({ id: 'ar', start: '2026-10-12T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P1D', recurrenceRule: { frequency: 'yearly' } }));
expect(h.masterRow('ar')).toMatchObject({ [Events.DURATION]: 'P1D', [Events.DTEND]: null, [Events.RRULE]: 'FREQ=YEARLY' });
// A time part on an all-day duration rounds up.
await h.download(single({ id: 'ap', start: '2026-10-12T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P1DT2H' }));
expect(h.masterRow('ap')[Events.DTEND]).toBe(utc('2026-10-14T00:00:00Z'));
});
it('writes the calendar defaults as reminders when the event uses them', async () => {
const h = await new Harness().setup();
await h.download(single({ id: 'da', useDefaultAlerts: true, alerts: { own: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT1H' } } } }));
const master = h.masterRow('da');
const reminders = h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [Number(master._id)]);
expect(reminders.map((r) => r[Reminders.MINUTES])).toEqual([10]);
});
it('writes no reminders when Bulwark owns them, and leaves the ones an app adds', async () => {
const h = await new Harness({ reminderOwner: 'bulwark' }).setup();
const cal = (await h.calendars()).find((c) => c.syncId === `${ACCT}/b`)!;
expect(cal.cells[Calendars.MAX_REMINDERS]).toBe(0);
await h.download(weekly());
const master = h.masterRow('bl');
expect(h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [Number(master._id)])).toHaveLength(0);
h.fake.user.setReminders(Number(master._id), [{ minutes: 5 }]);
// Neither uploaded nor deleted: the edit only clears DIRTY.
const upload = calendarPlanner.planUpload((await h.local('bl'))!, h.ctx);
expect(upload.kind).toBe('clean');
if (upload.kind === 'clean') await h.apply(upload.ops);
await h.download({ ...weekly(), title: 'Again' });
expect(h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [Number(master._id)])).toHaveLength(1);
});
it('normalises the user\'s own address, under any alias, to OWNER_ACCOUNT', async () => {
const h = await new Harness().setup();
const event = single({
id: 'inv',
organizerCalendarAddress: 'mailto:boss@example.net',
participants: {
boss: { calendarAddress: 'mailto:boss@example.net', roles: { owner: true } },
me: { calendarAddress: 'mailto:ALIAS@Example.org', roles: { attendee: true }, participationStatus: 'tentative' },
room: { calendarAddress: 'mailto:room@example.net', kind: 'resource', roles: { attendee: true } },
opt: { calendarAddress: 'mailto:opt@example.net', roles: { attendee: true, optional: true }, participationStatus: 'declined' },
noMail: { name: 'Phone only', roles: { attendee: true } },
},
} as Partial<CalendarEventWire>);
await h.download(event);
const master = h.masterRow('inv');
expect(master[Events.ORGANIZER]).toBe('boss@example.net');
const rows = h.fake.rows('attendees', `${Attendees.EVENT_ID} = ?`, [Number(master._id)]);
expect(rows.map((a) => [a[Attendees.ATTENDEE_EMAIL], a[Attendees.ATTENDEE_RELATIONSHIP], a[Attendees.ATTENDEE_TYPE], a[Attendees.ATTENDEE_STATUS]])).toEqual([
['boss@example.net', 2, 1, 3],
[ME, 1, 1, 4],
['room@example.net', 1, 3, 3],
['opt@example.net', 1, 2, 2],
]);
});
it('leaves ORGANIZER to the provider for events without participants', async () => {
const h = await new Harness().setup();
const plan = await h.download(single());
const insert = plan.ops.ops.find((op) => op.op === 'insert' && op.table === 'events') as { values: Record<string, unknown> };
expect(Events.ORGANIZER in insert.values).toBe(false);
expect(Events.SELF_ATTENDEE_STATUS in insert.values).toBe(false);
expect(h.masterRow('e1')[Events.ORGANIZER]).toBe(ME);
// The baseline predicted the provider's value, so there is nothing to heal and an echo writes nothing.
expect(calendarPlanner.planBaselineHeal((await h.local('e1'))!)).toBeNull();
expect(calendarPlanner.planDownload(single(), await h.local('e1'), h.ctx).effect).toBe('none');
});
it('never writes a NULL STATUS', async () => {
const h = await new Harness().setup();
await h.download(single({ status: undefined }));
expect(h.masterRow('e1')[Events.STATUS]).toBe(1);
await h.download(single({ status: 'cancelled' }));
expect(h.masterRow('e1')[Events.STATUS]).toBe(2);
});
it('removes and adds exception rows as overrides come and go', async () => {
const h = await new Harness().setup();
await h.download(weekly());
const next = weekly();
next.recurrenceOverrides = {
'2026-10-12T09:00:00': { excluded: true },
'2026-10-14T09:00:00': { excluded: true },
'2026-10-19T09:00:00': { title: 'Other' },
};
const plan = await h.download(next);
expect(plan.effect).toBe('update');
const exceptions = h.fake.rows('events', `${Events.ORIGINAL_SYNC_ID} = ?`, [`${ACCT}/bl`]);
expect(exceptions.map((x) => x[Events.SYNC_DATA2])).toEqual(['2026-10-19T09:00:00']);
expect(exceptions[0][Events.TITLE]).toBe('Other');
expect(h.masterRow('bl')[Events.EXDATE]).toBe('20261012T130000Z,20261014T130000Z');
});
it('replaces the rows of a different object under a reused id', async () => {
const h = await new Harness().setup();
await h.download(weekly());
const other = single({ id: 'bl', uid: 'other-uid', title: 'Someone else' });
const plan = await h.download(other);
expect(plan.effect).toBe('update');
const events = await h.events();
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ syncId: `${ACCT}/bl`, exceptions: [] });
expect(events[0].cells[Events.TITLE]).toBe('Someone else');
});
it('refuses tasks', async () => {
const h = await new Harness().setup();
expect(() => calendarPlanner.planDownload({ ...single(), '@type': 'Task' } as CalendarEventWire, null, h.ctx)).toThrow(/tasks/);
expect(() => calendarPlanner.planDownload({ ...single(), '@type': undefined, due: '2026-10-01T00:00:00' } as unknown as CalendarEventWire, null, h.ctx)).toThrow(/tasks/);
});
it('merges an edited event another client moved out of every synced calendar where its rows are', async () => {
const h = await new Harness().setup();
await h.download(single());
const id = Number(h.masterRow('e1')._id);
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Team lunch' });
// Filed in a calendar this device does not sync, with a new description.
const moved = single({ calendarIds: { archive: true }, description: 'Bring salad' });
const plan = calendarPlanner.planDownload(moved, await h.local('e1'), h.ctx);
await h.apply(plan.ops);
expect(plan).toMatchObject({ effect: 'update', stillDirty: true, conflicts: 0 });
expect(h.masterRow('e1')).toMatchObject({ [Events.CALENDAR_ID]: h.rowIds.get('b'), [Events.TITLE]: 'Team lunch', [Events.DESCRIPTION]: 'Bring salad', [Events.DIRTY]: 1 });
const local = (await h.local('e1'))!;
expect(local.shadow).toEqual(moved);
// Only the device's edit goes up (no calendar move), and the accepted version takes the rows away.
const plan2 = calendarPlanner.planUpload(local, h.ctx);
expect(plan2.kind === 'upload' && plan2.actions).toEqual([{ kind: 'update', id: 'e1', patch: { title: 'Team lunch' } }]);
await h.apply(calendarPlanner.planAccepted(local, { ...moved, title: 'Team lunch' }, h.ctx).ops);
expect(await h.events()).toEqual([]);
});
it('places an event in several calendars in the first selected one, and keeps it where it is', async () => {
const h = await new Harness().setup();
await h.download(single({ calendarIds: { w: true, b: true } }));
expect(h.masterRow('e1')[Events.CALENDAR_ID]).toBe(h.rowIds.get('b'));
await h.download(single({ calendarIds: { w: true } }));
expect(h.masterRow('e1')[Events.CALENDAR_ID]).toBe(h.rowIds.get('w'));
await h.download(single({ calendarIds: { w: true, b: true }, title: 'x' }));
expect(h.masterRow('e1')[Events.CALENDAR_ID]).toBe(h.rowIds.get('w'));
expect(CALENDARS.w.name).toBe('Work');
});
});
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest';
import {
addDuration,
durationBetween,
durationTextSeconds,
formatAospDuration,
formatJsDuration,
formatJsDurationSeconds,
parseDuration,
sameDurationLength,
} from '../../calendar/duration';
import { aospDurationValid } from '../fakes/fake-provider';
import { localToUtc } from '../../calendar/zoned-time';
describe('calendar/duration', () => {
it('parses JSCalendar, iCalendar and AOSP durations', () => {
expect(parseDuration('PT1H30M')).toEqual({ negative: false, days: 0, seconds: 5400 });
expect(parseDuration('P1W')).toEqual({ negative: false, days: 7, seconds: 0 });
expect(parseDuration('P1W2D')).toEqual({ negative: false, days: 9, seconds: 0 });
expect(parseDuration('P1DT2H')).toEqual({ negative: false, days: 1, seconds: 7200 });
expect(parseDuration('-PT15M')).toEqual({ negative: true, days: 0, seconds: 900 });
expect(parseDuration('+PT15M')).toEqual({ negative: false, days: 0, seconds: 900 });
expect(parseDuration('PT1.5S')).toEqual({ negative: false, days: 0, seconds: 1.5 });
expect(parseDuration('pt10m')).toEqual({ negative: false, days: 0, seconds: 600 });
// AOSP: no T before seconds, M always minutes, Fossify's padded form.
expect(parseDuration('P3600S')).toEqual({ negative: false, days: 0, seconds: 3600 });
expect(parseDuration('P0DT1H30M0S')).toEqual({ negative: false, days: 0, seconds: 5400 });
for (const bad of ['P', 'PT', 'P1Y', 'P1.5D', '1H', '', 'PTH']) expect(parseDuration(bad)).toBeNull();
expect(durationTextSeconds('P1W')).toBe(604_800);
});
it('writes the strict JSCalendar grammar', () => {
expect(formatJsDuration(0, 0)).toBe('PT0S');
expect(formatJsDuration(2, 0)).toBe('P2D');
expect(formatJsDuration(1, 3600)).toBe('P1DT1H');
expect(formatJsDuration(0, 5400)).toBe('PT1H30M');
// Minutes stay between hours and seconds.
expect(formatJsDuration(0, 3605)).toBe('PT1H0M5S');
expect(formatJsDuration(0, 45)).toBe('PT45S');
expect(formatJsDuration(0, 59.6)).toBe('PT1M');
expect(formatJsDurationSeconds(90_000)).toBe('P1DT1H');
expect(formatJsDuration(-1, -5)).toBe('PT0S');
});
it('writes only what CalendarProvider accepts', () => {
expect(formatAospDuration(5400, false)).toBe('P5400S');
expect(formatAospDuration(0, false)).toBe('P0S');
expect(formatAospDuration(172_800, true)).toBe('P2D');
// A time part on an all-day event is rounded up to whole days; never zero days.
expect(formatAospDuration(90_000, true)).toBe('P2D');
expect(formatAospDuration(0, true)).toBe('P1D');
for (const s of [0, 1, 59, 3600, 86_400, 1_000_000]) {
expect(aospDurationValid(formatAospDuration(s, false))).toBe(true);
expect(formatAospDuration(s, true)).toMatch(/^P\d+D$/);
}
});
it('counts whole days nominally in the zone and the rest exactly', () => {
const berlin = 'Europe/Berlin';
// 23 h across the spring change are still one day.
const start = localToUtc('2026-03-28T12:00:00', berlin)!;
const end = localToUtc('2026-03-29T12:00:00', berlin)!;
expect(end - start).toBe(23 * 3_600_000);
expect(durationBetween(start, end, berlin)).toEqual({ negative: false, days: 1, seconds: 0 });
expect(addDuration(start, { negative: false, days: 1, seconds: 0 }, berlin)).toBe(end);
expect(durationBetween(start, end + 1_800_000, berlin)).toEqual({ negative: false, days: 1, seconds: 1800 });
expect(durationBetween(start, start + 5_400_000, berlin)).toEqual({ negative: false, days: 0, seconds: 5400 });
expect(durationBetween(end, start, berlin)).toEqual({ negative: false, days: 0, seconds: 0 });
});
it('keeps the second instant of a repeated hour when adding exact time', () => {
const second = Date.parse('2026-10-25T01:30:00Z'); // 02:30 CET, after the change
expect(addDuration(second, { negative: false, days: 0, seconds: 3600 }, 'Europe/Berlin')).toBe(second + 3_600_000);
});
it('compares lengths with a day as 24 hours', () => {
expect(sameDurationLength('PT24H', 'P1D')).toBe(true);
expect(sameDurationLength('P3600S', 'PT1H')).toBe(true);
expect(sameDurationLength('PT1H', 'PT2H')).toBe(false);
});
});
@@ -0,0 +1,66 @@
/**
* Upload → server → accepted plan → the server's echo: after every kind of
* device edit, the rows must already be what the server's next download
* describes, so that echo writes nothing (docs/device-sync.md, invariant 4).
*/
import { describe, expect, it } from 'vitest';
import { Attendees, Events } from '../../android-columns';
import { calendarPlanner } from '../../calendar/planner';
import type { CalendarEventWire } from '../../wire';
import { ACCT, Harness, ME, serverApply, single, utc, weekly } from './harness';
async function roundTrip(event: CalendarEventWire, edit: (h: Harness, id: number) => void | Promise<void>) {
const h = await new Harness().setup();
await h.download(event);
const id = Number(h.masterRow(event.id)._id);
await edit(h, id);
const local = (await h.local(event.id))!;
const plan = calendarPlanner.planUpload(local, h.ctx);
if (plan.kind !== 'upload' || plan.actions[0].kind !== 'update') throw new Error(`expected an update: ${JSON.stringify(plan)}`);
const server = { ...serverApply(event, plan.actions[0].patch), updated: '2026-09-27T13:00:00Z' };
await h.apply(calendarPlanner.planAccepted(local, server, h.ctx).ops);
const after = (await h.local(event.id))!;
return { h, id, server, after, patch: plan.actions[0].patch };
}
const EDITS: Array<[string, () => CalendarEventWire, (h: Harness, id: number) => void | Promise<void>]> = [
['a title', weekly, (h, id) => h.fake.user.updateEvent(id, { [Events.TITLE]: 'T' })],
['a start of a single event', () => single(), (h, id) => h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-06T11:00:00Z'), [Events.DTEND]: utc('2026-10-06T12:00:00Z') })],
['a DST-ambiguous start (fixed-offset zone)', () => single(), (h, id) => h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-25T00:30:00Z'), [Events.DTEND]: utc('2026-10-25T01:30:00Z') })],
['an "all events" move', weekly, (h, id) => h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-05T14:00:00Z') })],
['a "this event" edit', weekly, (h, id) => void h.fake.user.insertException(id, utc('2026-10-14T13:00:00Z'), { [Events.TITLE]: 'Only this', [Events.DTSTART]: utc('2026-10-14T13:00:00Z'), [Events.DTEND]: utc('2026-10-14T14:30:00Z') })],
['a cancelled instance', weekly, (h, id) => void h.fake.user.cancelInstance(id, utc('2026-10-19T13:00:00Z'), utc('2026-10-19T14:30:00Z'))],
['a capped rule', weekly, (h, id) => h.fake.user.updateEvent(id, { [Events.RRULE]: 'FREQ=WEEKLY;UNTIL=20261010T000000Z;BYDAY=MO,WE' })],
['an RSVP', weekly, (h, id) => h.fake.user.setAttendeeStatus(id, ME, 4)],
['an added attendee', weekly, (h, id) => void h.fake.user.addAttendee(id, { [Attendees.ATTENDEE_EMAIL]: 'x@example.net', [Attendees.ATTENDEE_TYPE]: 1, [Attendees.ATTENDEE_STATUS]: 3, [Attendees.ATTENDEE_RELATIONSHIP]: 1 })],
['reminders', weekly, (h, id) => h.fake.user.setReminders(id, [{ minutes: 5 }, { minutes: 1440, method: 2 }])],
['a location on an event without one', () => single(), (h, id) => h.fake.user.updateEvent(id, { [Events.EVENT_LOCATION]: 'Hall' })],
['an all-day event\'s dates', () => single({ id: 'a', start: '2026-10-08T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P1D' }), (h, id) => h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-09T00:00:00Z'), [Events.DTEND]: utc('2026-10-12T00:00:00Z') })],
];
describe('calendar planner: accepted uploads leave nothing for the echo', () => {
it.each(EDITS)('%s', async (_name, make, edit) => {
const { h, server, after } = await roundTrip(make(), edit);
expect(after.dirty).toBe(false);
expect(after.exceptions.every((x) => !x.dirty)).toBe(true);
const echo = calendarPlanner.planDownload(server, after, h.ctx);
expect(echo).toMatchObject({ effect: 'none', writes: 0 });
expect(calendarPlanner.planBaselineHeal(after)).toBeNull();
});
it('re-keys the exception rows with the series after an "all events" move', async () => {
const { after, patch } = await roundTrip(weekly(), (h, id) => h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-05T14:00:00Z') }));
expect(Object.keys(patch.recurrenceOverrides as object).sort()).toEqual(['2026-10-07T10:00:00', '2026-10-12T10:00:00']);
expect(after.exceptions.map((x) => [x.syncId, x.cells[Events.ORIGINAL_INSTANCE_TIME]])).toEqual([
[`${ACCT}/bl#2026-10-07T10:00:00`, utc('2026-10-07T14:00:00Z')],
]);
});
it('drops the exception rows of overrides a "this and following" cut pruned', async () => {
const event = weekly();
event.recurrenceRule = { frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'we' }] };
event.recurrenceOverrides = { '2026-10-07T09:00:00': { title: 'Early' }, '2026-10-21T09:00:00': { title: 'Late' } };
const { after } = await roundTrip(event, (h, id) => h.fake.user.updateEvent(id, { [Events.RRULE]: 'FREQ=WEEKLY;UNTIL=20261019T125959Z;BYDAY=MO,WE' }));
expect(after.exceptions.map((x) => x.recurrenceId)).toEqual(['2026-10-07T09:00:00']);
});
});
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import { Events } from '../../android-columns';
import { calendarPlanner } from '../../calendar/planner';
import type { CalendarEventWire } from '../../wire';
import { ACCT, Harness, single, utc, weekly } from './harness';
async function synced(event: CalendarEventWire) {
const h = await new Harness().setup();
await h.download(event);
return { h, id: Number(h.masterRow(event.id)._id) };
}
describe('calendar planner: edge cases', () => {
it('undoes a deletion in a read-only calendar by purging and refetching', async () => {
const { h, id } = await synced(single({ calendarIds: { r: true } }));
h.fake.user.deleteEvent(id);
const plan = calendarPlanner.planUpload((await h.local('e1'))!, h.ctx);
expect(plan).toMatchObject({ kind: 'revert', refetch: true, reason: 'readOnly' });
if (plan.kind === 'revert') await h.apply(plan.ops);
expect(await h.events()).toEqual([]);
});
it('treats an instance Stalwart holds without its series as read-only', async () => {
const { h, id } = await synced(single({ recurrenceId: '2026-10-06T12:00:00', recurrenceIdTimeZone: 'Europe/Berlin' }));
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Mine' });
const plan = calendarPlanner.planUpload((await h.local('e1'))!, h.ctx);
expect(plan).toMatchObject({ kind: 'revert', reason: 'instanceOnly' });
if (plan.kind === 'revert') await h.apply(plan.ops);
expect(h.row(id)).toMatchObject({ [Events.TITLE]: 'Lunch', [Events.DIRTY]: 0 });
});
it('pairs Etar\'s move of a series with its copied exceptions and adopts them', async () => {
const { h, id } = await synced(weekly());
h.fake.user.deleteEvent(id);
const fresh = h.fake.user.insertEvent(h.rowIds.get('w')!, {
[Events.TITLE]: 'Probe weekly', [Events.DESCRIPTION]: 'desc', [Events.EVENT_LOCATION]: 'Room 1', [Events.DTSTART]: utc('2026-10-05T13:00:00Z'),
[Events.DURATION]: 'P5400S', [Events.RRULE]: 'FREQ=WEEKLY;COUNT=10;BYDAY=MO,WE', [Events.EVENT_TIMEZONE]: 'America/New_York',
[Events.STATUS]: 0, [Events.AVAILABILITY]: 1, [Events.ACCESS_LEVEL]: 2, [Events.EVENT_COLOR]: h.row(id)[Events.EVENT_COLOR],
});
for (const a of h.fake.rows('attendees', 'event_id = ?', [id])) {
h.fake.table('attendees').set(h.fake.allocId(), { ...a, _id: null, event_id: fresh });
}
// Etar copies the exception too (API 30+).
h.fake.user.insertException(fresh, utc('2026-10-07T13:00:00Z'), { [Events.TITLE]: 'Moved Wednesday', [Events.DTSTART]: utc('2026-10-07T15:00:00Z'), [Events.DTEND]: utc('2026-10-07T16:30:00Z') });
const events = await h.events();
const pairs = calendarPlanner.planPairs(events.filter((e) => e.deleted), events.filter((e) => !e.syncId && !e.deleted), h.ctx);
expect(pairs.map((p) => p.actions)).toEqual([[{ kind: 'update', id: 'bl', patch: { 'calendarIds/b': null, 'calendarIds/w': true } }]]);
await h.apply(pairs[0].ops);
const after = (await h.local('bl'))!;
expect(after.eventId).toBe(fresh);
expect(after.exceptions.map((x) => [x.syncId, x.dirty])).toEqual([[`${ACCT}/bl#2026-10-07T09:00:00`, false]]);
// The old row and its own exception are gone.
expect((await h.events()).map((e) => e.eventId)).toEqual([fresh]);
});
it('compares descriptions over 1 KB by hash and uploads them only when edited', async () => {
const long = 'Lorem ipsum '.repeat(200);
const { h, id } = await synced(single({ description: long }));
const local = (await h.local('e1'))!;
expect(String(local.baseline!.cells[Events.DESCRIPTION])).toMatch(/^sha256:[0-9a-f]{64}$/);
h.fake.user.updateEvent(id, {});
expect(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx).kind).toBe('clean');
h.fake.user.updateEvent(id, { [Events.DESCRIPTION]: `${long}!` });
const plan = calendarPlanner.planUpload((await h.local('e1'))!, h.ctx);
expect(plan.kind === 'upload' && plan.actions[0].kind === 'update' && plan.actions[0].patch).toEqual({ description: `${long}!` });
});
it('writes nothing into a split clone on download: it is a new event', async () => {
const event = single({ recurrenceRule: { frequency: 'daily' } });
const { h, id } = await synced(event);
const clone = h.fake.user.splitViaUri(id, utc('2026-10-10T10:00:00Z'), '20261010T095959Z', {});
const cloneEvent = (await h.events()).find((e) => e.eventId === clone)!;
expect(calendarPlanner.planDownload({ ...event, title: 'x' }, cloneEvent, h.ctx)).toMatchObject({ effect: 'none', stillDirty: true });
});
it('keeps an exception row an app left without a description or attendees inheriting them', async () => {
const { h, id } = await synced(weekly());
h.fake.user.insertException(id, utc('2026-10-14T13:00:00Z'), { [Events.DTSTART]: utc('2026-10-14T13:00:00Z'), [Events.DTEND]: utc('2026-10-14T14:30:00Z'), [Events.EVENT_LOCATION]: null });
const plan = calendarPlanner.planUpload((await h.local('bl'))!, h.ctx);
if (plan.kind !== 'upload' || plan.actions[0].kind !== 'update') throw new Error();
const override = plan.actions[0].patch['recurrenceOverrides/2026-10-14T09:00:00'] as Record<string, unknown>;
// The series' description, location and attendees stay on the instance.
expect(override).toMatchObject({ description: 'desc', locations: weekly().locations, participants: weekly().participants });
});
});
@@ -0,0 +1,456 @@
// The calendar planner through the engine and the fake JMAP server: device
// edits reach the server as the user made them and the rows stay as the user
// left them, end to end. Mapping details are the planner's own tests.
import { describe, expect, it } from 'vitest';
import { Events, Reminders } from '../../android-columns';
import { calendarPlanner } from '../../calendar/planner';
import { contactsPlanner } from '../../contacts/planner';
import { CALENDAR_AUTHORITY, createHarness, type Harness } from '../engine/harness';
function real(options: Parameters<typeof createHarness>[0] = {}): Harness {
const h = createHarness(options);
h.deps.planners = { contacts: contactsPlanner, calendar: calendarPlanner };
return h;
}
type Overrides = Record<string, Record<string, unknown>>;
describe('calendar device edits through the engine', () => {
it('creates an event and an occurrence edit an app left without a status as confirmed ones', async () => {
const h = real();
const series = h.server.addEvent('a', {
uid: 'weekly-uid',
title: 'Weekly',
start: '2026-10-05T08:00:00',
duration: 'PT1H',
timeZone: 'Europe/Berlin',
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly', count: 8 },
calendarIds: { [h.calendar]: true },
});
await h.run(CALENDAR_AUTHORITY);
const master = h.events().find((e) => e[Events._SYNC_ID] === `a/${series}`)!;
const rowId = Number(master[Events.CALENDAR_ID]);
// Google Calendar leaves STATUS NULL on the events and exceptions it inserts.
const created = h.device.user.insertEvent(rowId, {
[Events.TITLE]: 'New from Google Calendar',
[Events.DTSTART]: Date.UTC(2026, 9, 14, 13),
[Events.DTEND]: Date.UTC(2026, 9, 14, 14),
[Events.EVENT_TIMEZONE]: 'Europe/Berlin',
[Events.STATUS]: null,
});
h.device.user.insertException(Number(master._id), Date.UTC(2026, 10, 9, 7), {
[Events.TITLE]: 'Only Nov 9',
[Events.DTSTART]: Date.UTC(2026, 10, 9, 7),
[Events.DTEND]: Date.UTC(2026, 10, 9, 8),
[Events.STATUS]: null,
});
expect(await h.run(CALENDAR_AUTHORITY)).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 1, updated: 1 } } });
const event = h.server.all('CalendarEvent', 'a').find((e) => e.title === 'New from Google Calendar')!;
expect(event.status).toBeUndefined();
const overrides = h.server.get('CalendarEvent', 'a', series)!.recurrenceOverrides as Overrides;
expect(overrides['2026-11-09T08:00:00']).toMatchObject({ title: 'Only Nov 9' });
expect(overrides['2026-11-09T08:00:00'].status).toBeUndefined();
// The rows hold the default, confirmed, and are clean.
expect(h.events().find((e) => Number(e._id) === created)).toMatchObject({ [Events.STATUS]: 1, [Events.DIRTY]: 0 });
expect(h.events().filter((e) => e[Events.ORIGINAL_SYNC_ID] === `a/${series}`).map((e) => [e[Events.STATUS], e[Events.DIRTY]])).toEqual([[1, 0]]);
});
it('keeps a reminder changed on one occurrence of a series on the calendar\'s default alerts', async () => {
const h = real();
h.server.serverUpdate('Calendar', 'a', h.calendar, {
defaultAlertsWithTime: { d1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' }, action: 'display' } },
});
const id = h.server.addEvent('a', {
uid: 'standup-uid',
title: 'Standup',
start: '2026-09-28T09:00:00',
duration: 'PT15M',
timeZone: 'Europe/Berlin',
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'daily', count: 5 },
recurrenceOverrides: { '2026-09-29T09:00:00': { title: 'Standup (late)', start: '2026-09-29T10:00:00' } },
useDefaultAlerts: true,
calendarIds: { [h.calendar]: true },
});
await h.run(CALENDAR_AUTHORITY);
const minutes = (eventId: unknown) => h.device.rows('reminders').filter((r) => r[Reminders.EVENT_ID] === Number(eventId)).map((r) => Number(r[Reminders.MINUTES]));
const master = h.events().find((e) => e[Events._SYNC_ID] === `a/${id}`)!;
const exception = () => h.events().find((e) => e[Events.ORIGINAL_SYNC_ID] === `a/${id}`)!;
expect([minutes(master._id), minutes(exception()._id)]).toEqual([[15], [15]]);
h.device.user.setReminders(Number(exception()._id), [{ minutes: 60 }]);
expect(await h.run(CALENDAR_AUTHORITY)).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { updated: 1 } } });
expect(exception()[Events.DIRTY]).toBe(0);
expect([minutes(master._id), minutes(exception()._id)]).toEqual([[15], [60]]);
const event = h.server.get('CalendarEvent', 'a', id)!;
expect(event.useDefaultAlerts).toBe(true);
expect((event.recurrenceOverrides as Overrides)['2026-09-29T09:00:00']).toMatchObject({ useDefaultAlerts: false });
// Its echo writes nothing.
const before = h.batches.log.length;
await h.run(CALENDAR_AUTHORITY);
expect(h.batches.log.slice(before).filter((ops) => ops.some((op) => op.op !== 'syncState' && op.op !== 'assert'))).toEqual([]);
});
it('sends an answer to one occurrence in a calendar that grants only RSVP, and reports a refusal', async () => {
const h = real({ accounts: 'withShared' });
const team = h.server.addCalendar('team', {
name: 'Team events',
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: true, mayShare: false, mayDelete: false },
});
h.prefs.calendarSelection[`team/${team}`] = true;
const id = h.server.addEvent('team', {
uid: 'team-weekly',
title: 'Team weekly',
start: '2026-10-05T09:00:00',
duration: 'PT1H',
timeZone: 'Europe/Berlin',
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly', count: 5 },
organizerCalendarAddress: 'mailto:boss@example.net',
participants: {
boss: { '@type': 'Participant', calendarAddress: 'mailto:boss@example.net', roles: { owner: true }, participationStatus: 'accepted' },
me: { '@type': 'Participant', calendarAddress: 'mailto:alice@example.com', roles: { attendee: true }, participationStatus: 'needs-action' },
},
calendarIds: { [team]: true },
});
await h.run(CALENDAR_AUTHORITY);
const master = h.events().find((e) => e[Events._SYNC_ID] === `team/${id}`)!;
// Etar answers the Oct 12 occurrence through CONTENT_EXCEPTION_URI.
h.device.user.exceptionViaUri(Number(master._id), Date.UTC(2026, 9, 12, 7), { [Events.SELF_ATTENDEE_STATUS]: 2, [Events.STATUS]: 1 });
const sets = () => h.server.requests.filter((r) => r.methods.includes('CalendarEvent/set')).length;
const before = sets();
const report = await h.run(CALENDAR_AUTHORITY);
// The answer was sent (this server takes no RSVP without write rights), the refusal is reported and the row put back.
expect(sets()).toBeGreaterThan(before);
expect(report.itemErrors).toEqual([expect.objectContaining({ ref: `team/${id}`, side: 'upload', type: 'forbidden' })]);
expect(h.events().filter((e) => e[Events.ORIGINAL_SYNC_ID] === `team/${id}`)).toEqual([]);
expect(h.events().every((e) => Number(e[Events.DIRTY] ?? 0) === 0)).toBe(true);
// Nothing was stored at all: the report does not claim it was, without invitations.
expect(report.message ?? '').not.toContain('invitations not sent');
});
it('notes that invitations were not sent only when the change was stored without them', async () => {
const h = real();
const id = h.server.addEvent('a', {
uid: 'review-uid',
title: 'Review',
start: '2026-10-06T12:00:00',
duration: 'PT1H',
timeZone: 'Europe/Berlin',
organizerCalendarAddress: 'mailto:alice@example.com',
participants: {
me: { '@type': 'Participant', calendarAddress: 'mailto:alice@example.com', roles: { owner: true, attendee: true }, participationStatus: 'accepted' },
bob: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.com', roles: { attendee: true }, participationStatus: 'needs-action' },
},
calendarIds: { [h.calendar]: true },
});
await h.run(CALENDAR_AUTHORITY);
const row = Number(h.events().find((e) => e[Events._SYNC_ID] === `a/${id}`)!._id);
h.device.user.updateEvent(row, { [Events.TITLE]: 'Design review' });
// The server refuses the scheduling messages once (e.g. scheduling turned off): the change goes up without them.
h.server.setErrorFor('CalendarEvent', 'a', id, { type: 'forbidden' });
const report = await h.run(CALENDAR_AUTHORITY);
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { updated: 1 } } });
expect(report.message).toContain('invitations not sent');
expect(h.server.get('CalendarEvent', 'a', id)).toMatchObject({ title: 'Design review' });
});
it('puts an event an app moved in place to a calendar of another account back into its calendar', async () => {
const h = real({ accounts: 'withShared' });
const team = String(h.server.all('Calendar', 'team')[0].id);
h.prefs.calendarSelection[`team/${team}`] = true;
const id = h.server.addEvent('a', {
uid: 'lunch-uid',
title: 'Lunch',
start: '2026-10-06T12:00:00',
duration: 'PT1H',
timeZone: 'Europe/Berlin',
calendarIds: { [h.calendar]: true },
});
await h.run(CALENDAR_AUTHORITY);
const calendarRow = (key: string) => Number(h.device.rows('calendars').find((c) => c._sync_id === key)!._id);
const row = () => h.events().find((e) => e[Events._SYNC_ID] === `a/${id}`)!;
expect(row()[Events.CALENDAR_ID]).toBe(calendarRow(`a/${h.calendar}`));
// An app moves it by changing CALENDAR_ID in place (a move across JMAP accounts can't be a patch).
h.device.user.updateEvent(Number(row()._id), { [Events.CALENDAR_ID]: calendarRow(`team/${team}`) });
const report = await h.run(CALENDAR_AUTHORITY);
expect(row()).toMatchObject({ [Events.CALENDAR_ID]: calendarRow(`a/${h.calendar}`), [Events.DIRTY]: 0 });
expect(h.server.get('CalendarEvent', 'a', id)!.calendarIds).toEqual({ [h.calendar]: true });
expect(h.server.all('CalendarEvent', 'team')).toEqual([]);
// Nothing was sent; the change counts as skipped, and the report says why.
expect(report).toMatchObject({ outcome: 'ok', stats: { uploaded: { created: 0, updated: 0, deleted: 0 }, skipped: 1 } });
expect(report.itemErrors).toEqual([expect.objectContaining({ ref: `a/${id}`, side: 'upload', type: 'crossAccountMove' })]);
// Together with another edit: that one uploads, and the row goes back all the same.
h.device.user.updateEvent(Number(row()._id), { [Events.CALENDAR_ID]: calendarRow(`team/${team}`), [Events.TITLE]: 'Team lunch' });
expect(await h.run(CALENDAR_AUTHORITY)).toMatchObject({ outcome: 'ok', stats: { uploaded: { updated: 1 } } });
expect(h.server.get('CalendarEvent', 'a', id)).toMatchObject({ title: 'Team lunch', calendarIds: { [h.calendar]: true } });
expect(row()).toMatchObject({ [Events.CALENDAR_ID]: calendarRow(`a/${h.calendar}`), [Events.TITLE]: 'Team lunch', [Events.DIRTY]: 0 });
expect(h.server.all('CalendarEvent', 'team')).toEqual([]);
});
describe("Etar's move (delete + insert) with other edits", () => {
/** The event columns Etar copies into the row it inserts when it moves an event. */
const ETAR_COPY = [
Events.TITLE, Events.DESCRIPTION, Events.EVENT_LOCATION, Events.STATUS, Events.AVAILABILITY, Events.ACCESS_LEVEL,
Events.EVENT_COLOR, Events.DTSTART, Events.DTEND, Events.DURATION, Events.EVENT_TIMEZONE, Events.ALL_DAY, Events.RRULE, Events.EXDATE,
];
function etarMove(h: Harness, masterId: number, calendarRowId: number, reminders: Array<{ minutes: number }>): number {
const old = h.events().find((e) => Number(e._id) === masterId)!;
const values = Object.fromEntries(ETAR_COPY.filter((c) => old[c] !== undefined).map((c) => [c, old[c]]));
h.device.user.deleteEvent(masterId);
return h.device.user.insertEvent(calendarRowId, values, {
reminders: reminders.map((r) => ({ [Reminders.MINUTES]: r.minutes, [Reminders.METHOD]: 1 })),
});
}
const minutes = (h: Harness, eventId: number) =>
h.device.rows('reminders').filter((r) => Number(r[Reminders.EVENT_ID]) === eventId).map((r) => Number(r[Reminders.MINUTES]));
it('uploads a reminder saved with the move, and keeps the event', async () => {
const h = real();
const work = h.server.addCalendar('a', { name: 'Work' });
const id = h.server.addEvent('a', {
uid: 'lunch-uid',
title: 'Lunch',
start: '2026-10-06T12:00:00',
duration: 'PT1H',
timeZone: 'Europe/Berlin',
keywords: { important: true },
alerts: { al1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' }, action: 'display' } },
calendarIds: { [h.calendar]: true },
});
await h.run(CALENDAR_AUTHORITY);
const master = Number(h.events().find((e) => e[Events._SYNC_ID] === `a/${id}`)!._id);
const workRow = Number(h.device.rows('calendars').find((c) => c._sync_id === `a/${work}`)!._id);
const moved = etarMove(h, master, workRow, [{ minutes: 60 }]);
const report = await h.run(CALENDAR_AUTHORITY);
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 0, deleted: 0 } } });
const event = h.server.get('CalendarEvent', 'a', id)!;
expect(event).toMatchObject({ uid: 'lunch-uid', calendarIds: { [work]: true }, keywords: { important: true } });
const alerts = Object.values(event.alerts as Record<string, { trigger: { offset: string } }>).map((a) => a.trigger.offset);
expect(alerts).toEqual(['-PT1H']);
expect(h.events()).toEqual([expect.objectContaining({ _id: moved, _sync_id: `a/${id}`, [Events.DIRTY]: 0 })]);
expect(minutes(h, moved)).toEqual([60]);
const before = h.batches.log.length;
await h.run(CALENDAR_AUTHORITY);
expect(h.batches.log.slice(before).filter((ops) => ops.some((op) => op.op !== 'syncState' && op.op !== 'assert'))).toEqual([]);
});
describe('when the server deleted the event meanwhile', () => {
async function movedLunch() {
const h = real();
const work = h.server.addCalendar('a', { name: 'Work' });
const id = h.server.addEvent('a', {
uid: 'lunch-uid',
title: 'Lunch',
start: '2026-10-06T12:00:00',
duration: 'PT1H',
timeZone: 'Europe/Berlin',
calendarIds: { [h.calendar]: true },
});
await h.run(CALENDAR_AUTHORITY);
const master = Number(h.events().find((e) => e[Events._SYNC_ID] === `a/${id}`)!._id);
const workRow = Number(h.device.rows('calendars').find((c) => c._sync_id === `a/${work}`)!._id);
etarMove(h, master, workRow, [{ minutes: 30 }]);
return { h, id };
}
const creates = (h: Harness) =>
h.server.calls('CalendarEvent/set').filter(([, args]) => Object.keys((args as { create?: object }).create ?? {}).length);
it('removes the moved event when the patch of the move finds it gone', async () => {
const { h, id } = await movedLunch();
h.server.setErrorFor('CalendarEvent', 'a', id, { type: 'notFound' });
const report = await h.run(CALENDAR_AUTHORITY);
expect(report).toMatchObject({ outcome: 'ok', conflicts: 1, stats: { uploaded: { created: 0 } } });
expect(creates(h)).toEqual([]);
expect(h.events()).toEqual([]);
});
it('removes the moved event when the download finds it destroyed', async () => {
const { h, id } = await movedLunch();
h.server.serverDestroy('CalendarEvent', 'a', id);
const report = await h.run(CALENDAR_AUTHORITY);
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], conflicts: 1, stats: { uploaded: { created: 0 } } });
expect(h.server.all('CalendarEvent', 'a')).toEqual([]);
expect(h.events()).toEqual([]);
});
it('removes the moved event when it is destroyed while the move is on its way', async () => {
const { h, id } = await movedLunch();
const stop = h.server.onBeforeRequest((request) => {
if (!request.methods.includes('CalendarEvent/set')) return;
stop();
h.server.serverDestroy('CalendarEvent', 'a', id);
});
const report = await h.run(CALENDAR_AUTHORITY);
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 0 } } });
expect(h.server.all('CalendarEvent', 'a')).toEqual([]);
expect(h.events()).toEqual([]);
});
});
it('keeps an occurrence edit that had not reached the server when the series moves', async () => {
const h = real();
const work = h.server.addCalendar('a', { name: 'Work' });
const id = h.server.addEvent('a', {
uid: 'standup-uid',
title: 'Standup',
start: '2026-09-28T09:00:00',
duration: 'PT15M',
timeZone: 'Europe/Berlin',
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'daily', count: 5 },
recurrenceOverrides: { '2026-09-29T09:00:00': { title: 'Planning' } },
calendarIds: { [h.calendar]: true },
});
await h.run(CALENDAR_AUTHORITY);
const master = Number(h.events().find((e) => e[Events._SYNC_ID] === `a/${id}`)!._id);
const exception = Number(h.events().find((e) => e[Events.ORIGINAL_SYNC_ID] === `a/${id}`)!._id);
const workRow = Number(h.device.rows('calendars').find((c) => c._sync_id === `a/${work}`)!._id);
// The occurrence is renamed on the device; before it syncs, Etar moves the series (it copies only unsynced exceptions).
h.device.user.updateEvent(exception, { [Events.TITLE]: 'Sprint planning' });
const moved = etarMove(h, master, workRow, []);
const report = await h.run(CALENDAR_AUTHORITY);
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 0, deleted: 0 } } });
const event = h.server.get('CalendarEvent', 'a', id)!;
expect(event.calendarIds).toEqual({ [work]: true });
expect((event.recurrenceOverrides as Overrides)['2026-09-29T09:00:00']).toMatchObject({ title: 'Sprint planning' });
const rows = h.events();
expect(rows.filter((e) => e[Events.ORIGINAL_ID] === null)).toEqual([expect.objectContaining({ _id: moved, _sync_id: `a/${id}`, [Events.DIRTY]: 0 })]);
expect(rows.filter((e) => e[Events.ORIGINAL_ID] !== null)).toEqual([
expect.objectContaining({ [Events.ORIGINAL_ID]: moved, [Events.CALENDAR_ID]: workRow, [Events.TITLE]: 'Sprint planning', [Events.DIRTY]: 0 }),
]);
});
});
it('removes an event created in a read-only calendar and reports it', async () => {
const h = real({ accounts: 'withShared' });
const team = h.server.addCalendar('team', {
name: 'Team events',
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false },
});
h.prefs.calendarSelection[`team/${team}`] = true;
await h.run(CALENDAR_AUTHORITY);
const teamRow = Number(h.device.rows('calendars').find((c) => c._sync_id === `team/${team}`)!._id);
const id = h.device.user.insertEvent(teamRow, {
[Events.TITLE]: 'Mine',
[Events.DTSTART]: Date.UTC(2026, 9, 14, 13),
[Events.DTEND]: Date.UTC(2026, 9, 14, 14),
[Events.EVENT_TIMEZONE]: 'Europe/Berlin',
});
const report = await h.run(CALENDAR_AUTHORITY);
expect(report).toMatchObject({ outcome: 'ok', stats: { uploaded: { created: 0 }, skipped: 1 } });
expect(report.itemErrors).toEqual([expect.objectContaining({ ref: `row:${id}`, side: 'upload', type: 'readOnly' })]);
expect(h.events().find((e) => Number(e._id) === id)).toBeUndefined();
expect(h.server.all('CalendarEvent', 'team')).toEqual([]);
// Nothing is left waiting: the next run has nothing to say about it.
expect((await h.run(CALENDAR_AUTHORITY)).itemErrors).toEqual([]);
});
it('leaves nothing waiting for turning sync off after an event was created in a read-only calendar', async () => {
const h = real({ accounts: 'withShared' });
const team = h.server.addCalendar('team', { name: 'Team events', myRights: { mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayRSVP: false } });
h.prefs.calendarSelection[`team/${team}`] = true;
await h.run(CALENDAR_AUTHORITY);
const teamRow = Number(h.device.rows('calendars').find((c) => c._sync_id === `team/${team}`)!._id);
h.device.user.insertEvent(teamRow, { [Events.TITLE]: 'Mine', [Events.DTSTART]: Date.UTC(2026, 9, 14, 13), [Events.DTEND]: Date.UTC(2026, 9, 14, 14), [Events.EVENT_TIMEZONE]: 'Europe/Berlin' });
expect(await h.teardown(CALENDAR_AUTHORITY)).toEqual({ pending: 0 });
expect(h.server.all('CalendarEvent', 'team')).toEqual([]);
});
describe('an occurrence\'s own colour and reminders removed on the device', () => {
async function standup(extra: Record<string, unknown>, override: Record<string, unknown>) {
const h = real();
const id = h.server.addEvent('a', {
uid: 'standup-uid',
title: 'Standup',
start: '2026-09-28T09:00:00',
duration: 'PT15M',
timeZone: 'Europe/Berlin',
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'daily', count: 5 },
recurrenceOverrides: { '2026-09-29T09:00:00': { title: 'Standup (late)', ...override } },
calendarIds: { [h.calendar]: true },
...extra,
});
await h.run(CALENDAR_AUTHORITY);
const exception = () => h.events().find((e) => e[Events.ORIGINAL_SYNC_ID] === `a/${id}`)!;
const minutes = () => h.device.rows('reminders').filter((r) => Number(r[Reminders.EVENT_ID]) === Number(exception()._id)).map((r) => Number(r[Reminders.MINUTES]));
return { h, id, exception, minutes };
}
it('keeps a colour cleared on one occurrence cleared', async () => {
const { h, id, exception } = await standup({ color: 'steelblue' }, { color: 'red' });
h.device.user.updateEvent(Number(exception()._id), { [Events.EVENT_COLOR]: null });
expect(await h.run(CALENDAR_AUTHORITY)).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { updated: 1 } } });
expect(exception()).toMatchObject({ [Events.EVENT_COLOR]: null, [Events.DIRTY]: 0 });
expect((h.server.get('CalendarEvent', 'a', id)!.recurrenceOverrides as Overrides)['2026-09-29T09:00:00']).toMatchObject({ color: '' });
});
it('puts back the removal of every reminder of an occurrence whose series shows its own, and reports it', async () => {
const alert = { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' }, action: 'display' };
const { h, id, exception, minutes } = await standup({ alerts: { al1: alert } }, {});
expect(minutes()).toEqual([15]);
const sets = () => h.server.requests.filter((r) => r.methods.includes('CalendarEvent/set')).length;
const before = sets();
h.device.user.setReminders(Number(exception()._id), []);
const report = await h.run(CALENDAR_AUTHORITY);
expect(report.itemErrors).toEqual([expect.objectContaining({ ref: `a/${id}`, side: 'upload', type: 'remindersNotRepresentable' })]);
expect(sets()).toBe(before);
expect(minutes()).toEqual([15]);
expect(exception()[Events.DIRTY]).toBe(0);
});
});
it('keeps a description and location cleared on one occurrence cleared', async () => {
const h = real();
const id = h.server.addEvent('a', {
uid: 'standup-uid',
title: 'Standup',
description: 'Daily agenda',
start: '2026-09-28T09:00:00',
duration: 'PT15M',
timeZone: 'Europe/Berlin',
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'daily', count: 5 },
locations: { locA: { '@type': 'Location', name: 'Room 1' } },
recurrenceOverrides: {
'2026-09-29T09:00:00': { title: 'Standup', description: 'Special agenda', locations: { locB: { '@type': 'Location', name: 'Room 2' } } },
},
calendarIds: { [h.calendar]: true },
});
await h.run(CALENDAR_AUTHORITY);
const exception = () => h.events().find((e) => e[Events.ORIGINAL_SYNC_ID] === `a/${id}`)!;
expect(exception()).toMatchObject({ [Events.DESCRIPTION]: 'Special agenda', [Events.EVENT_LOCATION]: 'Room 2' });
h.device.user.updateEvent(Number(exception()._id), { [Events.DESCRIPTION]: null, [Events.EVENT_LOCATION]: null });
expect(await h.run(CALENDAR_AUTHORITY)).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { updated: 1 } } });
expect(exception()).toMatchObject({ [Events.DESCRIPTION]: null, [Events.EVENT_LOCATION]: null, [Events.DIRTY]: 0 });
const overrides = h.server.get('CalendarEvent', 'a', id)!.recurrenceOverrides as Overrides;
expect(overrides['2026-09-29T09:00:00']).toMatchObject({ description: '', locations: { locB: { name: '' } } });
});
});
@@ -0,0 +1,548 @@
import { describe, expect, it } from 'vitest';
import { Attendees, Events, Reminders } from '../../android-columns';
import { calendarPlanner } from '../../calendar/planner';
import { cssColorToArgb } from '../../calendar/values';
import type { LocalEvent, UploadPlan } from '../../planner';
import type { CalendarEventWire } from '../../wire';
import { ACCT, Harness, ME, serverApply, single, utc, weekly } from './harness';
type Plan = UploadPlan<CalendarEventWire>;
function patchOf(plan: Plan) {
expect(plan.kind).toBe('upload');
if (plan.kind !== 'upload' || plan.actions[0].kind !== 'update') throw new Error(JSON.stringify(plan));
return plan.actions[0];
}
async function synced(event: CalendarEventWire) {
const h = await new Harness().setup();
await h.download(event);
return { h, id: Number(h.masterRow(event.id)._id) };
}
/** Uploads, lets the server apply the patch, applies the accepted plan and returns the new server object. */
async function accept(h: Harness, event: CalendarEventWire, local: LocalEvent, plan: Plan): Promise<CalendarEventWire> {
const action = patchOf(plan);
const server = serverApply(event, action.patch);
const accepted = calendarPlanner.planAccepted(local, server, h.ctx);
await h.apply(accepted.ops);
return server;
}
const NO_FORBIDDEN = ['@type', 'organizerCalendarAddress', 'privacy', 'uid', 'recurrenceRule', 'recurrenceOverrides', 'recurrenceId', 'method', 'prodId', 'sentBy'];
describe('calendar planner: recurrence edits made on the device', () => {
it('uploads an Etar "this event" edit as a new override carrying the master\'s details', async () => {
const { h, id } = await synced(weekly());
h.fake.user.insertException(id, utc('2026-10-14T13:00:00Z'), {
[Events.TITLE]: 'Only this Wednesday',
[Events.DTSTART]: utc('2026-10-14T14:00:00Z'),
[Events.DTEND]: utc('2026-10-14T15:30:00Z'),
});
const local = (await h.local('bl'))!;
expect(local.exceptions.find((x) => !x.cells[Events.SYNC_DATA2])?.recurrenceId).toBe('2026-10-14T09:00:00');
const plan = calendarPlanner.planUpload(local, h.ctx);
const action = patchOf(plan);
expect(Object.keys(action.patch)).toEqual(['recurrenceOverrides/2026-10-14T09:00:00']);
const override = action.patch['recurrenceOverrides/2026-10-14T09:00:00'] as Record<string, unknown>;
expect(override).toMatchObject({
title: 'Only this Wednesday',
start: '2026-10-14T10:00:00',
duration: 'PT1H30M',
locations: weekly().locations,
participants: weekly().participants,
description: 'desc',
});
for (const key of NO_FORBIDDEN) expect(override).not.toHaveProperty(key);
// The organizer changed an instance: attendees are told.
expect(action.sendSchedulingMessages).toBe(true);
const server = await accept(h, weekly(), local, plan);
const after = (await h.local('bl'))!;
expect(after.dirty).toBe(false);
expect(after.exceptions.map((x) => [x.syncId, x.dirty])).toEqual([
[`${ACCT}/bl#2026-10-07T09:00:00`, false],
[`${ACCT}/bl#2026-10-14T09:00:00`, false],
]);
// The download of our own change writes nothing.
expect(calendarPlanner.planDownload(server, after, h.ctx).effect).toBe('none');
});
it('sends the whole overrides map for the first override of a series', async () => {
const event = single({ recurrenceRule: { frequency: 'daily', count: 5 } });
const { h, id } = await synced(event);
h.fake.user.insertException(id, utc('2026-10-07T10:00:00Z'), { [Events.TITLE]: 'Tuesday lunch', [Events.DTSTART]: utc('2026-10-07T10:00:00Z'), [Events.DTEND]: utc('2026-10-07T11:00:00Z') });
const action = patchOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx));
expect(Object.keys(action.patch)).toEqual(['recurrenceOverrides']);
expect(action.patch.recurrenceOverrides).toEqual({
'2026-10-07T12:00:00': { title: 'Tuesday lunch', start: '2026-10-07T12:00:00', duration: 'PT1H' },
});
});
it('uploads "delete this event" (a cancelled exception) as an exclusion and cleans up after', async () => {
const { h, id } = await synced(weekly());
h.fake.user.cancelInstance(id, utc('2026-10-14T13:00:00Z'), utc('2026-10-14T14:30:00Z'));
const local = (await h.local('bl'))!;
const plan = calendarPlanner.planUpload(local, h.ctx);
expect(patchOf(plan).patch).toEqual({ 'recurrenceOverrides/2026-10-14T09:00:00': { excluded: true } });
const server = await accept(h, weekly(), local, plan);
expect(h.masterRow('bl')[Events.EXDATE]).toBe('20261012T130000Z,20261014T130000Z');
const after = (await h.local('bl'))!;
expect(after.exceptions.map((x) => x.recurrenceId)).toEqual(['2026-10-07T09:00:00']);
expect(calendarPlanner.planDownload(server, after, h.ctx).effect).toBe('none');
});
it('excludes an instance whose synced exception row was deleted', async () => {
const { h } = await synced(weekly());
const x = (await h.local('bl'))!.exceptions[0];
h.fake.user.deleteEvent(x.eventId);
expect(h.row(x.eventId)).toMatchObject({ [Events.DELETED]: 1, [Events.DIRTY]: 1 });
const local = (await h.local('bl'))!;
const plan = calendarPlanner.planUpload(local, h.ctx);
expect(patchOf(plan).patch).toEqual({ 'recurrenceOverrides/2026-10-07T09:00:00': { excluded: true } });
await accept(h, weekly(), local, plan);
expect(h.row(x.eventId)).toBeUndefined();
});
it('does not make an occurrence an app left without a status (Google Calendar\'s "this event") tentative', async () => {
const event = single({ recurrenceRule: { frequency: 'weekly', count: 5 } });
const { h, id } = await synced(event);
h.fake.user.insertException(id, utc('2026-10-13T10:00:00Z'), {
[Events.TITLE]: 'Only this Tuesday', [Events.DTSTART]: utc('2026-10-13T10:00:00Z'), [Events.DTEND]: utc('2026-10-13T11:00:00Z'), [Events.STATUS]: null,
});
const local = (await h.local('e1'))!;
const plan = calendarPlanner.planUpload(local, h.ctx);
const overrides = patchOf(plan).patch.recurrenceOverrides as Record<string, Record<string, unknown>>;
expect(overrides['2026-10-13T12:00:00']).toEqual({ title: 'Only this Tuesday', start: '2026-10-13T12:00:00', duration: 'PT1H' });
await accept(h, event, local, plan);
const [x] = (await h.local('e1'))!.exceptions;
expect(x.cells).toMatchObject({ [Events.STATUS]: 1, [Events.DIRTY]: 0 });
});
it('clears an occurrence\'s own description and location instead of letting the series\' come back', async () => {
const event = weekly();
event.description = 'Daily agenda';
event.recurrenceOverrides!['2026-10-07T09:00:00'] = {
...event.recurrenceOverrides!['2026-10-07T09:00:00'],
description: 'Special agenda',
locations: { locB: { '@type': 'Location', name: 'Room 2' } },
};
const { h } = await synced(event);
const x = (await h.local('bl'))!.exceptions[0];
expect(h.row(x.eventId)).toMatchObject({ [Events.DESCRIPTION]: 'Special agenda', [Events.EVENT_LOCATION]: 'Room 2' });
h.fake.user.updateEvent(x.eventId, { [Events.DESCRIPTION]: null, [Events.EVENT_LOCATION]: null });
const local = (await h.local('bl'))!;
const plan = calendarPlanner.planUpload(local, h.ctx);
const server = await accept(h, event, local, plan);
expect(h.row(x.eventId)).toMatchObject({ [Events.DESCRIPTION]: null, [Events.EVENT_LOCATION]: null, [Events.DIRTY]: 0 });
expect(calendarPlanner.planDownload(server, (await h.local('bl'))!, h.ctx).effect).toBe('none');
// Without a value of its own the override would show the series' "Daily agenda" and "Room 1" again, so it
// gets the empty ones Stalwart keeps (an empty map would read back as no locations, i.e. the series' ones).
expect(patchOf(plan).patch).toEqual({
'recurrenceOverrides/2026-10-07T09:00:00/description': '',
'recurrenceOverrides/2026-10-07T09:00:00/locations': { locB: { '@type': 'Location', name: '' } },
});
});
it('removes an occurrence\'s own description and location when the series has none to show instead', async () => {
const event = weekly();
delete event.description;
delete event.locations;
event.recurrenceOverrides!['2026-10-07T09:00:00'] = {
...event.recurrenceOverrides!['2026-10-07T09:00:00'],
description: 'Special agenda',
locations: { locB: { '@type': 'Location', name: 'Room 2' } },
};
const { h } = await synced(event);
const x = (await h.local('bl'))!.exceptions[0];
h.fake.user.updateEvent(x.eventId, { [Events.DESCRIPTION]: null, [Events.EVENT_LOCATION]: null });
expect(patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx)).patch).toEqual({
'recurrenceOverrides/2026-10-07T09:00:00/description': null,
'recurrenceOverrides/2026-10-07T09:00:00/locations': null,
});
});
it('keeps a reminder changed on one occurrence of a series that uses the calendar\'s default alerts', async () => {
const event = weekly();
delete event.alerts;
event.useDefaultAlerts = true;
const { h, id } = await synced(event);
const minutes = (eventId: number) => h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [eventId]).map((r) => Number(r[Reminders.MINUTES]));
const x = (await h.local('bl'))!.exceptions[0];
// The calendar's default (10 minutes) on the series and on its occurrences.
expect([minutes(id), minutes(x.eventId)]).toEqual([[10], [10]]);
h.fake.user.setReminders(x.eventId, [{ minutes: 60 }]);
// A new occurrence edit with its own reminder, too (Etar "this event").
const added = h.fake.user.insertException(id, utc('2026-10-14T13:00:00Z'), {
[Events.TITLE]: 'Probe weekly', [Events.DTSTART]: utc('2026-10-14T13:00:00Z'), [Events.DTEND]: utc('2026-10-14T14:30:00Z'),
});
h.fake.user.setReminders(added, [{ minutes: 5 }]);
const local = (await h.local('bl'))!;
const plan = calendarPlanner.planUpload(local, h.ctx);
const server = await accept(h, event, local, plan);
const after = (await h.local('bl'))!;
expect(after.exceptions.map((e) => [e.recurrenceId, minutes(e.eventId), e.dirty])).toEqual([
['2026-10-07T09:00:00', [60], false],
['2026-10-14T09:00:00', [5], false],
]);
// The series and its other occurrences keep the calendar's defaults.
expect(server.useDefaultAlerts).toBe(true);
expect(minutes(id)).toEqual([10]);
expect(calendarPlanner.planDownload(server, after, h.ctx).effect).toBe('none');
// Only the occurrences leave the defaults: `useDefaultAlerts` is a property an override can patch.
const patch = patchOf(plan).patch;
expect(patch['recurrenceOverrides/2026-10-07T09:00:00/useDefaultAlerts']).toBe(false);
expect(patch['recurrenceOverrides/2026-10-14T09:00:00']).toMatchObject({ useDefaultAlerts: false });
});
it('clears an occurrence\'s own colour instead of letting the series\' come back', async () => {
const event = weekly();
event.recurrenceOverrides!['2026-10-07T09:00:00'] = { ...event.recurrenceOverrides!['2026-10-07T09:00:00'], color: 'red' };
const { h } = await synced(event);
const x = (await h.local('bl'))!.exceptions[0];
expect(h.row(x.eventId)[Events.EVENT_COLOR]).toBe(cssColorToArgb('red'));
h.fake.user.updateEvent(x.eventId, { [Events.EVENT_COLOR]: null });
const local = (await h.local('bl'))!;
const plan = calendarPlanner.planUpload(local, h.ctx);
// Without a colour of its own the occurrence would show the series' steelblue again: an empty one, which Stalwart keeps.
expect(patchOf(plan).patch).toEqual({ 'recurrenceOverrides/2026-10-07T09:00:00/color': '' });
const server = await accept(h, event, local, plan);
expect(h.row(x.eventId)).toMatchObject({ [Events.EVENT_COLOR]: null, [Events.DIRTY]: 0 });
expect(calendarPlanner.planDownload(server, (await h.local('bl'))!, h.ctx).effect).toBe('none');
// A series without a colour has none to show: the occurrence's own is removed.
const plain = weekly();
delete plain.color;
plain.recurrenceOverrides!['2026-10-07T09:00:00'] = { ...plain.recurrenceOverrides!['2026-10-07T09:00:00'], color: 'red' };
const p = await synced(plain);
const px = (await p.h.local('bl'))!.exceptions[0];
p.h.fake.user.updateEvent(px.eventId, { [Events.EVENT_COLOR]: null });
expect(patchOf(calendarPlanner.planUpload((await p.h.local('bl'))!, p.h.ctx)).patch).toEqual({ 'recurrenceOverrides/2026-10-07T09:00:00/color': null });
});
it('removes every reminder of one occurrence of a series on the calendar\'s defaults', async () => {
const event = weekly();
delete event.alerts;
event.useDefaultAlerts = true;
event.recurrenceOverrides!['2026-10-07T09:00:00'] = {
...event.recurrenceOverrides!['2026-10-07T09:00:00'],
useDefaultAlerts: false,
alerts: { a60: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT1H', relativeTo: 'start' }, action: 'display' } },
};
const { h } = await synced(event);
const x = (await h.local('bl'))!.exceptions[0];
h.fake.user.setReminders(x.eventId, []);
const local = (await h.local('bl'))!;
const plan = calendarPlanner.planUpload(local, h.ctx);
const server = await accept(h, event, local, plan);
// Stalwart drops the empty map, but the series has no alerts of its own to show instead.
expect(server.recurrenceOverrides!['2026-10-07T09:00:00']).toMatchObject({ useDefaultAlerts: false });
expect(server.recurrenceOverrides!['2026-10-07T09:00:00']).not.toHaveProperty('alerts');
expect(h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [x.eventId])).toEqual([]);
expect(h.row(x.eventId)[Events.DIRTY]).toBe(0);
expect(calendarPlanner.planDownload(server, (await h.local('bl'))!, h.ctx).effect).toBe('none');
});
it('puts back and reports the removal of every reminder of one occurrence whose series would show its own again', async () => {
const event = weekly();
event.alerts = { al1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' }, action: 'display' } };
const { h } = await synced(event);
const minutes = (eventId: number) => h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [eventId]).map((r) => Number(r[Reminders.MINUTES]));
const x = (await h.local('bl'))!.exceptions[0];
expect(minutes(x.eventId)).toEqual([15]);
h.fake.user.setReminders(x.eventId, []);
// An override without alerts shows the series' (Stalwart writes no VALARM for an empty map): none can't be stored.
const plan = calendarPlanner.planUpload((await h.local('bl'))!, h.ctx);
expect(plan).toMatchObject({ kind: 'revert', reason: 'remindersNotRepresentable' });
if (plan.kind === 'revert') await h.apply(plan.ops);
expect(minutes(x.eventId)).toEqual([15]);
expect(h.row(x.eventId)[Events.DIRTY]).toBe(0);
// With another edit, that one goes up alone.
h.fake.user.setReminders(x.eventId, []);
h.fake.user.updateEvent(x.eventId, { [Events.TITLE]: 'Moved again' });
expect(patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx)).patch).toEqual({ 'recurrenceOverrides/2026-10-07T09:00:00/title': 'Moved again' });
});
it('patches an existing override one level deep, never deeper', async () => {
const { h } = await synced(weekly());
const x = (await h.local('bl'))!.exceptions[0];
h.fake.user.updateEvent(x.eventId, { [Events.TITLE]: 'Moved again', [Events.EVENT_LOCATION]: 'Annex' });
const action = patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
expect(action.patch).toEqual({
'recurrenceOverrides/2026-10-07T09:00:00/title': 'Moved again',
'recurrenceOverrides/2026-10-07T09:00:00/locations': { locA: { name: 'Annex', '@type': 'Location' } },
});
});
it('answers one instance (CONTENT_EXCEPTION_URI) with the override\'s whole participants map', async () => {
const invite = weekly();
invite.organizerCalendarAddress = 'mailto:boss@example.net';
invite.participants = {
boss: { calendarAddress: 'mailto:boss@example.net', roles: { owner: true }, participationStatus: 'accepted' },
me: { calendarAddress: 'mailto:usera@example.org', roles: { attendee: true }, participationStatus: 'needs-action' },
other: { calendarAddress: 'mailto:other@example.net', roles: { attendee: true }, participationStatus: 'accepted' },
};
const { h, id } = await synced(invite);
h.fake.user.exceptionViaUri(id, utc('2026-10-19T13:00:00Z'), { [Events.SELF_ATTENDEE_STATUS]: 1, [Events.STATUS]: 1 });
const action = patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
const override = action.patch['recurrenceOverrides/2026-10-19T09:00:00'] as Record<string, unknown>;
expect(override.participants).toEqual({ ...invite.participants, me: { ...invite.participants!.me, participationStatus: 'accepted' } });
// Etar's STATUS_CONFIRMED on the instance is not the attendee's to upload: the series' status is kept.
expect(override).toMatchObject({ status: 'tentative', start: '2026-10-19T09:00:00', duration: 'PT1H30M', title: 'Probe weekly' });
for (const key of NO_FORBIDDEN) expect(override).not.toHaveProperty(key);
// An attendee's RSVP sends the REPLY.
expect(action.sendSchedulingMessages).toBe(true);
});
it('lets an answer to one occurrence through in a calendar that grants only RSVP, and nothing more', async () => {
const invite = weekly();
invite.calendarIds = { r: true };
invite.organizerCalendarAddress = 'mailto:boss@example.net';
invite.participants = {
boss: { calendarAddress: 'mailto:boss@example.net', roles: { owner: true }, participationStatus: 'accepted' },
me: { calendarAddress: 'mailto:usera@example.org', roles: { attendee: true }, participationStatus: 'needs-action' },
};
const { h, id } = await synced(invite);
// Etar answers Oct 19 through CONTENT_EXCEPTION_URI (a new override)...
const added = h.fake.user.exceptionViaUri(id, utc('2026-10-19T13:00:00Z'), { [Events.SELF_ATTENDEE_STATUS]: 2, [Events.STATUS]: 1 });
// ... and another app answers the existing override of Oct 7 on its row.
const existing = (await h.local('bl'))!.exceptions.find((x) => x.recurrenceId === '2026-10-07T09:00:00')!;
h.fake.table('attendees').forEach((a) => {
if (a.event_id === existing.eventId && a[Attendees.ATTENDEE_EMAIL] === ME) a[Attendees.ATTENDEE_STATUS] = 1;
});
h.fake.user.updateEvent(existing.eventId, {});
const action = patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
type Answer = { participants: Record<string, { participationStatus?: string }> };
expect((action.patch['recurrenceOverrides/2026-10-19T09:00:00'] as Answer).participants.me.participationStatus).toBe('declined');
expect((action.patch['recurrenceOverrides/2026-10-07T09:00:00/participants'] as Answer['participants']).me.participationStatus).toBe('accepted');
expect(action.sendSchedulingMessages).toBe(true);
// Anything more than the answer is still the calendar owner's to change.
h.fake.user.updateEvent(added, { [Events.TITLE]: 'Mine now' });
expect(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx)).toMatchObject({ kind: 'revert', reason: 'readOnly' });
});
it('turns EXDATE entries an app adds or removes into exclusions and re-inclusions', async () => {
const { h, id } = await synced(weekly());
h.fake.user.updateEvent(id, { [Events.EXDATE]: '20261012T130000Z\nAmerica/New_York;20261021T090000' });
expect(patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx)).patch).toEqual({
'recurrenceOverrides/2026-10-21T09:00:00': { excluded: true },
});
h.fake.user.updateEvent(id, { [Events.EXDATE]: null });
expect(patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx)).patch).toEqual({
'recurrenceOverrides/2026-10-12T09:00:00': null,
});
});
it('uploads Etar\'s "this and following" as a capped rule, prunes later overrides, and creates the new series', async () => {
const event = weekly();
event.recurrenceRule = { frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'we' }] };
event.recurrenceOverrides = {
'2026-10-07T09:00:00': { title: 'Moved Wednesday', start: '2026-10-07T11:00:00' },
'2026-10-21T09:00:00': { title: 'Later one' },
'2026-10-26T09:00:00': { excluded: true },
};
const { h, id } = await synced(event);
// Split at Mon 2026-10-19 09:00 New York: UNTIL = the instance minus one second, in UTC.
h.fake.user.updateEvent(id, { [Events.RRULE]: 'FREQ=WEEKLY;UNTIL=20261019T125959Z;BYDAY=MO,WE' });
const fresh = h.fake.user.insertEvent(h.rowIds.get('b')!, {
[Events.TITLE]: 'Probe weekly (new time)',
[Events.DTSTART]: utc('2026-10-19T14:00:00Z'),
[Events.DURATION]: 'P5400S',
[Events.RRULE]: 'FREQ=WEEKLY;BYDAY=MO,WE',
[Events.EVENT_TIMEZONE]: 'America/New_York',
[Events.STATUS]: 1,
});
const action = patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
expect(action.patch).toEqual({
recurrenceRule: { frequency: 'weekly', until: '2026-10-19T08:59:59', byDay: [{ day: 'mo' }, { day: 'we' }] },
'recurrenceOverrides/2026-10-21T09:00:00': null,
'recurrenceOverrides/2026-10-26T09:00:00': null,
});
// The new series is a new event: claimed with a fresh uid, then created.
const newRow = await h.byRow(fresh);
const claim = calendarPlanner.planUpload(newRow, h.ctx);
expect(claim.kind).toBe('claim');
if (claim.kind === 'claim') await h.apply(claim.ops);
const claimed = await h.byRow(fresh);
expect(claimed.syncId).toMatch(/^~pending\//);
expect(claimed.pending).toEqual({ uid: claimed.cells[Events.UID_2445], target: `${ACCT}/b` });
const create = calendarPlanner.planUpload(claimed, h.ctx);
expect(create.kind).toBe('upload');
if (create.kind !== 'upload' || create.actions[0].kind !== 'create') throw new Error('no create');
expect(create.actions[0]).toMatchObject({
kind: 'create',
uid: claimed.pending!.uid,
collectionId: 'b',
object: {
'@type': 'Event',
uid: claimed.pending!.uid,
calendarIds: { b: true },
title: 'Probe weekly (new time)',
start: '2026-10-19T10:00:00',
timeZone: 'America/New_York',
duration: 'PT1H30M',
recurrenceRule: { frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'we' }] },
},
});
});
it('prunes overrides after a series Etar capped with a lower COUNT, using the provider\'s LAST_DATE', async () => {
const { h, id } = await synced(weekly());
// COUNT 10 → 3 (Mon 5, Wed 7, Mon 12); the provider computed LAST_DATE = last start + duration.
h.fake.user.updateEvent(id, { [Events.RRULE]: 'FREQ=WEEKLY;COUNT=3;BYDAY=MO,WE' });
h.fake.table('events').get(id)![Events.LAST_DATE] = utc('2026-10-12T13:00:00Z') + 5_400_000;
const event = weekly();
event.recurrenceOverrides!['2026-10-14T09:00:00'] = { title: 'x' };
const local = (await h.local('bl'))!;
local.shadow = event;
expect(patchOf(calendarPlanner.planUpload(local, h.ctx)).patch).toEqual({
recurrenceRule: { frequency: 'weekly', count: 3, byDay: [{ day: 'mo' }, { day: 'we' }] },
'recurrenceOverrides/2026-10-14T09:00:00': null,
});
});
it('moves every recurrence id with an "all events" start change', async () => {
const { h, id } = await synced(weekly());
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-05T14:00:00Z') });
const action = patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
expect(action.patch).toEqual({
start: '2026-10-05T10:00:00',
recurrenceOverrides: {
'2026-10-12T10:00:00': { excluded: true },
// Its own start was a move elsewhere: it stays.
'2026-10-07T10:00:00': { updated: '2026-09-26T22:57:38Z', title: 'Moved Wednesday', start: '2026-10-07T11:00:00' },
},
});
});
it('resolves a CONTENT_EXCEPTION_URI split: the clone becomes a new event, the source uploads its capped rule', async () => {
const event = weekly();
event.recurrenceRule = { frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'we' }] };
event.recurrenceOverrides = { '2026-10-07T09:00:00': { title: 'Moved Wednesday', start: '2026-10-07T11:00:00' }, '2026-10-21T09:00:00': { title: 'Later' } };
const { h, id } = await synced(event);
const clone = h.fake.user.splitViaUri(id, utc('2026-10-19T13:00:00Z'), '20261019T125959Z', { [Events.TITLE]: 'New series' });
const all = await h.events();
const source = all.find((e) => e.eventId === id)!;
const cloneEvent = all.find((e) => e.eventId === clone)!;
expect([source.split, cloneEvent.split]).toEqual(['source', 'clone']);
expect(source.dirty).toBe(false);
// The source's rule was capped without DIRTY; it uploads as it stands.
expect(patchOf(calendarPlanner.planUpload(source, h.ctx)).patch).toEqual({
recurrenceRule: { frequency: 'weekly', until: '2026-10-19T08:59:59', byDay: [{ day: 'mo' }, { day: 'we' }] },
'recurrenceOverrides/2026-10-21T09:00:00': null,
});
// The clone is claimed under a fresh uid, drops the source's sync data and marks the source dirty.
const claim = calendarPlanner.planUpload(cloneEvent, h.ctx);
expect(claim.kind).toBe('claim');
if (claim.kind === 'claim') await h.apply(claim.ops);
const after = await h.events();
const claimed = after.find((e) => e.eventId === clone)!;
expect(claimed.syncId).toMatch(/^~pending\//);
expect(claimed.cells[Events.UID_2445]).not.toBe(event.uid);
expect(claimed.shadow).toBeNull();
expect(claimed.split).toBeUndefined();
const sourceAfter = after.find((e) => e.eventId === id)!;
expect(sourceAfter.dirty).toBe(true);
// The source keeps its exceptions (and their link) whatever the provider's trigger did.
expect(sourceAfter.exceptions.map((x) => x.cells[Events.ORIGINAL_SYNC_ID])).toEqual([`${ACCT}/bl`, `${ACCT}/bl`]);
expect(patchOf(calendarPlanner.planUpload(sourceAfter, h.ctx)).patch.recurrenceRule).toMatchObject({ until: '2026-10-19T08:59:59' });
});
it('keeps a local exception edit through a download that changed another instance', async () => {
const { h } = await synced(weekly());
const x = (await h.local('bl'))!.exceptions[0];
h.fake.user.updateEvent(x.eventId, { [Events.TITLE]: 'Local title' });
const remote = weekly();
remote.recurrenceOverrides!['2026-10-19T09:00:00'] = { title: 'Remote added' };
const plan = await h.download(remote);
expect(plan).toMatchObject({ conflicts: 0, stillDirty: true });
const after = (await h.local('bl'))!;
expect(after.exceptions.map((e) => [e.recurrenceId, e.cells[Events.TITLE], e.dirty])).toEqual([
['2026-10-07T09:00:00', 'Local title', true],
['2026-10-19T09:00:00', 'Remote added', false],
]);
expect(patchOf(calendarPlanner.planUpload(after, h.ctx)).patch).toEqual({ 'recurrenceOverrides/2026-10-07T09:00:00/title': 'Local title' });
});
it('lets a server removal of an override win over a local edit of it', async () => {
const { h } = await synced(weekly());
const x = (await h.local('bl'))!.exceptions[0];
h.fake.user.updateEvent(x.eventId, { [Events.TITLE]: 'Local title' });
const remote = weekly();
delete remote.recurrenceOverrides!['2026-10-07T09:00:00'];
const plan = await h.download(remote);
expect(plan.conflicts).toBe(1);
expect(h.row(x.eventId)).toBeUndefined();
});
it('lets a device deletion of an instance win over a server edit of it', async () => {
const { h } = await synced(weekly());
const x = (await h.local('bl'))!.exceptions[0];
h.fake.user.deleteEvent(x.eventId);
const remote = weekly();
remote.recurrenceOverrides!['2026-10-07T09:00:00'] = { title: 'Remote title', start: '2026-10-07T11:00:00' };
const plan = await h.download(remote);
expect(plan).toMatchObject({ conflicts: 0, stillDirty: true });
expect(patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx)).patch).toEqual({
'recurrenceOverrides/2026-10-07T09:00:00': { excluded: true },
});
});
it('writes an all-day series\' exceptions at UTC midnight and keys them by date', async () => {
const event = single({
id: 'ad',
start: '2026-10-05T00:00:00',
timeZone: null,
showWithoutTime: true,
duration: 'P1D',
recurrenceRule: { frequency: 'daily', count: 5 },
recurrenceOverrides: { '2026-10-06T00:00:00': { title: 'Day two' }, '2026-10-07T00:00:00': { excluded: true } },
});
const { h, id } = await synced(event);
expect(h.row(id)).toMatchObject({ [Events.EXDATE]: '20261007', [Events.DURATION]: 'P1D' });
const [x] = (await h.local('ad'))!.exceptions;
expect(x.cells).toMatchObject({ [Events.ORIGINAL_INSTANCE_TIME]: utc('2026-10-06T00:00:00Z'), [Events.ORIGINAL_ALL_DAY]: 1, [Events.DTSTART]: utc('2026-10-06T00:00:00Z'), [Events.DTEND]: utc('2026-10-07T00:00:00Z') });
// Etar cancels day four.
h.fake.user.cancelInstance(id, utc('2026-10-08T00:00:00Z'), utc('2026-10-09T00:00:00Z'));
expect(patchOf(calendarPlanner.planUpload((await h.local('ad'))!, h.ctx)).patch).toEqual({ 'recurrenceOverrides/2026-10-08T00:00:00': { excluded: true } });
});
it('skips a series whose instance to change is a repeated wall time', async () => {
const event = single({ id: 'n', start: '2026-10-20T02:30:00', recurrenceRule: { frequency: 'daily' } });
const { h, id } = await synced(event);
// Etar "this event" on the 25th: 02:30 happens twice that night in Berlin.
h.fake.user.insertException(id, utc('2026-10-25T00:30:00Z'), { [Events.TITLE]: 'x', [Events.DTSTART]: utc('2026-10-25T00:30:00Z'), [Events.DTEND]: utc('2026-10-25T01:30:00Z') });
expect(calendarPlanner.planUpload((await h.local('n'))!, h.ctx)).toEqual({ kind: 'skip', reason: 'dstAmbiguous' });
});
it('moves an UNTIL that falls into a DST gap or overlap one hour later', async () => {
const { h, id } = await synced(single({ recurrenceRule: { frequency: 'daily' } }));
// Until 2026-10-25 00:30Z = 02:30 CEST, a repeated wall time Stalwart would drop.
h.fake.user.updateEvent(id, { [Events.RRULE]: 'FREQ=DAILY;UNTIL=20261025T003000Z' });
expect(patchOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({
recurrenceRule: { frequency: 'daily', until: '2026-10-25T03:30:00' },
});
});
it('keeps the organizer and other attendees on the instance a pointer RSVP would have dropped', async () => {
const { h, id } = await synced(weekly());
const x = (await h.local('bl'))!.exceptions[0];
h.fake.table('attendees').forEach((a) => {
if (a.event_id === x.eventId && a[Attendees.ATTENDEE_EMAIL] === ME) a[Attendees.ATTENDEE_STATUS] = 2;
});
h.fake.user.updateEvent(x.eventId, {});
const action = patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
expect(Object.keys(action.patch)).toEqual(['recurrenceOverrides/2026-10-07T09:00:00/participants']);
expect(Object.keys(action.patch['recurrenceOverrides/2026-10-07T09:00:00/participants'] as object)).toEqual(['me1', 'guestX']);
expect(id).toBeGreaterThan(0);
});
});
@@ -0,0 +1,240 @@
/**
* Test harness for the calendar planner: the fake CalendarProvider, a
* CalendarContext over a few server calendars, and helpers that plan, apply
* the op groups the way the engine does and read the rows back.
*/
import { expect } from 'vitest';
import { FakeDeviceProviders } from '../fakes/fake-provider';
import { CALENDAR_AUTHORITY, type ProviderPort, type Row } from '../../types';
import type { CalendarContext, LocalCalendar, LocalEvent, OpGroup } from '../../planner';
import type { CalendarEventWire, CalendarLike } from '../../wire';
import { calendarPlanner } from '../../calendar/planner';
import { applyPatch, type PatchObject } from '../../common/patch';
import { makeKeyMinter, parseCollectionKey, uuidFrom } from '../../common/ids';
import { Events } from '../../android-columns';
export const ACCOUNT = 'usera@example.org';
export const ME = 'usera@example.org';
export const ALIAS = 'alias@example.org';
export const ACCT = 'c';
export function seeded(seed: number) {
return () => {
seed = (seed * 1103515245 + 12345) % 2147483648;
return seed / 2147483648;
};
}
export const CALENDARS: Record<string, CalendarLike> = {
b: {
id: 'b',
name: 'Personal',
color: '#3b82f6',
myRights: { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayRSVP: true, mayDelete: true },
defaultAlertsWithTime: { d1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT10M' }, action: 'display' } },
defaultAlertsWithoutTime: { d2: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15H' }, action: 'display' } },
} as CalendarLike,
w: { id: 'w', name: 'Work', color: '#22c55e', myRights: { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayRSVP: true } } as CalendarLike,
r: { id: 'r', name: 'Team (read)', myRights: { mayReadItems: true, mayRSVP: true } } as CalendarLike,
};
export interface HarnessOptions {
deviceZone?: string;
reminderOwner?: 'device' | 'bulwark';
readOnly?: string[];
now?: number;
}
export class Harness {
readonly fake = new FakeDeviceProviders();
readonly port: ProviderPort = this.fake.port(ACCOUNT, CALENDAR_AUTHORITY);
readonly rowIds = new Map<string, number>();
ctx: CalendarContext;
constructor(options: HarnessOptions = {}) {
const random = seeded(7);
const readOnly = new Set(options.readOnly ?? ['r']);
this.ctx = {
jmapAccountId: ACCT,
now: options.now ?? Date.parse('2026-09-27T12:00:00Z'),
mintKey: makeKeyMinter(random),
mintUid: () => uuidFrom(random),
deviceZone: options.deviceZone ?? 'Europe/Berlin',
ownerAccount: ME,
selfAddresses: [ME, ALIAS],
reminderOwner: options.reminderOwner ?? 'device',
calendar: (id) => CALENDARS[id],
calendarRowId: (id) => this.rowIds.get(id) ?? null,
calendarIdOfRow: (rowId) => {
for (const [id, row] of this.rowIds) if (row === rowId) return { jmapAccountId: ACCT, calendarId: id };
return null;
},
isReadOnly: (id) => readOnly.has(id),
isSelected: (key) => parseCollectionKey(key)?.accountId === ACCT,
};
}
/** Inserts the calendar rows through planCalendars. */
async setup(ids: string[] = ['b', 'w', 'r']): Promise<this> {
const plan = calendarPlanner.planCalendars(
ids.map((id) => ({ jmapAccountId: ACCT, calendar: CALENDARS[id], readOnly: this.ctx.isReadOnly(id), accountName: ME })),
[],
this.ctx,
);
for (const group of plan.groups) await this.apply(group);
for (const cal of await this.calendars()) this.rowIds.set(parseCollectionKey(cal.syncId)!.id, cal.calendarRowId);
return this;
}
/** Applies a group and the groups after it (`next`), each in a batch of its own, as the engine does. */
async apply(group: OpGroup): Promise<void> {
for (const g of [group, ...(group.next ?? [])]) {
const result = await this.port.applyBatch(g.ops);
if (!result.ok) throw new Error(`batch ${group.ref} failed: ${result.reason} ${result.message}`);
}
}
async tryApply(group: OpGroup) {
return this.port.applyBatch(group.ops);
}
async query(table: 'events' | 'attendees' | 'reminders' | 'calendars', columns: readonly string[]): Promise<Row[]> {
const res = await this.port.query({ table, columns: [...columns] });
return res.rows.map((cells) => Object.fromEntries(res.columns.map((c, i) => [c, cells[i]])));
}
async calendars(): Promise<LocalCalendar[]> {
return (await this.query('calendars', calendarPlanner.calendarColumns)).map(calendarPlanner.decodeCalendar);
}
async events(): Promise<LocalEvent[]> {
return calendarPlanner.decodeEvents(
await this.query('events', calendarPlanner.eventColumns),
await this.query('attendees', calendarPlanner.attendeeColumns),
await this.query('reminders', calendarPlanner.reminderColumns),
);
}
/** The local event with this server id (or null). */
async local(id: string): Promise<LocalEvent | null> {
return (await this.events()).find((e) => e.syncId === `${ACCT}/${id}`) ?? null;
}
async byRow(eventId: number): Promise<LocalEvent> {
const all = await this.events();
const found = all.find((e) => e.eventId === eventId);
if (!found) throw new Error(`no event row ${eventId}`);
return found;
}
/** Plans and applies a download of `event`, then heals baselines like the engine's read-back. */
async download(event: CalendarEventWire) {
const local = await this.local(event.id);
const plan = calendarPlanner.planDownload(event, local, this.ctx);
if (plan.effect !== 'none') await this.apply(plan.ops);
await this.heal(event.id);
return plan;
}
async heal(id: string): Promise<void> {
const local = await this.local(id);
if (!local) return;
const heal = calendarPlanner.planBaselineHeal(local);
if (heal) await this.apply(heal);
}
row(eventId: number): Row {
return this.fake.row('events', eventId)!;
}
masterRow(id: string): Row {
const rows = this.fake.rows('events', `${Events._SYNC_ID} = ?`, [`${ACCT}/${id}`]);
expect(rows).toHaveLength(1);
return rows[0];
}
}
/** The server applying an accepted patch (Stalwart's normalisation of what matters here). */
export function serverApply(event: CalendarEventWire, patch: PatchObject): CalendarEventWire {
const next = applyPatch(event, patch);
if (!next) throw new Error(`patch does not apply: ${JSON.stringify(patch)}`);
const overrides = next.recurrenceOverrides;
if (overrides) {
for (const [key, value] of Object.entries(overrides)) {
if (!value || typeof value !== 'object') continue;
if ((value as Record<string, unknown>).excluded === true) {
overrides[key] = { excluded: true };
continue;
}
for (const forbidden of ['@type', 'method', 'organizerCalendarAddress', 'privacy', 'prodId', 'recurrenceId', 'recurrenceIdTimeZone', 'sentBy', 'uid', 'recurrenceRule', 'recurrenceOverrides']) {
delete (value as Record<string, unknown>)[forbidden];
}
// An override is stored as its own VEVENT: an empty map writes nothing, so it reads back without it.
for (const map of ['participants', 'alerts', 'locations']) {
const inner = (value as Record<string, unknown>)[map];
if (inner && typeof inner === 'object' && !Object.keys(inner).length) delete (value as Record<string, unknown>)[map];
}
}
if (!Object.keys(overrides).length) delete next.recurrenceOverrides;
}
for (const map of ['participants', 'alerts', 'locations'] as const) {
if (next[map] && !Object.keys(next[map] as object).length) delete next[map];
}
return next;
}
/** The probe's weekly event as Stalwart returned it (scratchpad/probe/calendar.out.txt). */
export function weekly(): CalendarEventWire {
return {
id: 'bl',
'@type': 'Event',
uid: 'probe-rec@example.org',
calendarIds: { b: true },
title: 'Probe weekly',
description: 'desc',
start: '2026-10-05T09:00:00',
timeZone: 'America/New_York',
duration: 'PT1H30M',
status: 'tentative',
freeBusyStatus: 'free',
privacy: 'private',
color: 'steelblue',
keywords: { probe: true },
priority: 5,
recurrenceRule: { frequency: 'weekly', count: 10, firstDayOfWeek: 'mo', byDay: [{ day: 'mo' }, { day: 'we' }] },
recurrenceOverrides: {
'2026-10-12T09:00:00': { excluded: true },
'2026-10-07T09:00:00': { updated: '2026-09-26T22:57:38Z', title: 'Moved Wednesday', start: '2026-10-07T11:00:00' },
},
locations: { locA: { name: 'Room 1', '@type': 'Location' } },
participants: {
me1: { calendarAddress: 'mailto:usera@example.org', '@type': 'Participant', participationStatus: 'accepted', roles: { owner: true, attendee: true } },
guestX: { calendarAddress: 'mailto:guest@example.net', '@type': 'Participant', participationStatus: 'needs-action', name: 'Guest', expectReply: true, roles: { attendee: true } },
},
organizerCalendarAddress: 'mailto:usera@example.org',
alerts: {
al1: { action: 'display', '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' } },
alEnd: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: 'PT5M', relativeTo: 'end' } },
alAbs: { '@type': 'Alert', action: 'email', trigger: { '@type': 'AbsoluteTrigger', when: '2026-10-01T08:00:00Z' } },
},
updated: '2026-09-26T22:57:38Z',
isDraft: false,
} as CalendarEventWire;
}
export function single(extra: Partial<CalendarEventWire> = {}): CalendarEventWire {
return {
id: 'e1',
'@type': 'Event',
uid: 'single-1@example.org',
calendarIds: { b: true },
title: 'Lunch',
start: '2026-10-06T12:00:00',
timeZone: 'Europe/Berlin',
duration: 'PT1H',
updated: '2026-09-26T10:00:00Z',
...extra,
} as CalendarEventWire;
}
export const utc = (iso: string) => Date.parse(iso);
@@ -0,0 +1,694 @@
import { describe, expect, it } from 'vitest';
import { Attendees, Events, Reminders } from '../../android-columns';
import { calendarPlanner } from '../../calendar/planner';
import type { LocalEvent, UploadPlan } from '../../planner';
import type { CalendarEventWire } from '../../wire';
import { ACCT, Harness, ME, serverApply, single, utc, weekly } from './harness';
type Plan = UploadPlan<CalendarEventWire>;
async function synced(event: CalendarEventWire, options = {}) {
const h = await new Harness(options).setup();
await h.download(event);
return { h, id: Number(h.masterRow(event.id)._id) };
}
function actionsOf(plan: Plan) {
if (plan.kind !== 'upload') throw new Error(`expected an upload, got ${JSON.stringify(plan)}`);
return plan.actions;
}
/** Claims a new row, then returns its create action. */
async function claimAndCreate(h: Harness, eventId: number) {
const claim = calendarPlanner.planUpload(await h.byRow(eventId), h.ctx);
expect(claim.kind).toBe('claim');
if (claim.kind === 'claim') await h.apply(claim.ops);
const local = await h.byRow(eventId);
const [create] = actionsOf(calendarPlanner.planUpload(local, h.ctx));
if (create.kind !== 'create') throw new Error('not a create');
return { local, create };
}
describe('calendar planner: new events', () => {
it('claims, creates and adopts a device event without writing anything on its echo', async () => {
const h = await new Harness().setup();
const id = h.fake.user.insertEvent(
h.rowIds.get('b')!,
{ [Events.TITLE]: 'Dentist', [Events.DTSTART]: utc('2026-10-09T08:00:00Z'), [Events.DTEND]: utc('2026-10-09T09:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.EVENT_LOCATION]: 'Main St 1', [Events.STATUS]: 1, [Events.ACCESS_LEVEL]: 2, [Events.AVAILABILITY]: 0 },
{ reminders: [{ [Reminders.MINUTES]: 30, [Reminders.METHOD]: 1 }] },
);
const { local, create } = await claimAndCreate(h, id);
expect(local.syncId).toBe(`~pending/${create.uid}`);
expect(create).toMatchObject({ collectionId: 'b', uid: local.pending!.uid });
expect(create.sendSchedulingMessages).toBeUndefined();
const object = create.object as Record<string, unknown>;
expect(object).toMatchObject({
'@type': 'Event',
uid: create.uid,
calendarIds: { b: true },
title: 'Dentist',
start: '2026-10-09T10:00:00',
duration: 'PT1H',
timeZone: 'Europe/Berlin',
status: 'confirmed',
freeBusyStatus: 'busy',
privacy: 'private',
});
expect(Object.values(object.locations as object)).toEqual([{ '@type': 'Location', name: 'Main St 1' }]);
expect(Object.values(object.alerts as object)).toEqual([{ '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT30M', relativeTo: 'start' }, action: 'display' }]);
// The server stores it; the engine re-fetches it and applies the accepted plan.
const server = { ...(object as CalendarEventWire), id: 'n1', updated: '2026-09-27T12:00:00Z' } as CalendarEventWire;
const accepted = calendarPlanner.planAccepted(local, server, h.ctx);
await h.apply(accepted.ops);
const after = (await h.local('n1'))!;
expect(after).toMatchObject({ syncId: `${ACCT}/n1`, dirty: false, pending: null });
expect(after.cells[Events.UID_2445]).toBe(create.uid);
expect(calendarPlanner.planDownload(server, after, h.ctx).effect).toBe('none');
});
it('creates a new series with the exceptions and exclusions made before its upload', async () => {
const h = await new Harness().setup();
const id = h.fake.user.insertEvent(h.rowIds.get('b')!, {
[Events.TITLE]: 'Standup',
[Events.DTSTART]: utc('2026-10-05T07:00:00Z'),
[Events.DURATION]: 'P900S',
[Events.RRULE]: 'FREQ=DAILY;COUNT=5',
[Events.EVENT_TIMEZONE]: 'Europe/Berlin',
[Events.STATUS]: 1,
});
// Claimed first (the pending _SYNC_ID lets Etar offer "this event").
const claim = calendarPlanner.planUpload(await h.byRow(id), h.ctx);
if (claim.kind === 'claim') await h.apply(claim.ops);
h.fake.user.insertException(id, utc('2026-10-06T07:00:00Z'), { [Events.TITLE]: 'Standup (late)', [Events.DTSTART]: utc('2026-10-06T08:00:00Z'), [Events.DTEND]: utc('2026-10-06T08:15:00Z') });
h.fake.user.cancelInstance(id, utc('2026-10-07T07:00:00Z'), utc('2026-10-07T07:15:00Z'));
const local = await h.byRow(id);
expect(local.exceptions).toHaveLength(2);
const [create] = actionsOf(calendarPlanner.planUpload(local, h.ctx));
if (create.kind !== 'create') throw new Error('not a create');
expect(create.object.recurrenceRule).toEqual({ frequency: 'daily', count: 5 });
expect(create.object.recurrenceOverrides).toEqual({
// The series' details a new override needs come along (withNewOverrideDetails).
'2026-10-06T09:00:00': { title: 'Standup (late)', start: '2026-10-06T10:00:00', duration: 'PT15M', status: 'confirmed', freeBusyStatus: 'busy' },
'2026-10-07T09:00:00': { excluded: true },
});
const server = { ...(create.object as CalendarEventWire), id: 's1' } as CalendarEventWire;
await h.apply(calendarPlanner.planAccepted(local, server, h.ctx).ops);
const after = (await h.local('s1'))!;
expect(after.exceptions.map((x) => [x.syncId, x.dirty])).toEqual([[`${ACCT}/s1#2026-10-06T09:00:00`, false]]);
expect(h.masterRow('s1')[Events.EXDATE]).toBe('20261007T070000Z');
expect(calendarPlanner.planDownload(server, after, h.ctx).effect).toBe('none');
});
it('creates an event an app left without a status (Google Calendar) without making it tentative', async () => {
const h = await new Harness().setup();
// CalendarProvider has no default for STATUS: an app that does not write it leaves it NULL.
const id = h.fake.user.insertEvent(h.rowIds.get('b')!, {
[Events.TITLE]: 'From Google Calendar', [Events.DTSTART]: utc('2026-10-14T15:00:00Z'), [Events.DTEND]: utc('2026-10-14T16:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.STATUS]: null,
});
const { local, create } = await claimAndCreate(h, id);
expect(create.object).not.toHaveProperty('status');
const server = { ...(create.object as CalendarEventWire), id: 'g1' } as CalendarEventWire;
await h.apply(calendarPlanner.planAccepted(local, server, h.ctx).ops);
// Written as JSCalendar's default, confirmed.
expect(h.row(id)).toMatchObject({ [Events.STATUS]: 1, [Events.DIRTY]: 0 });
expect(calendarPlanner.planDownload(server, (await h.local('g1'))!, h.ctx).effect).toBe('none');
});
it('leaves out exclusions a new series inherited from before its start (Etar\'s split copies the EXDATE)', async () => {
const h = await new Harness().setup();
const id = h.fake.user.insertEvent(h.rowIds.get('b')!, {
[Events.TITLE]: 'Weekly (new time)',
[Events.DTSTART]: utc('2026-11-16T09:00:00Z'),
[Events.DURATION]: 'P3600S',
[Events.RRULE]: 'FREQ=WEEKLY;COUNT=3;BYDAY=MO',
[Events.EVENT_TIMEZONE]: 'Europe/Berlin',
// The old series' exclusions (Oct 12, Nov 2) and one of this series (Nov 23).
[Events.EXDATE]: '20261012T060000Z,20261102T070000Z,20261123T090000Z',
[Events.STATUS]: 1,
});
const { create } = await claimAndCreate(h, id);
expect(create.object.recurrenceOverrides).toEqual({ '2026-11-23T10:00:00': { excluded: true } });
});
it('invites the attendees of a new event as its organizer', async () => {
const h = await new Harness().setup();
const id = h.fake.user.insertEvent(
h.rowIds.get('b')!,
{ [Events.TITLE]: 'Review', [Events.DTSTART]: utc('2026-10-09T08:00:00Z'), [Events.DTEND]: utc('2026-10-09T09:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.STATUS]: 1 },
{
attendees: [
{ [Attendees.ATTENDEE_EMAIL]: ME, [Attendees.ATTENDEE_RELATIONSHIP]: 2, [Attendees.ATTENDEE_TYPE]: 1, [Attendees.ATTENDEE_STATUS]: 1 },
{ [Attendees.ATTENDEE_EMAIL]: 'guest@example.net', [Attendees.ATTENDEE_NAME]: 'Guest', [Attendees.ATTENDEE_RELATIONSHIP]: 1, [Attendees.ATTENDEE_TYPE]: 1, [Attendees.ATTENDEE_STATUS]: 3 },
],
},
);
const { create } = await claimAndCreate(h, id);
expect(create.sendSchedulingMessages).toBe(true);
const participants = Object.values(create.object.participants ?? {});
expect(participants).toEqual([
{ '@type': 'Participant', calendarAddress: `mailto:${ME}`, roles: { owner: true, attendee: true }, participationStatus: 'accepted' },
{ '@type': 'Participant', calendarAddress: 'mailto:guest@example.net', name: 'Guest', roles: { attendee: true }, participationStatus: 'needs-action', expectReply: true },
]);
expect(create.object.organizerCalendarAddress).toBe(`mailto:${ME}`);
});
it('gives a new event carrying another row\'s uid a fresh one', async () => {
const { h } = await synced(single());
const copy = h.fake.user.insertEvent(h.rowIds.get('b')!, {
[Events.TITLE]: 'Copy', [Events.DTSTART]: utc('2026-10-09T08:00:00Z'), [Events.DTEND]: utc('2026-10-09T09:00:00Z'), [Events.EVENT_TIMEZONE]: 'UTC', [Events.UID_2445]: 'single-1@example.org', [Events.STATUS]: 1,
});
const local = await h.byRow(copy);
expect(local.split).toBe('clone');
const { create } = await claimAndCreate(h, copy);
expect(create.uid).not.toBe('single-1@example.org');
// A uid nobody else carries is kept (an imported invitation keeps its UID).
const own = h.fake.user.insertEvent(h.rowIds.get('b')!, {
[Events.TITLE]: 'Imported', [Events.DTSTART]: utc('2026-10-09T08:00:00Z'), [Events.DTEND]: utc('2026-10-09T09:00:00Z'), [Events.EVENT_TIMEZONE]: 'UTC', [Events.UID_2445]: 'ics-uid@example.net', [Events.STATUS]: 1,
});
expect((await claimAndCreate(h, own)).create.uid).toBe('ics-uid@example.net');
});
it('adopts its own create found by uid on download, keeping later local edits', async () => {
const h = await new Harness().setup();
const id = h.fake.user.insertEvent(h.rowIds.get('b')!, { [Events.TITLE]: 'Draft', [Events.DTSTART]: utc('2026-10-09T08:00:00Z'), [Events.DTEND]: utc('2026-10-09T09:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.STATUS]: 1 });
const { create } = await claimAndCreate(h, id);
// The create reached the server, but the response was lost; meanwhile the user renamed it.
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Final' });
const server = { ...(create.object as CalendarEventWire), id: 'n7' } as CalendarEventWire;
const plan = calendarPlanner.planDownload(server, await h.byRow(id), h.ctx);
expect(plan).toMatchObject({ effect: 'update', stillDirty: true, conflicts: 0 });
await h.apply(plan.ops);
const adopted = (await h.local('n7'))!;
expect(adopted).toMatchObject({ eventId: id, dirty: true, pending: null });
const [update] = actionsOf(calendarPlanner.planUpload(adopted, h.ctx));
expect(update).toMatchObject({ kind: 'update', id: 'n7', patch: { title: 'Final' } });
});
it('removes a new event created in a read-only calendar', async () => {
const h = await new Harness().setup();
const id = h.fake.user.insertEvent(h.rowIds.get('r')!, { [Events.TITLE]: 'x', [Events.DTSTART]: 0, [Events.DTEND]: 1000, [Events.EVENT_TIMEZONE]: 'UTC', [Events.STATUS]: 1 });
const plan = calendarPlanner.planUpload(await h.byRow(id), h.ctx);
expect(plan).toMatchObject({ kind: 'revert', reason: 'readOnly' });
if (plan.kind === 'revert') await h.apply(plan.ops);
expect(h.row(id)).toBeUndefined();
});
});
describe('calendar planner: accepted uploads, merges and deletions', () => {
it('keeps a row edited again during its upload dirty, with the uploaded value as its baseline', async () => {
const { h, id } = await synced(single());
h.fake.user.updateEvent(id, { [Events.TITLE]: 'First' });
const local = (await h.local('e1'))!;
const [update] = actionsOf(calendarPlanner.planUpload(local, h.ctx));
if (update.kind !== 'update') throw new Error();
const server = serverApply(single(), update.patch);
const accepted = calendarPlanner.planAccepted(local, server, h.ctx);
// The user edits again before the clearing batch: its assert fails.
h.fake.beforeNextBatch(() => h.fake.user.updateEvent(id, { [Events.TITLE]: 'Second' }));
expect((await h.tryApply(accepted.ops)).ok).toBe(false);
await h.apply(accepted.keepDirtyOps);
const after = (await h.local('e1'))!;
expect(after).toMatchObject({ dirty: true });
expect(after.cells[Events.TITLE]).toBe('Second');
expect(after.shadow?.title).toBe('First');
const [next] = actionsOf(calendarPlanner.planUpload(after, h.ctx));
expect(next).toMatchObject({ patch: { title: 'Second' } });
// A revert to the old value uploads too.
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Lunch' });
expect(actionsOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx))[0]).toMatchObject({ patch: { title: 'Lunch' } });
});
it('purges a cancelled instance whose exclusion was uploaded even when the series was edited meanwhile', async () => {
const { h, id } = await synced(weekly());
const cancelled = h.fake.user.cancelInstance(id, utc('2026-10-19T13:00:00Z'), utc('2026-10-19T14:30:00Z'));
const local = (await h.local('bl'))!;
const [update] = actionsOf(calendarPlanner.planUpload(local, h.ctx));
if (update.kind !== 'update') throw new Error();
const server = serverApply(weekly(), update.patch);
const accepted = calendarPlanner.planAccepted(local, server, h.ctx);
h.fake.beforeNextBatch(() => h.fake.user.updateEvent(id, { [Events.TITLE]: 'Edited meanwhile' }));
expect((await h.tryApply(accepted.ops)).ok).toBe(false);
await h.apply(accepted.keepDirtyOps);
expect(h.row(cancelled)).toBeUndefined();
const after = (await h.local('bl'))!;
const [next] = actionsOf(calendarPlanner.planUpload(after, h.ctx));
// Only the newer edit: the exclusion is on the server already and stays one.
expect(next).toMatchObject({ kind: 'update', patch: { title: 'Edited meanwhile' } });
});
it('fails a download write when the user edits the event meanwhile', async () => {
const { h, id } = await synced(weekly());
const plan = calendarPlanner.planDownload({ ...weekly(), title: 'Server' }, await h.local('bl'), h.ctx);
h.fake.beforeNextBatch(() => h.fake.user.updateEvent(id, { [Events.DESCRIPTION]: 'mine' }));
const result = await h.tryApply(plan.ops);
expect(result).toMatchObject({ ok: false, reason: 'assert' });
expect(h.row(id)[Events.TITLE]).toBe('Probe weekly');
});
it('merges per unit: local and server changes to different fields both survive', async () => {
const { h, id } = await synced(weekly());
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Local title' });
const plan = await h.download({ ...weekly(), description: 'Server description' });
expect(plan).toMatchObject({ conflicts: 0, stillDirty: true });
expect(h.row(id)).toMatchObject({ [Events.TITLE]: 'Local title', [Events.DESCRIPTION]: 'Server description', [Events.DIRTY]: 1 });
expect(actionsOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx))[0]).toMatchObject({ patch: { title: 'Local title' } });
});
it('lets the server win a real conflict and counts it', async () => {
const { h, id } = await synced(weekly());
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Local title' });
const plan = await h.download({ ...weekly(), title: 'Server title' });
expect(plan).toMatchObject({ conflicts: 1, stillDirty: false });
expect(h.row(id)).toMatchObject({ [Events.TITLE]: 'Server title', [Events.DIRTY]: 0 });
});
it('never reads a status an app left NULL as an edit, in uploads or merges', async () => {
const { h, id } = await synced(weekly());
expect(h.row(id)[Events.STATUS]).toBe(0);
// A row an app wrote without STATUS (only an insert can leave it NULL), then edited.
h.fake.table('events').get(id)![Events.STATUS] = null;
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Renamed' });
expect(actionsOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx))[0]).toMatchObject({ kind: 'update', patch: { title: 'Renamed' } });
expect(Object.keys((actionsOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx))[0] as { patch: object }).patch)).toEqual(['title']);
// The server's status change meanwhile is no conflict: the row takes it, the title stays for the upload.
const plan = await h.download({ ...weekly(), status: 'cancelled' });
expect(plan).toMatchObject({ conflicts: 0, stillDirty: true });
expect(h.row(id)).toMatchObject({ [Events.STATUS]: 2, [Events.TITLE]: 'Renamed', [Events.DIRTY]: 1 });
});
it('treats the same change on both sides as converged', async () => {
const { h, id } = await synced(weekly());
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Same' });
const plan = await h.download({ ...weekly(), title: 'Same' });
expect(plan).toMatchObject({ conflicts: 0, stillDirty: false });
expect(h.row(id)[Events.DIRTY]).toBe(0);
});
it('merges attendees by address and keeps the user\'s RSVP', async () => {
const invite = single({
organizerCalendarAddress: 'mailto:boss@example.net',
participants: {
boss: { calendarAddress: 'mailto:boss@example.net', roles: { owner: true } },
me: { calendarAddress: 'mailto:usera@example.org', roles: { attendee: true }, participationStatus: 'needs-action' },
},
} as Partial<CalendarEventWire>);
const { h, id } = await synced(invite);
h.fake.user.setAttendeeStatus(id, ME, 1);
const remote = { ...invite, participants: { ...invite.participants, new: { calendarAddress: 'mailto:new@example.net', roles: { attendee: true } } } } as CalendarEventWire;
const plan = await h.download(remote);
expect(plan).toMatchObject({ conflicts: 0, stillDirty: true });
const rows = h.fake.rows('attendees', `${Attendees.EVENT_ID} = ?`, [id]);
expect(rows.map((a) => [a[Attendees.ATTENDEE_EMAIL], a[Attendees.ATTENDEE_STATUS]])).toEqual([
['boss@example.net', 3],
[ME, 1],
['new@example.net', 3],
]);
expect(actionsOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx))[0]).toMatchObject({
patch: { 'participants/me/participationStatus': 'accepted' },
sendSchedulingMessages: true,
});
});
it('destroys an event deleted on the device, with a CANCEL when others take part', async () => {
const { h, id } = await synced(weekly());
h.fake.user.deleteEvent(id);
expect(actionsOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx))).toEqual([
{ kind: 'destroy', id: 'bl', uid: 'probe-rec@example.org', sendSchedulingMessages: true },
]);
// Its exceptions go with it.
const local = (await h.local('bl'))!;
const group = calendarPlanner.planLocalDelete(local);
await h.apply(group);
expect(await h.events()).toEqual([]);
});
it('removes only the synced membership of an event that is in several calendars', async () => {
const h = await new Harness().setup(['b', 'w', 'r']);
const isSelected = h.ctx.isSelected;
h.ctx = { ...h.ctx, isSelected: (key) => key !== `${ACCT}/x` && isSelected(key) };
await h.download(single({ calendarIds: { b: true, x: true } }));
h.fake.user.deleteEvent(Number(h.masterRow('e1')._id));
expect(actionsOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx))).toEqual([{ kind: 'update', id: 'e1', patch: { 'calendarIds/b': null } }]);
// The server keeps it elsewhere: the rows go.
const accepted = calendarPlanner.planAccepted((await h.local('e1'))!, single({ calendarIds: { x: true } }), h.ctx);
await h.apply(accepted.ops);
expect(await h.events()).toEqual([]);
});
it('leaves an event the server moved out of every synced calendar before the deletion reached it, and only purges the rows', async () => {
const { h, id } = await synced(single());
const isSelected = h.ctx.isSelected;
h.ctx = { ...h.ctx, isSelected: (key) => key !== `${ACCT}/x` && isSelected(key) };
h.fake.user.deleteEvent(id);
// The shadow follows the server although no synced calendar holds the event now.
expect(await h.download(single({ calendarIds: { x: true } }))).toMatchObject({ stillDirty: true, effect: 'update' });
expect(h.row(id)).toMatchObject({ [Events.DELETED]: 1 });
const plan = calendarPlanner.planUpload((await h.local('e1'))!, h.ctx);
expect(plan.kind).toBe('purge');
if (plan.kind === 'purge') await h.apply(plan.ops);
expect(await h.events()).toEqual([]);
});
it('still destroys an event the server moved to another synced calendar, or whose calendar was deselected before the upload', async () => {
const destroy = [{ kind: 'destroy', id: 'e1', uid: 'single-1@example.org' }];
const moved = await synced(single());
moved.h.fake.user.deleteEvent(moved.id);
await moved.h.download(single({ calendarIds: { w: true } }));
expect(actionsOf(calendarPlanner.planUpload((await moved.h.local('e1'))!, moved.h.ctx))).toEqual(destroy);
const deselected = await synced(single());
deselected.h.fake.user.deleteEvent(deselected.id);
deselected.h.ctx = { ...deselected.h.ctx, isSelected: () => false };
expect(actionsOf(calendarPlanner.planUpload((await deselected.h.local('e1'))!, deselected.h.ctx))).toEqual(destroy);
});
it('destroys a deleted event whose create had an unknown outcome by its uid', async () => {
const h = await new Harness().setup();
const id = h.fake.user.insertEvent(h.rowIds.get('b')!, { [Events.TITLE]: 'x', [Events.DTSTART]: 0, [Events.DTEND]: 1000, [Events.EVENT_TIMEZONE]: 'UTC', [Events.STATUS]: 1 });
const { local } = await claimAndCreate(h, id);
h.fake.user.deleteEvent(id);
// With the pending _SYNC_ID the app delete was a soft delete the engine can act on.
expect(h.row(id)).toMatchObject({ [Events.DELETED]: 1 });
expect(actionsOf(calendarPlanner.planUpload(await h.byRow(id), h.ctx))).toEqual([{ kind: 'destroy', id: null, uid: local.pending!.uid }]);
});
it('keeps a device deletion when the server edited the event meanwhile', async () => {
const { h, id } = await synced(single());
h.fake.user.deleteEvent(id);
const plan = await h.download(single({ title: 'Server edit' }));
expect(plan).toMatchObject({ stillDirty: true, effect: 'update', writes: 1 });
expect(h.row(id)).toMatchObject({ [Events.DELETED]: 1, [Events.TITLE]: 'Lunch' });
expect(actionsOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx))[0].kind).toBe('destroy');
});
it('purges an exception row whose master is gone instead of uploading it', async () => {
const { h } = await synced(weekly());
const local = (await h.local('bl'))!;
await h.apply({ ref: 'x', ops: [{ op: 'delete', table: 'events', id: local.eventId, expectCount: 1 }] });
const [orphan] = await h.events();
expect(orphan.cells[Events.ORIGINAL_SYNC_ID]).toBe(`${ACCT}/bl`);
const plan = calendarPlanner.planUpload(orphan, h.ctx);
expect(plan.kind).toBe('purge');
if (plan.kind === 'purge') await h.apply(plan.ops);
expect(await h.events()).toEqual([]);
});
it('refuses tasks everywhere', async () => {
const { h } = await synced(single());
const local = (await h.local('e1'))!;
const task = { ...single(), '@type': 'Task' } as CalendarEventWire;
expect(() => calendarPlanner.planAccepted(local, task, h.ctx)).toThrow(/tasks/);
expect(() => calendarPlanner.planUpload({ ...local, shadow: task }, h.ctx)).toThrow(/tasks/);
});
});
describe('calendar planner: pairs', () => {
it('uploads Etar\'s move to another calendar (delete + insert) as a calendarIds swap', async () => {
const { h, id } = await synced(single());
h.fake.user.deleteEvent(id);
const moved = h.fake.user.insertEvent(h.rowIds.get('w')!, {
[Events.TITLE]: 'Lunch', [Events.DTSTART]: utc('2026-10-06T10:00:00Z'), [Events.DTEND]: utc('2026-10-06T11:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.STATUS]: 1, [Events.AVAILABILITY]: 0, [Events.ACCESS_LEVEL]: 0,
});
const events = await h.events();
const pairs = calendarPlanner.planPairs(events.filter((e) => e.deleted), events.filter((e) => !e.syncId), h.ctx);
expect(pairs).toHaveLength(1);
expect(pairs[0].actions).toEqual([{ kind: 'update', id: 'e1', patch: { 'calendarIds/b': null, 'calendarIds/w': true } }]);
await h.apply(pairs[0].ops);
const after = await h.events();
expect(after).toHaveLength(1);
expect(after[0]).toMatchObject({ eventId: moved, syncId: `${ACCT}/e1`, dirty: false });
expect(after[0].shadow?.calendarIds).toEqual({ w: true });
// The server's echo writes nothing.
expect(calendarPlanner.planDownload(single({ calendarIds: { w: true } }), after[0], h.ctx).effect).toBe('none');
});
it('uploads a series turned into a single event by delete + insert as a rule removal', async () => {
const { h, id } = await synced(single({ recurrenceRule: { frequency: 'daily' } }));
h.fake.user.deleteEvent(id);
h.fake.user.insertEvent(h.rowIds.get('b')!, {
[Events.TITLE]: 'Lunch', [Events.DTSTART]: utc('2026-10-06T10:00:00Z'), [Events.DTEND]: utc('2026-10-06T11:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.STATUS]: 1, [Events.AVAILABILITY]: 0, [Events.ACCESS_LEVEL]: 0,
});
const events = await h.events();
const pairs = calendarPlanner.planPairs(events.filter((e) => e.deleted), events.filter((e) => !e.syncId), h.ctx);
expect(pairs.map((p) => p.actions)).toEqual([[{ kind: 'update', id: 'e1', patch: { recurrenceRule: null } }]]);
});
it('pairs a copy an app inserted without a status (Google Calendar\'s "Copy to" + delete) with the original', async () => {
const { h, id } = await synced(single());
h.fake.user.deleteEvent(id);
h.fake.user.insertEvent(h.rowIds.get('w')!, {
[Events.TITLE]: 'Lunch', [Events.DTSTART]: utc('2026-10-06T10:00:00Z'), [Events.DTEND]: utc('2026-10-06T11:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.STATUS]: null, [Events.AVAILABILITY]: 0, [Events.ACCESS_LEVEL]: 0,
});
const events = await h.events();
const pairs = calendarPlanner.planPairs(events.filter((e) => e.deleted), events.filter((e) => !e.syncId), h.ctx);
expect(pairs.map((p) => p.actions)).toEqual([[{ kind: 'update', id: 'e1', patch: { 'calendarIds/b': null, 'calendarIds/w': true } }]]);
});
it('does not pair unrelated rows', async () => {
const { h, id } = await synced(single());
h.fake.user.deleteEvent(id);
h.fake.user.insertEvent(h.rowIds.get('w')!, { [Events.TITLE]: 'Something else', [Events.DTSTART]: utc('2026-10-06T10:00:00Z'), [Events.DTEND]: utc('2026-10-06T11:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.STATUS]: 1 });
const events = await h.events();
expect(calendarPlanner.planPairs(events.filter((e) => e.deleted), events.filter((e) => !e.syncId), h.ctx)).toEqual([]);
});
/** Etar's move of `single()` (delete + insert into Work), with `extra` columns and children on the new row. */
const lunchColumns = {
[Events.TITLE]: 'Lunch', [Events.DTSTART]: utc('2026-10-06T10:00:00Z'), [Events.DTEND]: utc('2026-10-06T11:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.STATUS]: 1, [Events.AVAILABILITY]: 0, [Events.ACCESS_LEVEL]: 0,
};
async function pairAndApply(h: Harness) {
const events = await h.events();
const pairs = calendarPlanner.planPairs(events.filter((e) => e.deleted), events.filter((e) => !e.syncId && !e.deleted), h.ctx);
expect(pairs).toHaveLength(1);
await h.apply(pairs[0].ops);
return pairs[0];
}
const alert15 = { al1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' }, action: 'display' } } as CalendarEventWire['alerts'];
it('keeps a reminder saved with the move for the next upload instead of taking it as the baseline', async () => {
const { h, id } = await synced(single({ alerts: alert15 }));
h.fake.user.deleteEvent(id);
const moved = h.fake.user.insertEvent(h.rowIds.get('w')!, lunchColumns, { reminders: [{ [Reminders.MINUTES]: 60, [Reminders.METHOD]: 1 }] });
const pair = await pairAndApply(h);
expect(pair.actions).toEqual([{ kind: 'update', id: 'e1', patch: { 'calendarIds/b': null, 'calendarIds/w': true } }]);
const after = (await h.local('e1'))!;
expect(after).toMatchObject({ eventId: moved, dirty: true });
const [update] = actionsOf(calendarPlanner.planUpload(after, h.ctx));
if (update.kind !== 'update') throw new Error('not an update');
// Only the reminder is left to send: the move went up with the pair.
expect(Object.keys(update.patch).every((k) => k.startsWith('alerts/'))).toBe(true);
const alerts = serverApply(after.shadow!, update.patch).alerts!;
expect(Object.values(alerts).map((a) => (a.trigger as { offset?: string }).offset)).toEqual(['-PT1H']);
});
it('uploads an edit made before the move, which the new row copied', async () => {
const { h, id } = await synced(single({ description: 'Bring salad' }));
h.fake.user.updateEvent(id, { [Events.DESCRIPTION]: 'Bring soup' });
h.fake.user.deleteEvent(id);
h.fake.user.insertEvent(h.rowIds.get('w')!, { ...lunchColumns, [Events.DESCRIPTION]: 'Bring soup' });
await pairAndApply(h);
const after = (await h.local('e1'))!;
expect(after.dirty).toBe(true);
expect(actionsOf(calendarPlanner.planUpload(after, h.ctx))).toEqual([{ kind: 'update', id: 'e1', patch: { description: 'Bring soup' } }]);
});
it('takes attendee rows an app re-inserted with constant details as they are, uploading nothing for them', async () => {
const participants = {
bob: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.net', name: 'Bob', roles: { attendee: true, optional: true }, participationStatus: 'accepted' },
carol: { '@type': 'Participant', calendarAddress: 'mailto:carol@example.net', name: 'Carol', roles: { attendee: true }, participationStatus: 'declined' },
} as CalendarEventWire['participants'];
const { h, id } = await synced(single({ participants, organizerCalendarAddress: 'mailto:boss@example.net' }));
const attendees = h.fake.rows('attendees', `${Attendees.EVENT_ID} = ?`, [id]).map((a) => ({
// Etar writes every attendee it re-inserts as a required attendee without a status.
[Attendees.ATTENDEE_EMAIL]: a[Attendees.ATTENDEE_EMAIL], [Attendees.ATTENDEE_NAME]: a[Attendees.ATTENDEE_NAME],
[Attendees.ATTENDEE_RELATIONSHIP]: 1, [Attendees.ATTENDEE_TYPE]: 1, [Attendees.ATTENDEE_STATUS]: 0,
}));
const organizer = h.row(id)[Events.ORGANIZER];
h.fake.user.deleteEvent(id);
h.fake.user.insertEvent(h.rowIds.get('w')!, { ...lunchColumns, [Events.ORGANIZER]: organizer }, { attendees });
await pairAndApply(h);
const after = (await h.local('e1'))!;
expect(after.dirty).toBe(false);
expect(calendarPlanner.planUpload({ ...after, dirty: true }, h.ctx).kind).toBe('clean');
});
describe("Etar's move of a meeting (its copy leaves the user's own row out, or adds the user as organizer)", () => {
const emailsOf = (h: Harness, eventId: number) =>
h.fake.rows('attendees', `${Attendees.EVENT_ID} = ?`, [eventId]).map((a) => String(a[Attendees.ATTENDEE_EMAIL])).sort();
const person = (address: string, extra: Record<string, unknown> = {}) => ({ '@type': 'Participant', calendarAddress: `mailto:${address}`, roles: { attendee: true }, ...extra });
const moveToWork = { kind: 'update', id: 'e1', patch: { 'calendarIds/b': null, 'calendarIds/w': true } };
it('pairs a meeting that lists the user', async () => {
const participants = {
me: person(ME, { roles: { owner: true, attendee: true }, participationStatus: 'accepted' }),
bob: person('bob@example.net', { name: 'Bob', participationStatus: 'accepted' }),
} as CalendarEventWire['participants'];
const { h, id } = await synced(single({ participants, organizerCalendarAddress: `mailto:${ME}` }));
const moved = h.fake.user.etarMove(id, h.rowIds.get('w')!);
expect(emailsOf(h, moved)).toEqual(['bob@example.net']);
expect((await pairAndApply(h)).actions).toEqual([moveToWork]);
expect((await h.local('e1'))!).toMatchObject({ eventId: moved, dirty: false });
});
it('pairs a meeting that does not list the user', async () => {
const participants = {
boss: person('boss@example.net', { roles: { owner: true, attendee: true }, participationStatus: 'accepted' }),
bob: person('bob@example.net', { participationStatus: 'needs-action' }),
} as CalendarEventWire['participants'];
const { h, id } = await synced(single({ participants, organizerCalendarAddress: 'mailto:boss@example.net' }));
const moved = h.fake.user.etarMove(id, h.rowIds.get('w')!);
expect(emailsOf(h, moved)).toEqual(['bob@example.net', 'boss@example.net', ME]);
expect((await pairAndApply(h)).actions).toEqual([moveToWork]);
});
it('never pairs a copy that lost or gained someone else', async () => {
const participants = {
me: person(ME, { roles: { owner: true, attendee: true }, participationStatus: 'accepted' }),
bob: person('bob@example.net'),
carol: person('carol@example.net'),
} as CalendarEventWire['participants'];
const attendee = (email: string) => ({ [Attendees.ATTENDEE_EMAIL]: email, [Attendees.ATTENDEE_RELATIONSHIP]: 1, [Attendees.ATTENDEE_TYPE]: 1, [Attendees.ATTENDEE_STATUS]: 0 });
for (const copied of [['bob@example.net'], ['bob@example.net', 'carol@example.net', 'dave@example.net']]) {
const { h, id } = await synced(single({ participants, organizerCalendarAddress: `mailto:${ME}` }));
h.fake.user.deleteEvent(id);
h.fake.user.insertEvent(h.rowIds.get('w')!, lunchColumns, { attendees: copied.map(attendee) });
const events = await h.events();
expect(calendarPlanner.planPairs(events.filter((e) => e.deleted), events.filter((e) => !e.syncId), h.ctx), copied.join()).toEqual([]);
}
});
});
it('keeps the timing of a series turned single against the series, so a start moved before uploads', async () => {
const { h, id } = await synced(single({ recurrenceRule: { frequency: 'daily' } }));
// Moved an hour later in place, then turned into a single event (Etar: delete + insert).
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-06T11:00:00Z') });
h.fake.user.deleteEvent(id);
h.fake.user.insertEvent(h.rowIds.get('b')!, { ...lunchColumns, [Events.DTSTART]: utc('2026-10-06T11:00:00Z'), [Events.DTEND]: utc('2026-10-06T12:00:00Z') });
const pair = await pairAndApply(h);
expect(pair.actions).toEqual([{ kind: 'update', id: 'e1', patch: { recurrenceRule: null } }]);
const after = (await h.local('e1'))!;
expect(after.dirty).toBe(true);
expect(actionsOf(calendarPlanner.planUpload(after, h.ctx))).toEqual([{ kind: 'update', id: 'e1', patch: { start: '2026-10-06T13:00:00' } }]);
// Without the earlier edit, the single event matches what the server holds after the pair: nothing left.
const plain = await synced(single({ recurrenceRule: { frequency: 'daily' } }));
plain.h.fake.user.deleteEvent(plain.id);
plain.h.fake.user.insertEvent(plain.h.rowIds.get('b')!, lunchColumns);
await pairAndApply(plain.h);
expect((await plain.h.local('e1'))!.dirty).toBe(false);
});
});
describe('calendar planner: baselines, zones and reminder owners', () => {
it('heals a clean row\'s baseline after provider normalisation, but never a series\' timing or rule', async () => {
const { h, id } = await synced(weekly());
// Provider-side changes without DIRTY (as CONTENT_EXCEPTION_URI's split does to the rule).
const row = h.fake.table('events').get(id)!;
row[Events.ORGANIZER] = 'someone@example.net';
row[Events.RRULE] = 'FREQ=WEEKLY;UNTIL=20261019T125959Z;BYDAY=MO,WE';
const heal = calendarPlanner.planBaselineHeal((await h.local('bl'))!)!;
expect(heal).not.toBeNull();
await h.apply(heal);
const baseline = (await h.local('bl'))!.baseline!;
expect(baseline.cells[Events.ORGANIZER]).toBe('someone@example.net');
expect(baseline.cells[Events.RRULE]).toBe('FREQ=WEEKLY;COUNT=10;BYDAY=MO,WE');
expect(calendarPlanner.planBaselineHeal((await h.local('bl'))!)).toBeNull();
});
it('does not heal a row that turned dirty', async () => {
const { h, id } = await synced(single());
h.fake.user.updateEvent(id, { [Events.TITLE]: 'x' });
expect(calendarPlanner.planBaselineHeal((await h.local('e1'))!)).toBeNull();
});
it('rewrites floating events for a new device zone, keeping their wall time', async () => {
const { h, id } = await synced(single({ id: 'f', timeZone: null, start: '2026-10-06T14:00:00' }));
expect(h.row(id)[Events.DTSTART]).toBe(utc('2026-10-06T12:00:00Z'));
h.ctx = { ...h.ctx, deviceZone: 'America/New_York' };
const group = calendarPlanner.planZoneChange((await h.local('f'))!, 'Europe/Berlin', h.ctx)!;
await h.apply(group);
expect(h.row(id)).toMatchObject({ [Events.DTSTART]: utc('2026-10-06T18:00:00Z'), [Events.EVENT_TIMEZONE]: 'America/New_York' });
expect(calendarPlanner.planZoneChange((await h.local('f'))!, 'Europe/Berlin', h.ctx)).toBeNull();
// Events with a zone are not floating.
const t = await synced(single());
expect(calendarPlanner.planZoneChange((await t.h.local('e1'))!, 'Europe/Berlin', { ...t.h.ctx, deviceZone: 'Asia/Tokyo' })).toBeNull();
});
it('rewrites Reminders rows when the reminder owner changes, without server writes', async () => {
const { h, id } = await synced(weekly());
expect(h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [id])).toHaveLength(1);
h.ctx = { ...h.ctx, reminderOwner: 'bulwark' };
await h.apply(calendarPlanner.planReminderOwnerChange((await h.local('bl'))!, h.ctx)!);
expect(h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [id])).toHaveLength(0);
expect(calendarPlanner.planReminderOwnerChange((await h.local('bl'))!, h.ctx)).toBeNull();
h.ctx = { ...h.ctx, reminderOwner: 'device' };
await h.apply(calendarPlanner.planReminderOwnerChange((await h.local('bl'))!, h.ctx)!);
expect(h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [id]).map((r) => r[Reminders.MINUTES])).toEqual([15]);
// Back with the calendar app: an echo download writes nothing.
expect(calendarPlanner.planDownload(weekly(), await h.local('bl'), h.ctx).effect).toBe('none');
});
});
describe('calendar planner: decoding', () => {
it('tolerates garbage in sync columns and numbers read back as text', async () => {
const [event] = calendarPlanner.decodeEvents(
[
{
_id: '5', calendar_id: '1', _sync_id: 'c/x', dirty: '1', deleted: '0', sync_data1: '{broken', sync_data3: '{"uid":1}',
sync_data4: '[]', sync_data5: 'nope', title: 'T', allDay: '0', dtstart: '1000', rrule: null, original_id: null, original_sync_id: null,
},
],
[],
[],
);
expect(event).toMatchObject({ eventId: 5, calendarRowId: 1, syncId: 'c/x', dirty: true, deleted: false, shadow: null, pending: null, baseline: null, poison: null });
});
it('groups exceptions by ORIGINAL_ID, else ORIGINAL_SYNC_ID, and returns orphans as masters', () => {
const events = calendarPlanner.decodeEvents(
[
{ _id: 1, calendar_id: 1, _sync_id: 'c/m', eventTimezone: 'Europe/Berlin', allDay: 0, dirty: 0, deleted: 0 },
{ _id: 2, calendar_id: 1, _sync_id: null, original_id: 1, originalInstanceTime: utc('2026-10-07T07:00:00Z'), dirty: 1, deleted: 0 },
{ _id: 3, calendar_id: 1, _sync_id: 'c/m#2026-10-08T09:00:00', original_sync_id: 'c/m', sync_data2: '2026-10-08T09:00:00', dirty: 0, deleted: 0 },
{ _id: 4, calendar_id: 1, _sync_id: null, original_sync_id: 'c/gone', originalInstanceTime: 0, dirty: 1, deleted: 0 },
],
[],
[],
);
expect(events.map((e) => [e.eventId, e.exceptions.map((x) => [x.eventId, (x as { recurrenceId: string }).recurrenceId])])).toEqual([
[1, [[2, '2026-10-07T09:00:00'], [3, '2026-10-08T09:00:00']]],
[4, []],
]);
});
it('marks two masters sharing a _SYNC_ID as a split source and clone', () => {
const events = calendarPlanner.decodeEvents(
[
{ _id: 1, calendar_id: 1, _sync_id: 'c/m' },
{ _id: 9, calendar_id: 1, _sync_id: 'c/m' },
{ _id: 3, calendar_id: 1, _sync_id: 'c/n' },
],
[],
[],
);
expect(events.map((e: LocalEvent) => [e.eventId, e.split])).toEqual([
[1, 'source'],
[3, undefined],
[9, 'clone'],
]);
});
});
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest';
import { Attendees, Events, Reminders } from '../../android-columns';
import { calendarPlanner } from '../../calendar/planner';
import { Harness, single, utc, weekly } from './harness';
describe('calendar planner: what the real providers hand back', () => {
it('sees no edit in numbers read back as text', async () => {
const h = await new Harness().setup();
await h.download(weekly());
const id = Number(h.masterRow('bl')._id);
// Some provider columns come back through the bridge as text.
const row = h.fake.table('events').get(id)!;
for (const c of [Events.STATUS, Events.AVAILABILITY, Events.ACCESS_LEVEL, Events.DTSTART, Events.ALL_DAY, Events.EVENT_COLOR, Events.CALENDAR_ID]) {
row[c] = String(row[c]);
}
for (const a of h.fake.table('attendees').values()) {
if (a.event_id === id) for (const c of [Attendees.ATTENDEE_STATUS, Attendees.ATTENDEE_TYPE, Attendees.ATTENDEE_RELATIONSHIP]) a[c] = String(a[c]);
}
for (const r of h.fake.table('reminders').values()) {
if (r.event_id === id) for (const c of [Reminders.MINUTES, Reminders.METHOD]) r[c] = String(r[c]);
}
expect(calendarPlanner.planDownload(weekly(), await h.local('bl'), h.ctx).effect).toBe('none');
h.fake.user.updateEvent(id, {});
expect(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx).kind).toBe('clean');
});
it('asserts DIRTY as read, so a row inserted with a NULL DIRTY still takes a guarded write', async () => {
const h = await new Harness().setup();
await h.download(single());
const id = Number(h.masterRow('e1')._id);
h.fake.table('events').get(id)![Events.DIRTY] = null;
const local = (await h.local('e1'))!;
expect(local.dirty).toBe(false);
const plan = calendarPlanner.planDownload(single({ title: 'Changed' }), local, h.ctx);
const assert = plan.ops.ops.find((op) => op.op === 'assert' && 'id' in op && op.id === id) as { values: Record<string, unknown> };
expect(assert.values[Events.DIRTY]).toBeNull();
await h.apply(plan.ops);
expect(h.row(id)[Events.TITLE]).toBe('Changed');
});
it('writes DIRTY = 0 on every row it inserts', async () => {
const h = await new Harness().setup();
const plan = calendarPlanner.planDownload(weekly(), null, h.ctx);
const inserts = plan.ops.ops.filter((op) => op.op === 'insert' && op.table === 'events') as Array<{ values: Record<string, unknown> }>;
expect(inserts).toHaveLength(2);
for (const insert of inserts) expect(insert.values[Events.DIRTY]).toBe(0);
});
it('addresses attendee and reminder rows by id only', async () => {
const h = await new Harness().setup();
await h.download(weekly());
const plan = calendarPlanner.planDownload(
{ ...weekly(), participants: { me1: weekly().participants!.me1 }, alerts: {} },
await h.local('bl'),
h.ctx,
);
for (const op of plan.ops.ops) {
if ((op.op === 'update' || op.op === 'delete') && (op.table === 'attendees' || op.table === 'reminders')) {
expect(op).toMatchObject({ expectCount: 1 });
expect(typeof op.id).toBe('number');
expect(op.where).toBeUndefined();
}
if ('where' in op && op.where) expect(op.where).not.toMatch(/\b_id\b/);
}
await h.apply(plan.ops);
});
it('computes instance times exactly across a DST change of the series\' zone', async () => {
const h = await new Harness().setup();
const event = weekly();
event.recurrenceRule = { frequency: 'weekly', byDay: [{ day: 'mo' }] };
event.recurrenceOverrides = { '2026-11-02T09:00:00': { title: 'After the change' } };
await h.download(event);
const [x] = (await h.local('bl'))!.exceptions;
// New York left DST on 1 November: 09:00 is 14:00Z from then on, not 13:00Z.
expect(x.cells[Events.ORIGINAL_INSTANCE_TIME]).toBe(utc('2026-11-02T14:00:00Z'));
expect(x.cells[Events.DTSTART]).toBe(utc('2026-11-02T14:00:00Z'));
});
it('derives the recurrence id of an app\'s exception from its instance time in the master\'s zone', async () => {
const h = await new Harness().setup();
const event = weekly();
event.recurrenceRule = { frequency: 'weekly', byDay: [{ day: 'mo' }] };
event.recurrenceOverrides = {};
await h.download(event);
const id = Number(h.masterRow('bl')._id);
h.fake.user.insertException(id, utc('2026-11-02T14:00:00Z'), { [Events.TITLE]: 'x', [Events.DTSTART]: utc('2026-11-02T14:00:00Z'), [Events.DTEND]: utc('2026-11-02T15:30:00Z') });
expect((await h.local('bl'))!.exceptions[0].recurrenceId).toBe('2026-11-02T09:00:00');
});
it('ignores an EXRULE or RDATE an app wrote', async () => {
const h = await new Harness().setup();
await h.download(weekly());
const id = Number(h.masterRow('bl')._id);
h.fake.user.updateEvent(id, { [Events.EXRULE]: 'FREQ=MONTHLY' });
expect(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx).kind).toBe('clean');
});
});
@@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest';
import type { RecurrenceRule } from '../../../api/types';
import {
canonicalRRule,
isRuleRepresentable,
parseUntil,
ruleToRRule,
rruleToRule,
unrepresentableParts,
untilUtc,
} from '../../calendar/rrule';
import { aospRruleValid } from '../fakes/fake-provider';
import { exdateSet, formatExdate, parseExdate } from '../../calendar/exdate';
const timed = { allDay: false, zone: 'Europe/Berlin' };
const allDay = { allDay: true, zone: 'UTC' };
describe('calendar/rrule', () => {
it.each<[string, RecurrenceRule, string]>([
['daily', { frequency: 'daily' }, 'FREQ=DAILY'],
['interval', { frequency: 'weekly', interval: 2 }, 'FREQ=WEEKLY;INTERVAL=2'],
['count', { frequency: 'daily', count: 10 }, 'FREQ=DAILY;COUNT=10'],
['byDay', { frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'we' }] }, 'FREQ=WEEKLY;BYDAY=MO,WE'],
['nth weekday', { frequency: 'monthly', byDay: [{ day: 'fr', nthOfPeriod: -1 }, { day: 'tu', nthOfPeriod: 2 }] }, 'FREQ=MONTHLY;BYDAY=-1FR,2TU'],
['byMonthDay', { frequency: 'monthly', byMonthDay: [1, 15, -1] }, 'FREQ=MONTHLY;BYMONTHDAY=1,15,-1'],
['byMonth', { frequency: 'yearly', byMonth: ['3', '10'] }, 'FREQ=YEARLY;BYMONTH=3,10'],
['byYearDay', { frequency: 'yearly', byYearDay: [1, -1] }, 'FREQ=YEARLY;BYYEARDAY=1,-1'],
['byWeekNo', { frequency: 'yearly', byWeekNo: [20], byDay: [{ day: 'mo' }] }, 'FREQ=YEARLY;BYDAY=MO;BYWEEKNO=20'],
['byHour/Minute/Second', { frequency: 'daily', byHour: [9, 17], byMinute: [0, 30], bySecond: [0] }, 'FREQ=DAILY;BYSECOND=0;BYMINUTE=0,30;BYHOUR=9,17'],
['bySetPosition', { frequency: 'monthly', byDay: [{ day: 'mo' }, { day: 'tu' }], bySetPosition: [-1] }, 'FREQ=MONTHLY;BYDAY=MO,TU;BYSETPOS=-1'],
['wkst', { frequency: 'weekly', interval: 2, firstDayOfWeek: 'su' }, 'FREQ=WEEKLY;INTERVAL=2;WKST=SU'],
])('writes and reads back %s', (_name, rule, rrule) => {
const written = ruleToRRule(rule, timed)!;
expect(written).toBe(rrule);
expect(aospRruleValid(written)).toBe(true);
expect(rruleToRule(written, timed)).toEqual(rule);
});
it('leaves out the defaults: INTERVAL=1, WKST=MO, rscale gregorian, skip omit', () => {
expect(ruleToRRule({ frequency: 'weekly', interval: 1, firstDayOfWeek: 'mo', rscale: 'gregorian', skip: 'omit' }, timed)).toBe('FREQ=WEEKLY');
});
it('writes UNTIL as UTC for timed rules, converted from the event zone, and as a date for all-day rules', () => {
expect(ruleToRRule({ frequency: 'daily', until: '2026-10-30T09:00:00' }, timed)).toBe('FREQ=DAILY;UNTIL=20261030T080000Z');
expect(ruleToRRule({ frequency: 'daily', until: '2026-07-30T09:00:00' }, timed)).toBe('FREQ=DAILY;UNTIL=20260730T070000Z');
expect(ruleToRRule({ frequency: 'daily', until: '2026-10-30T23:59:59' }, allDay)).toBe('FREQ=DAILY;UNTIL=20261030');
expect(rruleToRule('FREQ=DAILY;UNTIL=20261030T080000Z', timed)).toEqual({ frequency: 'daily', until: '2026-10-30T09:00:00' });
expect(rruleToRule('FREQ=DAILY;UNTIL=20261030', allDay)).toEqual({ frequency: 'daily', until: '2026-10-30T00:00:00' });
});
it('converts UNTIL across DST edges', () => {
// New York spring gap: 02:30 does not exist, UNTIL moves forward like the instance does.
expect(ruleToRRule({ frequency: 'daily', until: '2026-03-08T02:30:00' }, { allDay: false, zone: 'America/New_York' })).toBe(
'FREQ=DAILY;UNTIL=20260308T073000Z',
);
// Berlin autumn overlap: the first instant.
expect(ruleToRRule({ frequency: 'daily', until: '2026-10-25T02:30:00' }, timed)).toBe('FREQ=DAILY;UNTIL=20261025T003000Z');
expect(untilUtc({ frequency: 'daily', until: '2026-10-25T02:30:00' }, timed)).toBe(Date.parse('2026-10-25T00:30:00Z'));
// Sydney's autumn overlap.
expect(ruleToRRule({ frequency: 'weekly', until: '2026-04-05T02:30:00' }, { allDay: false, zone: 'Australia/Sydney' })).toBe(
'FREQ=WEEKLY;UNTIL=20260404T153000Z',
);
});
it('reads rules the way device apps write them', () => {
expect(rruleToRule('rrule:freq=weekly;byday=mo,we;x-fossify=1', timed)).toEqual({ frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'we' }] });
// Etar writes all-day UNTIL as a UTC date-time (the instance start minus 1 s).
expect(rruleToRule('FREQ=DAILY;UNTIL=20261015T235959Z', allDay)).toEqual({ frequency: 'daily', until: '2026-10-15T00:00:00' });
// A floating UNTIL is UTC to AOSP, and so is a DATE on a timed rule.
expect(parseUntil('20261030T080000', timed)).toBe('2026-10-30T09:00:00');
expect(parseUntil('20261030', timed)).toBe('2026-10-30T01:00:00');
// COUNT wins over UNTIL next to it.
expect(rruleToRule('FREQ=DAILY;COUNT=5;UNTIL=20261030T080000Z', timed)).toEqual({ frequency: 'daily', count: 5 });
// Several rules: the first one.
expect(rruleToRule('FREQ=DAILY\nFREQ=WEEKLY', timed)).toEqual({ frequency: 'daily' });
expect(rruleToRule('INTERVAL=2', timed)).toBeNull();
expect(rruleToRule('FREQ=FORTNIGHTLY', timed)).toBeNull();
expect(rruleToRule(null, timed)).toBeNull();
});
it('tells rules AOSP cannot hold', () => {
expect(unrepresentableParts({ frequency: 'yearly', rscale: 'chinese' })).toEqual(['rscale']);
expect(unrepresentableParts({ frequency: 'monthly', skip: 'forward' })).toEqual(['skip']);
expect(unrepresentableParts({ frequency: 'yearly', byMonth: ['5L'] })).toEqual(['byMonth']);
expect(isRuleRepresentable({ frequency: 'yearly', rscale: 'GREGORIAN', skip: 'omit' })).toBe(true);
// Never RSCALE or SKIP; a leap month is dropped, not written.
const written = ruleToRRule({ frequency: 'yearly', rscale: 'chinese', skip: 'forward', byMonth: ['5L', '6'] }, timed)!;
expect(written).toBe('FREQ=YEARLY;BYMONTH=6');
expect(aospRruleValid(written)).toBe(true);
});
it('compares rules by meaning', () => {
expect(canonicalRRule('FREQ=WEEKLY;BYDAY=WE,MO;INTERVAL=1;WKST=MO')).toBe(canonicalRRule('freq=weekly;byday=mo,we'));
expect(canonicalRRule('FREQ=WEEKLY;BYDAY=MO')).not.toBe(canonicalRRule('FREQ=WEEKLY;BYDAY=TU'));
expect(canonicalRRule(null)).toBe('');
});
});
describe('calendar/exdate', () => {
it('writes excluded instances as UTC date-times, or dates for all-day events', () => {
expect(formatExdate(['2026-10-14T09:00:00', '2026-10-12T09:00:00'], timed)).toBe('20261012T070000Z,20261014T070000Z');
expect(formatExdate(['2026-10-12T00:00:00'], allDay)).toBe('20261012');
expect(formatExdate([], timed)).toBeNull();
});
it('reads every form back into recurrence ids in the event zone', () => {
expect(parseExdate('20261012T070000Z,20261014T070000Z', timed)).toEqual(['2026-10-12T09:00:00', '2026-10-14T09:00:00']);
// A zone prefix, a floating time without one (UTC), several lines.
expect(parseExdate('Europe/Berlin;20261012T090000\n20261014T070000', timed)).toEqual(['2026-10-12T09:00:00', '2026-10-14T09:00:00']);
expect(parseExdate('20261012,20261013T000000Z', allDay)).toEqual(['2026-10-12T00:00:00', '2026-10-13T00:00:00']);
expect(parseExdate('garbage,20261012T070000Z', timed)).toEqual(['2026-10-12T09:00:00']);
expect(parseExdate(null, timed)).toEqual([]);
});
it('compares columns by the instants they name', () => {
expect([...exdateSet('Europe/Berlin;20261012T090000')]).toEqual(['20261012T070000Z']);
expect([...exdateSet('20261012T000000Z', true)]).toEqual(['20261012']);
});
});
@@ -0,0 +1,324 @@
import { describe, expect, it } from 'vitest';
import { Attendees, Events, Reminders } from '../../android-columns';
import { calendarPlanner } from '../../calendar/planner';
import type { UploadPlan } from '../../planner';
import type { CalendarEventWire } from '../../wire';
import type { Row } from '../../types';
import { cssColorToArgb } from '../../calendar/values';
import { Harness, ME, serverApply, single, utc, weekly } from './harness';
type Plan = UploadPlan<CalendarEventWire>;
function updateOf(plan: Plan) {
expect(plan.kind).toBe('upload');
if (plan.kind !== 'upload') throw new Error('not an upload');
expect(plan.actions).toHaveLength(1);
const action = plan.actions[0];
if (action.kind !== 'update') throw new Error(`not an update: ${action.kind}`);
return action;
}
async function synced(event: CalendarEventWire, options = {}) {
const h = await new Harness(options).setup();
await h.download(event);
const id = Number(h.masterRow(event.id)._id);
return { h, id };
}
const FIXTURES: Array<[string, () => CalendarEventWire]> = [
['a recurring event with overrides', weekly],
['a single timed event', () => single()],
['a floating event', () => single({ id: 'f', timeZone: null })],
['an all-day event', () => single({ id: 'a', start: '2026-10-08T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P3D' })],
['a recurring all-day event', () => single({ id: 'y', start: '2026-10-08T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P1D', recurrenceRule: { frequency: 'yearly' } })],
['an HTML description and a CSS colour name', () => single({ id: 'h', description: '<p>Hi <b>there</b></p>', descriptionContentType: 'text/html', color: 'rebeccapurple' })],
];
describe('calendar planner: uploads of edited events', () => {
it.each(FIXTURES)('round-trips %s: rows untouched → clean', async (_name, make) => {
const event = make();
const { h, id } = await synced(event);
// An app saving the event without changing anything.
h.fake.user.updateEvent(id, {});
const plan = calendarPlanner.planUpload((await h.local(event.id))!, h.ctx);
expect(plan.kind).toBe('clean');
if (plan.kind !== 'clean') return;
await h.apply(plan.ops);
expect(h.row(id)[Events.DIRTY]).toBe(0);
// And the next download of the same event writes nothing.
expect(calendarPlanner.planDownload(event, await h.local(event.id), h.ctx).effect).toBe('none');
});
it.each<[string, Row, Record<string, unknown>]>([
['title', { [Events.TITLE]: 'New title' }, { title: 'New title' }],
['description', { [Events.DESCRIPTION]: 'New text' }, { description: 'New text' }],
['a cleared description', { [Events.DESCRIPTION]: '' }, { description: null }],
['location', { [Events.EVENT_LOCATION]: 'Room 2' }, { 'locations/locA/name': 'Room 2' }],
['a cleared location', { [Events.EVENT_LOCATION]: null }, { 'locations/locA': null }],
['status', { [Events.STATUS]: 1 }, { status: 'confirmed' }],
['availability', { [Events.AVAILABILITY]: 0 }, { freeBusyStatus: 'busy' }],
['tentative availability (busy)', { [Events.AVAILABILITY]: 2 }, { freeBusyStatus: 'busy' }],
['privacy', { [Events.ACCESS_LEVEL]: 3 }, { privacy: 'public' }],
['default privacy', { [Events.ACCESS_LEVEL]: 0 }, { privacy: null }],
['colour', { [Events.EVENT_COLOR]: cssColorToArgb('#ff8800') }, { color: '#ff8800' }],
['duration', { [Events.DURATION]: 'P7200S' }, { duration: 'PT2H' }],
])('uploads one edited field of a series as a patch of only that field: %s', async (_name, values, patch) => {
const { h, id } = await synced(weekly());
h.fake.user.updateEvent(id, values);
const action = updateOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
expect(action.id).toBe('bl');
expect(action.patch).toEqual(patch);
});
it('adds the first location as a whole map', async () => {
const { h, id } = await synced(single());
h.fake.user.updateEvent(id, { [Events.EVENT_LOCATION]: 'Café' });
const action = updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx));
const [[key, value]] = Object.entries(action.patch.locations as Record<string, unknown>);
expect(Object.keys(action.patch)).toEqual(['locations']);
expect(key).toMatch(/^b[0-9a-z]{8}$/);
expect(value).toEqual({ '@type': 'Location', name: 'Café' });
});
it('uploads an HTML description as text/plain only when it was edited', async () => {
const { h, id } = await synced(single({ description: '<p>Hi <b>there</b></p>', descriptionContentType: 'text/html' }));
expect(h.row(id)[Events.DESCRIPTION]).toBe('Hi there');
h.fake.user.updateEvent(id, { [Events.DESCRIPTION]: 'Hi there!' });
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({
description: 'Hi there!',
descriptionContentType: 'text/plain',
});
});
it('never uploads an edit of a description that was written truncated', async () => {
const long = 'x'.repeat(70_000);
const { h, id } = await synced(single({ description: long }));
expect(String(h.row(id)[Events.DESCRIPTION]).length).toBe(65_536);
h.fake.user.updateEvent(id, { [Events.DESCRIPTION]: 'short now', [Events.TITLE]: 'T' });
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({ title: 'T' });
});
it('moves a single event: start in its zone, the length unchanged', async () => {
const { h, id } = await synced(single());
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-06T12:30:00Z'), [Events.DTEND]: utc('2026-10-06T13:30:00Z') });
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({ start: '2026-10-06T14:30:00' });
});
it('keeps a floating event floating, and takes a zone the user picked', async () => {
const { h, id } = await synced(single({ id: 'f', timeZone: null }));
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-06T11:00:00Z'), [Events.DTEND]: utc('2026-10-06T12:00:00Z') });
expect(updateOf(calendarPlanner.planUpload((await h.local('f'))!, h.ctx)).patch).toEqual({ start: '2026-10-06T13:00:00' });
h.fake.user.updateEvent(id, { [Events.EVENT_TIMEZONE]: 'Asia/Tokyo' });
expect(updateOf(calendarPlanner.planUpload((await h.local('f'))!, h.ctx)).patch).toEqual({ start: '2026-10-06T20:00:00', timeZone: 'Asia/Tokyo' });
});
it('keeps all-day events dates and turns timed ones into all-day ones', async () => {
const { h, id } = await synced(single({ id: 'a', start: '2026-10-08T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P1D' }));
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-09T00:00:00Z'), [Events.DTEND]: utc('2026-10-11T00:00:00Z') });
expect(updateOf(calendarPlanner.planUpload((await h.local('a'))!, h.ctx)).patch).toEqual({ start: '2026-10-09T00:00:00', duration: 'P2D' });
const t = await synced(single());
t.h.fake.user.updateEvent(t.id, {
[Events.ALL_DAY]: 1,
[Events.EVENT_TIMEZONE]: 'UTC',
[Events.DTSTART]: utc('2026-10-06T00:00:00Z'),
[Events.DTEND]: utc('2026-10-07T00:00:00Z'),
});
expect(updateOf(calendarPlanner.planUpload((await t.h.local('e1'))!, t.h.ctx)).patch).toEqual({
start: '2026-10-06T00:00:00',
duration: 'P1D',
timeZone: null,
showWithoutTime: true,
});
});
it('sends a single event whose new start is a repeated wall time in a fixed-offset zone', async () => {
const { h, id } = await synced(single());
// 02:30 CEST on the night the clocks go back: Stalwart would drop it in Europe/Berlin.
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-25T00:30:00Z'), [Events.DTEND]: utc('2026-10-25T01:30:00Z') });
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({ start: '2026-10-25T02:30:00', timeZone: 'Etc/GMT-2' });
// The second 02:30 (CET) is the same wall time an hour later.
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-25T01:30:00Z'), [Events.DTEND]: utc('2026-10-25T02:30:00Z') });
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({ start: '2026-10-25T02:30:00', timeZone: 'Etc/GMT-1' });
});
it('skips a series whose new start is a repeated wall time', async () => {
const { h, id } = await synced(single({ recurrenceRule: { frequency: 'daily' } }));
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-25T00:30:00Z') });
expect(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).toEqual({ kind: 'skip', reason: 'dstAmbiguous' });
});
it('never uploads the timing of a rule Android cannot represent', async () => {
const event = single({ recurrenceRule: { frequency: 'yearly', rscale: 'chinese', byMonth: ['5L'] } });
const { h, id } = await synced(event);
expect(h.row(id)[Events.RRULE]).toBe('FREQ=YEARLY');
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-06T11:00:00Z') });
expect(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).toEqual({ kind: 'skip', reason: 'ruleNotRepresentable' });
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Festival' });
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({ title: 'Festival' });
});
it('uploads the user\'s RSVP with scheduling messages', async () => {
const event = single({
organizerCalendarAddress: 'mailto:boss@example.net',
participants: {
boss: { calendarAddress: 'mailto:boss@example.net', roles: { owner: true } },
me: { calendarAddress: 'mailto:usera@example.org', roles: { attendee: true }, participationStatus: 'needs-action' },
},
} as Partial<CalendarEventWire>);
const { h, id } = await synced(event);
h.fake.user.setAttendeeStatus(id, ME, 1);
const action = updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx));
expect(action).toMatchObject({ patch: { 'participants/me/participationStatus': 'accepted' }, sendSchedulingMessages: true });
});
it('adds and removes attendees; the organizer notifies them', async () => {
const { h, id } = await synced(weekly());
const guest = h.fake.rows('attendees', `${Attendees.EVENT_ID} = ? AND ${Attendees.ATTENDEE_EMAIL} = ?`, [id, 'guest@example.net'])[0];
h.fake.user.removeAttendee(Number(guest._id));
h.fake.user.addAttendee(id, {
[Attendees.ATTENDEE_EMAIL]: 'new@example.net',
[Attendees.ATTENDEE_NAME]: 'New',
[Attendees.ATTENDEE_TYPE]: 2,
[Attendees.ATTENDEE_RELATIONSHIP]: 1,
[Attendees.ATTENDEE_STATUS]: 3,
});
const action = updateOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
expect(action.sendSchedulingMessages).toBe(true);
const added = Object.entries(action.patch).find(([k, v]) => k.startsWith('participants/') && v);
expect(added![1]).toEqual({
'@type': 'Participant',
calendarAddress: 'mailto:new@example.net',
name: 'New',
roles: { attendee: true, optional: true },
participationStatus: 'needs-action',
expectReply: true,
});
expect(action.patch['participants/guestX']).toBeNull();
});
it('sends the whole participants map for the first attendee', async () => {
const { h, id } = await synced(single());
h.fake.user.addAttendee(id, { [Attendees.ATTENDEE_EMAIL]: 'guest@example.net', [Attendees.ATTENDEE_TYPE]: 1, [Attendees.ATTENDEE_STATUS]: 3 });
const action = updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx));
expect(Object.keys(action.patch)).toEqual(['participants']);
expect(Object.values(action.patch.participants as object)).toHaveLength(1);
expect(action.sendSchedulingMessages).toBe(true);
});
it('replaces the reminders it can show, keeping the matching keys and the others', async () => {
const { h, id } = await synced(weekly());
h.fake.user.setReminders(id, [{ minutes: 15 }, { minutes: 60, method: 2 }]);
const action = updateOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
const keys = Object.keys(action.patch);
expect(keys).toHaveLength(1);
expect(keys[0]).toMatch(/^alerts\/b[0-9a-z]{8}$/);
expect(action.patch[keys[0]]).toEqual({ '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT1H', relativeTo: 'start' }, action: 'email' });
// Reminders alone notify nobody.
expect(action.sendSchedulingMessages).toBeUndefined();
h.fake.user.setReminders(id, []);
expect(updateOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx)).patch).toEqual({ 'alerts/al1': null });
});
it('switches off the calendar defaults when reminders of an event that used them change', async () => {
const { h, id } = await synced(single({ useDefaultAlerts: true }));
h.fake.user.setReminders(id, [{ minutes: 10 }, { minutes: 30 }]);
const patch = updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch;
expect(patch.useDefaultAlerts).toBe(false);
const alerts = patch.alerts as Record<string, { trigger: { offset: string } }>;
// The default's key is kept for the reminder that matches it.
expect(alerts.d1.trigger.offset).toBe('-PT10M');
expect(Object.values(alerts).map((a) => a.trigger.offset).sort()).toEqual(['-PT10M', '-PT30M']);
});
it('does not upload reminders when Bulwark owns them', async () => {
const { h, id } = await synced(weekly(), { reminderOwner: 'bulwark' });
h.fake.user.setReminders(id, [{ minutes: 5 }]);
expect(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx).kind).toBe('clean');
});
it('ignores attendee types and relationships a lossy editor rewrote, and merges its rule part by part', async () => {
const event = weekly();
event.recurrenceRule = { frequency: 'monthly', byDay: [{ day: 'mo' }, { day: 'tu' }], bySetPosition: [-1], count: 10 };
const { h, id } = await synced(event);
const attendees = h.fake.rows('attendees', `${Attendees.EVENT_ID} = ?`, [id]).map((a) => ({
[Attendees.ATTENDEE_EMAIL]: a[Attendees.ATTENDEE_EMAIL],
[Attendees.ATTENDEE_NAME]: a[Attendees.ATTENDEE_NAME],
[Attendees.ATTENDEE_RELATIONSHIP]: 1,
[Attendees.ATTENDEE_TYPE]: 1,
[Attendees.ATTENDEE_STATUS]: a[Attendees.ATTENDEE_STATUS],
}));
const reminders = h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [id]).map((r) => ({ minutes: Number(r[Reminders.MINUTES]) }));
// Fossify rebuilds the rule without BYSETPOS and changes the count.
h.fake.user.fossifySaveEvent(id, { [Events.RRULE]: 'FREQ=MONTHLY;COUNT=12;BYDAY=MO,TU' }, attendees, reminders);
h.fake.table('events').get(id)![Events.MUTATORS] = 'org.fossify.calendar';
const action = updateOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
expect(action.patch).toEqual({
recurrenceRule: { frequency: 'monthly', count: 12, byDay: [{ day: 'mo' }, { day: 'tu' }], bySetPosition: [-1] },
});
});
it('turns a series into a single event in place', async () => {
const { h, id } = await synced(weekly());
h.fake.user.updateEvent(id, { [Events.RRULE]: null, [Events.DURATION]: null, [Events.DTEND]: utc('2026-10-05T14:30:00Z') });
const action = updateOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
expect(action.patch).toEqual({ recurrenceRule: null, recurrenceOverrides: null });
expect(action.sendSchedulingMessages).toBe(true);
});
it('moves an event between calendars of its account by swapping calendarIds', async () => {
const { h, id } = await synced(single());
h.fake.user.updateEvent(id, { [Events.CALENDAR_ID]: h.rowIds.get('w')! });
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({ 'calendarIds/b': null, 'calendarIds/w': true });
});
it('reverts edits in a read-only calendar, but lets an RSVP through', async () => {
const event = single({
calendarIds: { r: true },
organizerCalendarAddress: 'mailto:boss@example.net',
participants: {
boss: { calendarAddress: 'mailto:boss@example.net', roles: { owner: true } },
me: { calendarAddress: 'mailto:usera@example.org', roles: { attendee: true }, participationStatus: 'needs-action' },
},
} as Partial<CalendarEventWire>);
const { h, id } = await synced(event);
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Mine now' });
const plan = calendarPlanner.planUpload((await h.local('e1'))!, h.ctx);
expect(plan).toMatchObject({ kind: 'revert', reason: 'readOnly' });
if (plan.kind === 'revert') await h.apply(plan.ops);
expect(h.row(id)).toMatchObject({ [Events.TITLE]: 'Lunch', [Events.DIRTY]: 0 });
h.fake.user.setAttendeeStatus(id, ME, 2);
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({ 'participants/me/participationStatus': 'declined' });
// Without mayRSVP, the answer is put back too, and says why.
const noRsvp = { ...h.ctx, calendar: (cid: string) => (cid === 'r' ? { ...h.ctx.calendar(cid)!, myRights: { mayReadItems: true } } : h.ctx.calendar(cid)) };
expect(calendarPlanner.planUpload((await h.local('e1'))!, noRsvp)).toMatchObject({ kind: 'revert', reason: 'rsvpRefused' });
});
it('skips a poisoned item until it changes or the back-off ends', async () => {
const { h, id } = await synced(single());
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Bad' });
const local = (await h.local('e1'))!;
const { poisonFingerprint, poisonOps } = await import('../../calendar/planner');
const { nextMarker } = await import('../../engine/poison');
// The marker as the engine writes it after an invalidProperties SetError.
const marker = nextMarker(null, poisonFingerprint(local), { type: 'invalidProperties' }, h.ctx.now, { firstMs: 3_600_000, maxMs: 86_400_000 });
await h.apply(poisonOps(local, marker));
expect(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).toEqual({ kind: 'skip', reason: 'invalidProperties' });
expect(calendarPlanner.planUpload((await h.local('e1'))!, { ...h.ctx, now: h.ctx.now + 7_200_000 }).kind).toBe('upload');
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Better' });
expect(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx).kind).toBe('upload');
});
it('applies a patch the server accepts (round trip through serverApply)', async () => {
const { h, id } = await synced(weekly());
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Renamed', [Events.EVENT_LOCATION]: 'Hall' });
const action = updateOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
const server = serverApply(weekly(), action.patch);
expect(server.title).toBe('Renamed');
expect(server.locations).toEqual({ locA: { name: 'Hall', '@type': 'Location' } });
expect(server.participants).toEqual(weekly().participants);
});
});
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest';
import {
canonicalZone,
classifyLocal,
dateToUtcMidnight,
fixedOffsetZone,
isValidTimeZone,
localToUtc,
offsetAt,
parseLocalDateTime,
utcMidnightToDate,
utcToLocal,
} from '../../calendar/zoned-time';
const utc = (iso: string) => Date.parse(iso);
describe('calendar/zoned-time', () => {
it('reads LocalDateTimes like Stalwart: fraction and suffix ignored, impossible dates refused', () => {
expect(parseLocalDateTime('2026-10-25T02:30:00')).toEqual({ year: 2026, month: 10, day: 25, hour: 2, minute: 30, second: 0 });
expect(parseLocalDateTime('2026-10-25T02:30:00.250Z')).toMatchObject({ hour: 2, minute: 30, second: 0 });
expect(parseLocalDateTime('2026-10-25')).toMatchObject({ hour: 0, minute: 0 });
expect(parseLocalDateTime('2026-02-30T00:00:00')).toBeNull();
expect(parseLocalDateTime('2026-01-01T24:00:00')).toBeNull();
expect(parseLocalDateTime('garbage')).toBeNull();
});
// R3 B1: the app's localDateTimeToInstant gets these wrong in one direction or the other.
it.each([
['America/New_York', '2026-03-08T02:30:00', 'gap', '2026-03-08T07:30:00Z'],
['America/New_York', '2026-11-01T01:30:00', 'overlap', '2026-11-01T05:30:00Z'],
['Europe/Berlin', '2026-03-29T02:30:00', 'gap', '2026-03-29T01:30:00Z'],
['Europe/Berlin', '2026-10-25T02:30:00', 'overlap', '2026-10-25T00:30:00Z'],
['Australia/Sydney', '2026-04-05T02:30:00', 'overlap', '2026-04-04T15:30:00Z'],
['Australia/Sydney', '2026-10-04T02:30:00', 'gap', '2026-10-03T16:30:00Z'],
['Australia/Lord_Howe', '2026-10-04T02:15:00', 'gap', '2026-10-03T15:45:00Z'],
['Europe/Berlin', '2026-10-25T04:30:00', 'ok', '2026-10-25T03:30:00Z'],
['Asia/Kolkata', '2026-06-01T10:00:00', 'ok', '2026-06-01T04:30:00Z'],
])('resolves %s %s (%s) with RFC 5545 semantics', (zone, local, kind, expected) => {
expect(classifyLocal(local, zone)).toBe(kind);
expect(new Date(localToUtc(local, zone)!).toISOString().replace('.000', '')).toBe(expected);
});
it('moves a gap time forward by the gap and keeps the wall time of an overlap', () => {
expect(utcToLocal(localToUtc('2026-03-29T02:30:00', 'Europe/Berlin')!, 'Europe/Berlin')).toBe('2026-03-29T03:30:00');
expect(utcToLocal(localToUtc('2026-03-08T02:30:00', 'America/New_York')!, 'America/New_York')).toBe('2026-03-08T03:30:00');
// Both instants of the repeated hour read back as the same wall time.
expect(utcToLocal(utc('2026-10-25T00:30:00Z'), 'Europe/Berlin')).toBe('2026-10-25T02:30:00');
expect(utcToLocal(utc('2026-10-25T01:30:00Z'), 'Europe/Berlin')).toBe('2026-10-25T02:30:00');
});
it('does not depend on the zone the process runs in', () => {
// The device's own gap day is no special case: fields are parsed, not handed to Date.
expect(localToUtc('2026-03-29T02:30:00', 'UTC')).toBe(utc('2026-03-29T02:30:00Z'));
expect(offsetAt(utc('2026-07-01T00:00:00Z'), 'Europe/Berlin')).toBe(7_200_000);
expect(offsetAt(utc('2026-01-01T00:00:00Z'), 'America/New_York')).toBe(-18_000_000);
});
it('validates zones with Intl and names them canonically', () => {
expect(isValidTimeZone('Europe/Berlin')).toBe(true);
expect(isValidTimeZone('Mars/Olympus_Mons')).toBe(false);
expect(isValidTimeZone('')).toBe(false);
expect(isValidTimeZone(null)).toBe(false);
expect(canonicalZone('europe/berlin')).toBe('Europe/Berlin');
expect(canonicalZone('US/Eastern')).toBe('America/New_York');
});
it('names fixed-offset zones the Etc way, UTC for fractional offsets', () => {
expect(fixedOffsetZone(7_200_000)).toBe('Etc/GMT-2');
expect(fixedOffsetZone(-18_000_000)).toBe('Etc/GMT+5');
expect(fixedOffsetZone(0)).toBe('Etc/UTC');
expect(fixedOffsetZone(19_800_000)).toBe('Etc/UTC');
});
it('maps all-day dates to UTC midnight and back', () => {
expect(dateToUtcMidnight('2026-10-08T00:00:00')).toBe(utc('2026-10-08T00:00:00Z'));
expect(dateToUtcMidnight('2026-10-08T09:00:00')).toBe(utc('2026-10-08T00:00:00Z'));
expect(utcMidnightToDate(utc('2026-10-08T00:00:00Z'))).toBe('2026-10-08T00:00:00');
// Date.UTC would read year 50 as 1950.
expect(new Date(dateToUtcMidnight('0050-01-01T00:00:00')!).toISOString()).toBe('0050-01-01T00:00:00.000Z');
});
});
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest';
import { canonicalJson, deepEqual, jsonHash, parseJsonColumn } from '../common/json';
import { applyPatch, assertPatchWellFormed, ptr, ptrSegments, setInPatch } from '../common/patch';
import {
exceptionRef,
makeKeyMinter,
objectRef,
parseObjectRef,
pendingRef,
pendingUidOf,
uuidFrom,
} from '../common/ids';
function seeded(seed: number) {
return () => {
seed = (seed * 1103515245 + 12345) % 2147483648;
return seed / 2147483648;
};
}
describe('common/json', () => {
it('compares and hashes independently of key order and undefined members', () => {
const a = { b: 1, a: { d: [1, { y: 2, x: 1 }], c: undefined } };
const b = { a: { d: [1, { x: 1, y: 2 }] }, b: 1 };
expect(canonicalJson(a)).toBe(canonicalJson(b));
expect(deepEqual(a, b)).toBe(true);
expect(jsonHash(a)).toBe(jsonHash(b));
expect(deepEqual({ a: [1, 2] }, { a: [2, 1] })).toBe(false);
expect(deepEqual({ a: null }, {})).toBe(true);
});
it('reads sync columns defensively', () => {
expect(parseJsonColumn('{"v":1}')).toEqual({ v: 1 });
expect(parseJsonColumn('not json')).toBeNull();
expect(parseJsonColumn(null)).toBeNull();
});
});
describe('common/patch', () => {
it('escapes pointer segments', () => {
expect(ptr('emails', 'x/y', 'label')).toBe('emails/x~1y/label');
expect(ptrSegments('emails/x~1y/a~0b')).toEqual(['emails', 'x/y', 'a~b']);
});
it('applies patches like Stalwart, refusing a missing parent', () => {
const card = { emails: { e1: { address: 'a@x' } }, name: { full: 'A' } };
expect(applyPatch(card, { 'emails/e1/address': 'b@x', 'emails/e2': { address: 'c@x' }, 'name/full': null })).toEqual({
emails: { e1: { address: 'b@x' }, e2: { address: 'c@x' } },
name: {},
});
expect(applyPatch(card, { 'phones/p1': { number: '1' } })).toBeNull();
expect(card.emails.e1.address).toBe('a@x');
});
it('keeps patches free of overlapping pointers', () => {
expect(() => assertPatchWellFormed({ emails: {}, 'emails/e1': null })).toThrow(/prefix/);
const patch: Record<string, unknown> = { 'emails/e1/label': 'Office' };
setInPatch(patch, 'emails', { e1: { address: 'a@x', label: 'Office' } });
expect(patch).toEqual({ emails: { e1: { address: 'a@x', label: 'Office' } } });
setInPatch(patch, 'emails/e1/address', 'b@x');
expect(patch).toEqual({ emails: { e1: { address: 'b@x', label: 'Office' } } });
assertPatchWellFormed(patch);
});
});
describe('common/ids', () => {
it('mints keys Stalwart treats as plain keys', () => {
const mint = makeKeyMinter(seeded(7));
const keys = Array.from({ length: 50 }, () => mint());
for (const key of keys) {
expect(key).toMatch(/^b[0-9a-z]{8}$/);
expect(key).not.toMatch(/^k\d+$/);
}
expect(new Set(keys).size).toBe(keys.length);
const again = makeKeyMinter(() => 0);
expect(again(['b00000000'])).not.toBe('b00000000');
});
it('builds v4 uuids and object refs', () => {
expect(uuidFrom(seeded(1))).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
expect(objectRef('c', 'e9')).toBe('c/e9');
expect(parseObjectRef('c/e9')).toEqual({ accountId: 'c', id: 'e9' });
expect(parseObjectRef(pendingRef('u1'))).toBeNull();
expect(pendingUidOf(pendingRef('urn:uuid:1'))).toBe('urn:uuid:1');
expect(exceptionRef('c/e9', '2026-10-07T09:00:00')).toBe('c/e9#2026-10-07T09:00:00');
});
});
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';
import { RawContacts } from '../../android-columns';
import { sha256Hex } from '../../../lib/sha256';
import { base64ToBytes } from '../../contacts/photo';
import { appleCard, JPEG2, probeCard } from './fixtures';
import { Harness } from './harness';
async function patchOf(h: Harness, rawId: number) {
const plan = h.planner.planUpload(await h.contact(rawId), h.ctx);
return plan.kind === 'upload' && plan.actions[0].kind === 'update' ? plan.actions[0].patch : plan.kind;
}
describe('contacts planAccepted', () => {
it('writes the new shadow and baselines and clears DIRTY behind the VERSION read before the upload', async () => {
const h = new Harness();
const { id, rawId } = await h.seed(appleCard());
const email = h.rowsByKey(rawId)['emails:k1']._id as number;
h.device.user.updateData(email, { data1: 'new@example.com' });
const local = await h.contact(rawId);
const plan = h.planner.planUpload(local, h.ctx);
if (plan.kind !== 'upload') throw new Error(plan.kind);
h.send(plan.actions);
const accepted = h.planner.planAccepted(local, h.card(id), h.ctx);
expect(accepted.ops.ops[0]).toEqual({ op: 'assert', table: 'raw_contacts', id: rawId, values: { version: local.version }, expectCount: 1 });
await h.applyOk(accepted.ops);
const after = await h.contact(rawId);
expect(after.dirty).toBe(false);
expect(after.shadow?.emails).toEqual(h.card(id).emails);
expect(after.rows.find((r) => r.id === email)?.baseline?.data1).toBe('new@example.com');
expect(h.planner.planDownload(h.card(id), after, h.ctx).effect).toBe('none');
expect(h.planner.planBaselineHeal(after)).toBeNull();
});
it('keeps DIRTY and uploads only the newer edit when the contact was edited during the upload', async () => {
const h = new Harness();
const { rawId } = await h.seed(appleCard());
const rows = h.rowsByKey(rawId);
h.device.user.updateData(rows['emails:k1']._id as number, { data1: 'first@example.com' });
await h.upload(rawId, () => h.device.user.updateData(rows['phones:k1']._id as number, { data1: '+1 555 0142' }));
expect(h.dirty(rawId)).toBe(true);
expect(await patchOf(h, rawId)).toEqual({ 'phones/k1/number': '+1 555 0142' });
});
it('uploads a second edit of the same field made during the upload, and a revert to the old value', async () => {
const h = new Harness();
const { rawId } = await h.seed(appleCard());
const email = h.rowsByKey(rawId)['emails:k1']._id as number;
h.device.user.updateData(email, { data1: 'a1@example.com' });
await h.upload(rawId, () => h.device.user.updateData(email, { data1: 'a2@example.com' }));
expect(await patchOf(h, rawId)).toEqual({ 'emails/k1/address': 'a2@example.com' });
const h2 = new Harness();
const seeded = await h2.seed(appleCard());
const email2 = h2.rowsByKey(seeded.rawId)['emails:k1']._id as number;
h2.device.user.updateData(email2, { data1: 'a1@example.com' });
await h2.upload(seeded.rawId, () => h2.device.user.updateData(email2, { data1: 'anna@example.com' }));
expect(await patchOf(h2, seeded.rawId)).toEqual({ 'emails/k1/address': 'anna@example.com' });
});
it('writes the identity of a create even when the contact changed meanwhile', async () => {
const h = new Harness();
const rawId = h.device.user.insertContact('usera@example.org', [
{ mimetype: 'vnd.android.cursor.item/name', data1: 'Late Edit', data2: 'Late', data3: 'Edit' },
]);
await h.upload(rawId, () => h.device.user.star(rawId));
const local = await h.contact(rawId);
expect(local.sourceId).toMatch(/^c\//);
expect(local.pending).toBeNull();
});
it('keeps the device photo after uploading it and records the server hash', async () => {
const h = new Harness();
const { id, rawId } = await h.seed(probeCard());
h.device.user.setPhoto(rawId, JPEG2);
h.devicePhotos.set(rawId, JPEG2);
const fileId = h.rowsByKey(rawId)['media:ph'].data14;
await h.upload(rawId);
const photo = h.rowsByKey(rawId)['media:ph'];
expect(photo.data14).toBe(fileId);
expect(photo.data_sync2).toBe(`sha256:${sha256Hex(base64ToBytes(JPEG2)!)}`);
expect(h.dirty(rawId)).toBe(false);
const local = await h.contact(rawId);
expect(h.planner.planDownload(h.card(id), local, h.ctx).effect).toBe('none');
expect(h.planner.planBaselineHeal(local)).toBeNull();
});
it('clears the poison marker and the pending create once accepted', async () => {
const h = new Harness();
const { rawId } = await h.seed(appleCard());
await h.applyOk({ ref: 'x', ops: [{ op: 'update', table: 'raw_contacts', id: rawId, values: { [RawContacts.SYNC4]: JSON.stringify({ fp: 'old', type: 'invalidProperties', n: 1, until: 1 }) } }] });
h.device.user.updateData(h.rowsByKey(rawId)['emails:k1']._id as number, { data1: 'z@example.com' });
await h.upload(rawId);
expect(h.rawContact(rawId)[RawContacts.SYNC4]).toBeNull();
});
});
@@ -0,0 +1,246 @@
import { describe, expect, it } from 'vitest';
import { Data, MimeType, RawContacts } from '../../android-columns';
import { sha256Hex } from '../../../lib/sha256';
import { base64ToBytes } from '../../contacts/photo';
import type { Row } from '../../types';
import { appleCard, googleCard, JPEG, probeCard } from './fixtures';
import { Harness, JMAP } from './harness';
/** Cells as text, so the fixtures don't depend on how the provider types a column. */
function cells(row: Row | undefined, columns: string[]): Record<string, string | null> {
if (!row) throw new Error('missing row');
return Object.fromEntries(columns.map((c) => [c, row[c] === null || row[c] === undefined ? null : String(row[c])]));
}
const NAME = ['data1', 'data2', 'data3', 'data4', 'data5', 'data6', 'data7', 'data8', 'data9'];
const TYPED = ['data1', 'data2', 'data3'];
const PRIMARY = ['data1', 'data2', 'data3', 'is_primary'];
const POSTAL = ['data1', 'data2', 'data3', 'data4', 'data5', 'data6', 'data7', 'data8', 'data9', 'data10'];
const ORG = ['data1', 'data4', 'data5', 'data6'];
const photoHash = (b64: string) => `sha256:${sha256Hex(base64ToBytes(b64)!)}`;
describe('contacts download: golden rows', () => {
it('writes the probe card as one row per entry with its keys', async () => {
const h = new Harness();
h.names.set('urn:uuid:other-person', 'Other Person');
const { id, rawId } = await h.seed(probeCard());
const rows = h.rowsByKey(rawId);
expect(Object.keys(rows).sort()).toEqual([
'addresses:home', 'anniversaries:bday', 'anniversaries:wed', 'emails:work1', 'emails:x/y', 'emails:zz9',
'links:l1', 'media:ph', 'name', 'nicknames:nk', 'notes:n1', 'organizations:o1|titles:t1,r1', 'phones:fax7',
'phones:p-a', 'relatedTo:urn:uuid:other-person',
]);
expect(cells(rows.name, NAME)).toEqual({
data1: 'Dr. Probe Person', data2: 'Probe', data3: 'Person', data4: 'Dr.', data5: null, data6: null,
data7: 'Proob', data8: null, data9: null,
});
expect(cells(rows['nicknames:nk'], ['data1', 'data2'])).toEqual({ data1: 'Probby', data2: '1' });
expect(cells(rows['emails:work1'], PRIMARY)).toEqual({ data1: 'probe@work.example', data2: '0', data3: 'Office', is_primary: '1' });
expect(cells(rows['emails:x/y'], PRIMARY)).toEqual({ data1: 'slash@example.org', data2: '3', data3: null, is_primary: '0' });
expect(cells(rows['emails:zz9'], PRIMARY)).toEqual({ data1: 'private@example.org', data2: '1', data3: null, is_primary: '0' });
expect(cells(rows['phones:p-a'], TYPED)).toEqual({ data1: '+49 30 1234', data2: '2', data3: null });
expect(cells(rows['phones:fax7'], TYPED)).toEqual({ data1: '+49 30 9999', data2: '4', data3: null });
expect(cells(rows['addresses:home'], POSTAL)).toEqual({
data1: 'Main St 12, 10115 Berlin, Germany', data2: '1', data3: null, data4: 'Main St 12 4b', data5: null,
data6: null, data7: 'Berlin', data8: null, data9: '10115', data10: 'Germany',
});
expect(cells(rows['organizations:o1|titles:t1,r1'], ORG)).toEqual({ data1: 'ACME', data4: 'Chief Prober', data5: 'R&D', data6: 'Lead' });
expect(cells(rows['links:l1'], TYPED)).toEqual({ data1: 'https://probe.example', data2: '5', data3: null });
expect(cells(rows['anniversaries:bday'], TYPED)).toEqual({ data1: '--03-14', data2: '3', data3: null });
expect(cells(rows['anniversaries:wed'], TYPED)).toEqual({ data1: '2010-06-01', data2: '1', data3: null });
expect(cells(rows['relatedTo:urn:uuid:other-person'], TYPED)).toEqual({ data1: 'Other Person', data2: '6', data3: null });
expect(cells(rows['notes:n1'], ['data1'])).toEqual({ data1: 'first note' });
expect(rows['media:ph'].data_sync2).toBe(photoHash(JPEG));
// A 1×1 photo is kept as the thumbnail alone (no PHOTO_FILE_ID), and read from it.
expect(rows['media:ph'].data14).toBeNull();
expect((await h.port.readPhoto(rawId, 512))?.jpegBase64).toBe(JPEG);
const raw = h.rawContact(rawId);
expect(raw).toMatchObject({
[RawContacts.SOURCE_ID]: `${JMAP}/${id}`,
[RawContacts.SYNC1]: `${JMAP}/${h.book}`,
[RawContacts.SYNC3]: null,
[RawContacts.DIRTY]: 0,
});
expect(Number(raw[RawContacts.RAW_CONTACT_IS_READ_ONLY])).toBe(0);
});
it('keeps the photo out of the shadow: its data URI becomes the hash of its bytes', async () => {
const h = new Harness();
const { id, rawId } = await h.seed(probeCard());
const shadow = JSON.parse(h.rawContact(rawId)[RawContacts.SYNC2] as string);
expect(shadow.media.ph.uri).toBe(photoHash(JPEG));
expect({ ...shadow, media: undefined, '~memberOf': undefined }).toEqual({ ...h.card(id), media: undefined });
});
it('records each row as stored, so a clean contact needs no baseline heal', async () => {
const h = new Harness();
for (const card of [probeCard(), appleCard(), googleCard(), { name: { full: 'Madonna' } }]) {
const { rawId } = await h.seed(card);
const local = await h.contact(rawId);
expect(h.planner.planBaselineHeal(local)).toBeNull();
for (const row of local.rows) if (row.mimetype !== MimeType.PHOTO) expect(row.baseline).not.toBeNull();
}
});
it('maps an Apple card with positional keys, filling the formatted address itself', async () => {
const h = new Harness();
const { rawId } = await h.seed(appleCard());
const rows = h.rowsByKey(rawId);
expect(cells(rows['emails:k1'], PRIMARY)).toEqual({ data1: 'anna@example.com', data2: '1', data3: null, is_primary: '1' });
expect(cells(rows['emails:k2'], PRIMARY)).toEqual({ data1: 'anna@work.example', data2: '2', data3: null, is_primary: '0' });
expect(cells(rows['phones:k1'], TYPED)).toMatchObject({ data2: '2' });
expect(cells(rows['phones:k2'], TYPED)).toMatchObject({ data2: '3' });
expect(cells(rows['phones:k3'], TYPED)).toMatchObject({ data2: '4' });
expect(cells(rows['addresses:k1'], POSTAL)).toEqual({
data1: '1 Infinite Loop, 95014 Cupertino, CA, USA', data2: '2', data3: null, data4: '1 Infinite Loop', data5: null,
data6: null, data7: 'Cupertino', data8: 'CA', data9: '95014', data10: 'USA',
});
expect(cells(rows['anniversaries:k1'], TYPED)).toEqual({ data1: '1980-05-04', data2: '3', data3: null });
expect(Object.keys(rows).some((k) => k.startsWith('onlineServices'))).toBe(false);
});
it('maps a Google card: custom display name, legacy cell phone, profile link, department list, relation by name', async () => {
const h = new Harness();
const { rawId } = await h.seed(googleCard());
const rows = h.rowsByKey(rawId);
expect(cells(rows.name, NAME)).toEqual({
data1: 'Groß, Günther', data2: 'Günther', data3: 'Groß', data4: 'Prof.', data5: 'Karl', data6: 'PhD',
data7: null, data8: null, data9: null,
});
expect(cells(rows['nicknames:k1'], ['data1'])).toEqual({ data1: 'Gü' });
expect(cells(rows['nicknames:k2'], ['data1'])).toEqual({ data1: 'GKG' });
expect(cells(rows['phones:k1'], TYPED)).toEqual({ data1: '0171 2345678', data2: '2', data3: null });
// The label names a type Android has.
expect(cells(rows['links:k1'], TYPED)).toEqual({ data1: 'https://example.org/~guenther', data2: '3', data3: null });
expect(cells(rows['organizations:k1|titles:k1,'], ORG)).toEqual({ data1: 'Uni Beispiel', data4: 'Professor', data5: 'Informatik, AG Sync', data6: null });
expect(cells(rows['relatedTo:Maria Groß'], TYPED)).toEqual({ data1: 'Maria Groß', data2: '14', data3: null });
expect(cells(rows['anniversaries:k1'], TYPED)).toEqual({ data1: '--08-15', data2: '1', data3: null });
});
it('writes a name with only `full` as the display name and lets the provider split it', async () => {
const h = new Harness();
const { rawId } = await h.seed({ name: { full: 'Madonna' } });
expect(cells(h.rowsByKey(rawId).name, ['data1', 'data2', 'data3'])).toEqual({ data1: 'Madonna', data2: 'Madonna', data3: null });
});
it('writes text longer than the provider keeps already cut, and never uploads the cut column', async () => {
const h = new Harness();
const long = 'x'.repeat(12_000);
const { id, rawId } = await h.seed({ name: { full: 'Long Note' }, notes: { n1: { note: long } }, phones: { p1: { number: '1'.repeat(1200) } } });
const rows = h.rowsByKey(rawId);
expect((rows['notes:n1'].data1 as string).length).toBe(10 * 1024);
expect((rows['phones:p1'].data1 as string).length).toBe(1000);
expect(h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx).effect).toBe('none');
h.device.user.updateData(rows['notes:n1']._id as number, { data1: `${rows['notes:n1'].data1 as string}!` });
expect(h.planner.planUpload(await h.contact(rawId), h.ctx).kind).toBe('clean');
});
it('marks a card of read-only books read-only', async () => {
const h = new Harness();
h.readOnlyBooks.add(h.book);
const { rawId } = await h.seed(appleCard());
expect(Number(h.rawContact(rawId)[RawContacts.RAW_CONTACT_IS_READ_ONLY])).toBe(1);
});
});
describe('contacts download: echoes and updates', () => {
it.each([['probe', probeCard], ['apple', appleCard], ['google', googleCard]])('writes nothing for an unchanged %s card', async (_n, make) => {
const h = new Harness();
const { id, rawId } = await h.seed(make());
const plan = h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx);
expect(plan).toMatchObject({ effect: 'none', writes: 0, conflicts: 0 });
expect(plan.ops.ops).toEqual([]);
});
it('writes nothing when the shadow itself comes back (a re-plan after a group change)', async () => {
const h = new Harness();
const { rawId } = await h.seed(probeCard());
const local = await h.contact(rawId);
const plan = h.planner.planDownload(local.shadow!, local, h.ctx);
expect(plan.effect).toBe('none');
});
it('rewrites only the row of the entry the server changed, behind VERSION and DIRTY', async () => {
const h = new Harness();
const { id, rawId } = await h.seed(appleCard());
h.server.serverUpdate('ContactCard', JMAP, id, { 'emails/k2/address': 'anna@new-work.example' });
const local = await h.contact(rawId);
const plan = h.planner.planDownload(h.card(id), local, h.ctx);
const [assert, raw, ...data] = plan.ops.ops;
expect(assert).toEqual({ op: 'assert', table: 'raw_contacts', id: rawId, values: { version: local.version, dirty: 0 }, expectCount: 1 });
expect(raw).toMatchObject({ op: 'update', table: 'raw_contacts', values: { [RawContacts.SYNC2]: expect.any(String) } });
expect(data).toHaveLength(1);
expect(data[0]).toMatchObject({ op: 'update', table: 'data', expectCount: 1, values: { data1: 'anna@new-work.example', data_sync1: 'emails:k2' } });
await h.applyOk(plan.ops);
expect(h.rowsByKey(rawId)['emails:k2'].data1).toBe('anna@new-work.example');
expect(h.dirty(rawId)).toBe(false);
});
it('inserts entries the server added and deletes the ones it removed', async () => {
const h = new Harness();
const { id, rawId } = await h.seed(appleCard());
h.server.serverUpdate('ContactCard', JMAP, id, { 'phones/k3': null, 'links': { k1: { uri: 'https://anna.example' } } });
await h.download(id);
const rows = h.rowsByKey(rawId);
expect(rows['phones:k3']).toBeUndefined();
expect(cells(rows['links:k1'], TYPED)).toEqual({ data1: 'https://anna.example', data2: '7', data3: null });
expect(h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx).effect).toBe('none');
});
it('follows keys Stalwart shifted (a CardDAV insert at the front) by content, with no rewrite of the rows', async () => {
const h = new Harness();
const { id, rawId } = await h.seed(appleCard());
const before = h.rowsByKey(rawId);
h.server.serverUpdate('ContactCard', JMAP, id, {
emails: {
k1: { address: 'first@example.com' },
k2: { address: 'anna@example.com', contexts: { private: true }, pref: 1 },
k3: { address: 'anna@work.example', contexts: { work: true } },
},
});
const plan = h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx);
const data = plan.ops.ops.filter((o) => o.op !== 'assert' && o.op !== 'syncState' && o.table === 'data');
expect(data).toHaveLength(3);
const renames = data.filter((o) => o.op === 'update');
for (const r of renames) expect(Object.keys((r as { values: object }).values).sort()).toEqual(['data_sync1', 'data_sync3']);
await h.applyOk(plan.ops);
const rows = h.rowsByKey(rawId);
expect(rows['emails:k2']._id).toBe(before['emails:k1']._id);
expect(rows['emails:k3']._id).toBe(before['emails:k2']._id);
expect(rows['emails:k1'].data1).toBe('first@example.com');
});
it('fails as a whole when the user edits the contact between read and write', async () => {
const h = new Harness();
const { id, rawId } = await h.seed(appleCard());
h.server.serverUpdate('ContactCard', JMAP, id, { 'notes/k1/note': 'from the server' });
const plan = h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx);
const phone = h.rowsByKey(rawId)['phones:k1']._id as number;
h.device.beforeNextBatch(() => h.device.user.updateData(phone, { [Data.DATA1]: '+1 555 0199' }));
const res = await h.apply(plan.ops);
expect(res).toMatchObject({ ok: false, reason: 'assert' });
expect(h.rowsByKey(rawId)['notes:k1'].data1).toBe('Met at WWDC');
});
it('adopts its own create whose identity was never written, keeping the local rows', async () => {
const h = new Harness();
const rawId = h.device.user.insertContact('usera@example.org', [
{ [Data.MIMETYPE]: MimeType.STRUCTURED_NAME, [Data.DATA1]: 'New Person', [Data.DATA2]: 'New', [Data.DATA3]: 'Person' },
{ [Data.MIMETYPE]: MimeType.EMAIL, [Data.DATA1]: 'new@example.org', [Data.DATA2]: 1 },
]);
const plan = await h.planUpload(rawId);
if (plan.kind !== 'upload' || plan.actions[0].kind !== 'create') throw new Error(`expected a create, got ${plan.kind}`);
const cardId = h.send(plan.actions)!;
// The response was lost: the next download finds the card and the pending uid.
const local = await h.contact(rawId);
const adopt = h.planner.planDownload(h.card(cardId), local, h.ctx);
expect(adopt.effect).toBe('update');
expect(adopt.stillDirty).toBe(true);
await h.applyOk(adopt.ops);
const after = await h.contact(rawId);
expect(after).toMatchObject({ sourceId: `${JMAP}/${cardId}`, pending: null, dirty: true });
expect(h.planner.planUpload(after, h.ctx).kind).toBe('clean');
});
});
@@ -0,0 +1,142 @@
/**
* Server cards for the contacts planner tests. `probeCard` is what the local
* Stalwart returned for a card created over JMAP with client keys (live probe
* output); the others are shaped like cards synced in over CardDAV from
* Apple and Google clients, whose keys Stalwart generates by position.
*/
/** A 1×1 JPEG. */
export const JPEG = '/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////wgALCAABAAEBAREA/8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABPxA=';
/** Another picture's bytes (any base64 does for the fakes). */
export const JPEG2 = '/9j/4AAQSkZJRgABAgAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==';
export function probeCard(): Record<string, unknown> {
return {
uid: 'urn:uuid:probe-1790463389675',
kind: 'individual',
name: {
full: 'Dr. Probe Person',
phoneticSystem: 'ipa',
components: [
{ kind: 'title', value: 'Dr.' },
{ kind: 'given', value: 'Probe', phonetic: 'Proob' },
{ kind: 'surname', value: 'Person' },
],
isOrdered: true,
},
nicknames: { nk: { name: 'Probby' } },
emails: {
work1: { address: 'probe@work.example', contexts: { work: true }, pref: 1, label: 'Office' },
'x/y': { address: 'slash@example.org' },
zz9: { address: 'private@example.org', contexts: { private: true } },
},
phones: {
'p-a': { number: '+49 30 1234', contexts: { private: true }, features: { voice: true, mobile: true } },
fax7: { number: '+49 30 9999', contexts: { work: true }, features: { fax: true } },
},
addresses: {
home: {
contexts: { private: true },
coordinates: 'geo:52.5,13.4',
timeZone: 'Europe/Berlin',
countryCode: 'DE',
full: 'Main St 12, 10115 Berlin, Germany',
components: [
{ kind: 'name', value: 'Main St' },
{ kind: 'separator', value: ' ' },
{ kind: 'number', value: '12' },
{ kind: 'locality', value: 'Berlin' },
{ kind: 'postcode', value: '10115' },
{ kind: 'country', value: 'Germany' },
{ kind: 'apartment', value: '4b' },
],
isOrdered: true,
},
},
organizations: { o1: { name: 'ACME', units: [{ name: 'R&D' }] } },
titles: {
t1: { name: 'Chief Prober', kind: 'title', organizationId: 'o1' },
r1: { name: 'Lead', kind: 'role', organizationId: 'o1' },
},
links: { l1: { uri: 'https://probe.example', contexts: { work: true } } },
onlineServices: { im1: { uri: 'xmpp:probe@example.org', service: 'XMPP' } },
anniversaries: {
bday: { date: { '@type': 'PartialDate', month: 3, day: 14 }, kind: 'birth' },
wed: { date: { '@type': 'PartialDate', year: 2010, month: 6, day: 1 }, kind: 'wedding' },
},
relatedTo: { 'urn:uuid:other-person': { relation: { friend: true } } },
notes: { n1: { note: 'first note' }, n2: { note: 'second note' } },
media: { ph: { uri: `data:image/jpeg;base64,${JPEG}`, kind: 'photo', mediaType: 'image/jpeg' } },
keywords: { vip: true },
personalInfo: { pi1: { value: 'probing', kind: 'hobby' } },
preferredLanguages: { lang1: { language: 'de', pref: 1 } },
speakToAs: { grammaticalGender: 'neuter' },
};
}
/** Apple Contacts over CardDAV: no PROP-IDs, so `k1…kN`; an address without `full`. */
export function appleCard(): Record<string, unknown> {
return {
uid: 'urn:uuid:5F2C9B1E-APPLE',
kind: 'individual',
prodId: '-//Apple Inc.//iPhone OS 18.0//EN',
name: {
components: [{ kind: 'given', value: 'Anna' }, { kind: 'surname', value: 'Apfel' }],
isOrdered: true,
full: 'Anna Apfel',
},
emails: {
k1: { address: 'anna@example.com', contexts: { private: true }, pref: 1 },
k2: { address: 'anna@work.example', contexts: { work: true } },
},
phones: {
k1: { number: '+1 555 0100', features: { mobile: true, voice: true } },
k2: { number: '+1 555 0101', contexts: { work: true }, features: { voice: true } },
k3: { number: '+1 555 0102', contexts: { work: true }, features: { fax: true } },
},
addresses: {
k1: {
components: [
{ kind: 'name', value: '1 Infinite Loop' },
{ kind: 'locality', value: 'Cupertino' },
{ kind: 'region', value: 'CA' },
{ kind: 'postcode', value: '95014' },
{ kind: 'country', value: 'USA' },
],
isOrdered: true,
contexts: { work: true },
},
},
anniversaries: { k1: { kind: 'birth', date: { '@type': 'PartialDate', year: 1980, month: 5, day: 4 } } },
notes: { k1: { note: 'Met at WWDC' } },
onlineServices: { k1: { service: 'Skype', user: 'anna.apfel' } },
keywords: { friends: true },
};
}
/** Google Contacts over CardDAV: a legacy `cell` feature, a custom display name, two nicknames. */
export function googleCard(): Record<string, unknown> {
return {
uid: 'google-8a7d2f',
kind: 'individual',
name: {
components: [
{ kind: 'title', value: 'Prof.' },
{ kind: 'given', value: 'Günther' },
{ kind: 'given2', value: 'Karl' },
{ kind: 'surname', value: 'Groß' },
{ kind: 'credential', value: 'PhD' },
],
isOrdered: true,
full: 'Groß, Günther',
},
nicknames: { k1: { name: 'Gü' }, k2: { name: 'GKG' } },
phones: { k1: { number: '0171 2345678', features: { cell: true } } },
links: { k1: { uri: 'https://example.org/~guenther', label: 'profile' } },
organizations: { k1: { name: 'Uni Beispiel', units: [{ name: 'Informatik' }, { name: 'AG Sync' }] } },
titles: { k1: { name: 'Professor', kind: 'title', organizationId: 'k1' } },
relatedTo: { 'Maria Groß': { relation: { spouse: true } } },
anniversaries: { k1: { kind: 'wedding', date: { '@type': 'PartialDate', month: 8, day: 15 } } },
speakToAs: { grammaticalGender: 'masculine' },
};
}
@@ -0,0 +1,336 @@
import { describe, expect, it } from 'vitest';
import { Data, Groups, MimeType } from '../../android-columns';
import type { ContactCardWire, OpGroup, UploadPlan } from '../../planner';
import { appleCard, googleCard } from './fixtures';
import { ACCOUNT, Harness, JMAP } from './harness';
function actionsOf(plan: UploadPlan<ContactCardWire>) {
if (plan.kind !== 'upload') throw new Error(`expected an upload, got ${plan.kind}`);
return plan.actions;
}
function opsOf(plan: UploadPlan<ContactCardWire>): OpGroup {
if (plan.kind === 'upload' || plan.kind === 'skip') throw new Error(`expected ops, got ${plan.kind}`);
return plan.ops;
}
async function localGroup(h: Harness, groupId: string) {
const g = (await h.groups()).find((x) => x.sourceId === `${JMAP}/${groupId}`);
if (!g) throw new Error(`no group row for ${groupId}`);
return g;
}
/** A group card in an address book this user may only read, on the device. */
async function readOnlyGroup(h: Harness, members: Record<string, boolean> = {}) {
const shared = h.server.addAddressBook(JMAP, { name: 'Shared' });
h.readOnlyBooks.add(shared);
const gid = h.addCard({ uid: 'urn:uuid:group-shared', kind: 'group', name: { full: 'Shared' }, members, addressBookIds: { [shared]: true } });
await h.download(gid);
return gid;
}
/** A group card holding Anna (apple card), both on the device. */
async function withGroup(members: Record<string, boolean> = { [appleCard().uid as string]: true }) {
const h = new Harness();
const gid = h.addCard({ uid: 'urn:uuid:group-friends', kind: 'group', name: { full: 'Friends' }, members });
await h.download(gid);
const { id, rawId } = await h.seed(appleCard());
return { h, gid, id, rawId };
}
describe('contacts groups: download', () => {
it('writes a group card as a visible group row, and nothing for its echo', async () => {
const { h, gid } = await withGroup();
const row = h.device.rows('groups', 'sourceid = ?', [`${JMAP}/${gid}`])[0];
expect(row).toMatchObject({ [Groups.TITLE]: 'Friends', [Groups.GROUP_VISIBLE]: 1, [Groups.DIRTY]: 0 });
const plan = h.planner.planGroupDownload(h.card(gid), await localGroup(h, gid), h.ctx);
expect(plan).toMatchObject({ effect: 'none', ops: { ops: [] } });
});
it('writes one membership row per group card that lists the contact', async () => {
const { h, gid, rawId } = await withGroup();
expect(h.membershipGroups(rawId)).toEqual([`${JMAP}/${gid}`]);
const local = await h.contact(rawId);
expect(local.rows.find((r) => r.mimetype === MimeType.GROUP_MEMBERSHIP)?.key).toBe(`members:${JMAP}/${gid}`);
expect(local.shadow?.['~memberOf']).toEqual([`${JMAP}/${gid}`]);
});
it('leaves memberships alone when the engine has no group index', async () => {
const h = new Harness();
h.groupIndex = false;
const gid = h.addCard({ uid: 'urn:uuid:g', kind: 'group', name: { full: 'G' }, members: { [appleCard().uid as string]: true } });
await h.download(gid);
const { rawId } = await h.seed(appleCard());
expect(h.membershipGroups(rawId)).toEqual([]);
expect((await h.contact(rawId)).shadow?.['~memberOf']).toBeUndefined();
});
it('follows the group card when members change on the server (a re-plan of the unchanged contact)', async () => {
const { h, gid, rawId } = await withGroup();
h.server.serverUpdate('ContactCard', JMAP, gid, { [`members/${appleCard().uid as string}`]: null });
await h.download(gid);
let local = await h.contact(rawId);
await h.applyOk(h.planner.planDownload(local.shadow!, local, h.ctx).ops);
expect(h.membershipGroups(rawId)).toEqual([]);
h.server.serverUpdate('ContactCard', JMAP, gid, { members: { [appleCard().uid as string]: true } });
await h.download(gid);
local = await h.contact(rawId);
await h.applyOk(h.planner.planDownload(local.shadow!, local, h.ctx).ops);
expect(h.membershipGroups(rawId)).toEqual([`${JMAP}/${gid}`]);
});
it('lets the server win when both sides renamed the group', async () => {
const { h, gid } = await withGroup();
const g = await localGroup(h, gid);
h.device.user.updateGroup(g.groupId, { [Groups.TITLE]: 'Mates' });
h.server.serverUpdate('ContactCard', JMAP, gid, { 'name/full': 'Pals' });
const plan = h.planner.planGroupDownload(h.card(gid), await localGroup(h, gid), h.ctx);
expect(plan.conflicts).toBe(1);
await h.applyOk(plan.ops);
expect((await localGroup(h, gid)).title).toBe('Pals');
});
});
describe('contacts groups: membership edits', () => {
it('adds the contact to a group card with `members/<uid>: true`, keeping it dirty until the server has it', async () => {
const h = new Harness();
const other = h.addCard({ uid: 'urn:uuid:g2', kind: 'group', name: { full: 'Work' }, members: { 'urn:uuid:someone': true } });
await h.download(other);
const { rawId } = await h.seed(appleCard());
const g = await localGroup(h, other);
h.device.user.addToGroup(rawId, g.groupId);
expect((await h.upload(rawId)).kind).toBe('clean');
expect(h.dirty(rawId)).toBe(true);
const actions = h.planner.planMembershipUploads([await h.contact(rawId)], await h.groups(), h.ctx);
expect(actions).toEqual([{ kind: 'update', id: other, patch: { [`members/${appleCard().uid as string}`]: true } }]);
h.send(actions);
const accepted = h.planner.planGroupAccepted(g, h.card(other), h.ctx);
await h.applyOk(accepted.ops);
await h.upload(rawId);
expect(h.dirty(rawId)).toBe(false);
expect(h.planner.planMembershipUploads([await h.contact(rawId)], await h.groups(), h.ctx)).toEqual([]);
});
it('keeps a rename the server does not have when a membership patch of the group is accepted', async () => {
const h = new Harness();
const gid = h.addCard({ uid: 'urn:uuid:g2', kind: 'group', name: { full: 'Work' }, members: { 'urn:uuid:someone': true } });
await h.download(gid);
const { rawId } = await h.seed(appleCard());
const g = await localGroup(h, gid);
h.device.user.addToGroup(rawId, g.groupId);
await h.upload(rawId);
// Renamed after the group's own upload was planned, before the membership phase read it.
h.device.user.updateGroup(g.groupId, { [Groups.TITLE]: 'Colleagues' });
const read = await localGroup(h, gid);
h.send(h.planner.planMembershipUploads([await h.contact(rawId)], await h.groups(), h.ctx));
await h.applyOk(h.planner.planGroupAccepted(read, h.card(gid), h.ctx).ops);
expect(await localGroup(h, gid)).toMatchObject({ title: 'Colleagues', dirty: true, shadow: { members: { [appleCard().uid as string]: true } } });
expect(h.planner.planGroupUpload(await localGroup(h, gid), h.ctx)).toEqual({
kind: 'upload', actions: [{ kind: 'update', id: gid, patch: { 'name/full': 'Colleagues' } }],
});
});
it('keeps the groups of a contact created on the device and adds it to their cards', async () => {
const h = new Harness();
const gid = h.addCard({ uid: 'urn:uuid:g7', kind: 'group', name: { full: 'Team' }, members: { 'urn:uuid:someone': true } });
await h.download(gid);
const g = await localGroup(h, gid);
const rawId = h.device.user.insertContact(ACCOUNT, [
{ [Data.MIMETYPE]: MimeType.STRUCTURED_NAME, data1: 'New Person', data2: 'New', data3: 'Person' },
{ [Data.MIMETYPE]: MimeType.GROUP_MEMBERSHIP, [Data.DATA1]: g.groupId },
]);
await h.upload(rawId);
expect(h.membershipGroups(rawId)).toEqual([`${JMAP}/${gid}`]);
expect(h.dirty(rawId)).toBe(true);
const uid = (await h.contact(rawId)).shadow!.uid as string;
const actions = h.planner.planMembershipUploads([await h.contact(rawId)], await h.groups(), h.ctx);
expect(actions).toEqual([{ kind: 'update', id: gid, patch: { [`members/${uid}`]: true } }]);
h.send(actions);
await h.applyOk(h.planner.planGroupAccepted(g, h.card(gid), h.ctx).ops);
await h.upload(rawId);
expect(h.dirty(rawId)).toBe(false);
expect(h.membershipGroups(rawId)).toEqual([`${JMAP}/${gid}`]);
});
it('sends the whole map for the first member of an empty group', async () => {
const h = new Harness();
const empty = h.addCard({ uid: 'urn:uuid:g3', kind: 'group', name: { full: 'Empty' } });
await h.download(empty);
const { rawId } = await h.seed(appleCard());
h.device.user.addToGroup(rawId, (await localGroup(h, empty)).groupId);
const actions = h.planner.planMembershipUploads([await h.contact(rawId)], await h.groups(), h.ctx);
expect(actions).toEqual([{ kind: 'update', id: empty, patch: { members: { [appleCard().uid as string]: true } } }]);
});
it('removes the contact with `members/<uid>: null` when its membership row was deleted in place', async () => {
const { h, gid, rawId } = await withGroup({ [appleCard().uid as string]: true, 'urn:uuid:someone': true });
const membership = (await h.contact(rawId)).rows.find((r) => r.mimetype === MimeType.GROUP_MEMBERSHIP)!;
h.device.user.deleteData(membership.id);
const actions = h.planner.planMembershipUploads([await h.contact(rawId)], await h.groups(), h.ctx);
expect(actions).toEqual([{ kind: 'update', id: gid, patch: { [`members/${appleCard().uid as string}`]: null } }]);
});
it('uploads nothing when an editor re-inserted the same memberships without keys', async () => {
const { h, rawId } = await withGroup();
const rows = h.device.rows('data', 'raw_contact_id = ?', [rawId]).filter((r) => r[Data.MIMETYPE] === MimeType.GROUP_MEMBERSHIP);
h.device.user.fossifySave(rawId, null, rows.map((r) => ({ [Data.MIMETYPE]: MimeType.GROUP_MEMBERSHIP, data1: r.data1 })));
expect(h.planner.planMembershipUploads([await h.contact(rawId)], await h.groups(), h.ctx)).toEqual([]);
});
it('puts back an addition to a group in read-only books instead of keeping the contact dirty', async () => {
const h = new Harness();
const gid = await readOnlyGroup(h, { 'urn:uuid:someone': true });
const { rawId } = await h.seed(appleCard());
h.device.user.addToGroup(rawId, (await localGroup(h, gid)).groupId);
expect(await h.upload(rawId)).toMatchObject({ kind: 'revert', reason: 'groupNotWritable' });
expect(h.membershipGroups(rawId)).toEqual([]);
expect(h.dirty(rawId)).toBe(false);
expect(h.planner.planMembershipUploads([await h.contact(rawId)], await h.groups(), h.ctx)).toEqual([]);
});
it('puts back a removal from a group in read-only books', async () => {
const h = new Harness();
const gid = await readOnlyGroup(h, { [appleCard().uid as string]: true });
const { rawId } = await h.seed(appleCard());
const membership = (await h.contact(rawId)).rows.find((r) => r.mimetype === MimeType.GROUP_MEMBERSHIP)!;
h.device.user.deleteData(membership.id);
expect(await h.upload(rawId)).toMatchObject({ kind: 'revert', reason: 'groupNotWritable' });
expect(h.membershipGroups(rawId)).toEqual([`${JMAP}/${gid}`]);
expect(h.dirty(rawId)).toBe(false);
});
it('puts back an addition to a group of another JMAP account', async () => {
const h = new Harness();
h.server.addAccount('t', { name: 'Team' });
const book = h.server.addAddressBook('t', { name: 'Team contacts' });
const team = h.server.addCard('t', { uid: 'urn:uuid:team', kind: 'group', name: { full: 'Team' }, addressBookIds: { [book]: true } });
const card = h.server.get('ContactCard', 't', team) as unknown as ContactCardWire;
await h.applyOk(h.planner.planGroupDownload(card, null, { ...h.ctx, jmapAccountId: 't' }).ops);
const { rawId } = await h.seed(appleCard());
h.device.user.addToGroup(rawId, (await h.groups()).find((g) => g.sourceId === `t/${team}`)!.groupId);
expect(h.membershipGroups(rawId)).toEqual([`t/${team}`]);
expect(await h.upload(rawId)).toMatchObject({ kind: 'revert', reason: 'groupNotWritable' });
expect(h.membershipGroups(rawId)).toEqual([]);
expect(h.dirty(rawId)).toBe(false);
});
it('still uploads a read-only contact joining a writable group (the group card is what changes)', async () => {
const h = new Harness();
const other = h.server.addAddressBook(JMAP, { name: 'Shared' });
h.readOnlyBooks.add(other);
const gid = h.addCard({ uid: 'urn:uuid:g5', kind: 'group', name: { full: 'Mine' }, members: { 'urn:uuid:someone': true } });
await h.download(gid);
const { rawId } = await h.seed({ ...appleCard(), addressBookIds: { [other]: true } });
h.device.user.addToGroup(rawId, (await localGroup(h, gid)).groupId);
const plan = await h.upload(rawId);
expect(plan.kind).toBe('revert');
// Nothing of its own was put back: nothing to report.
expect(plan).not.toHaveProperty('reason');
expect(h.membershipGroups(rawId)).toEqual([`${JMAP}/${gid}`]);
expect(h.dirty(rawId)).toBe(true);
expect(h.planner.planMembershipUploads([await h.contact(rawId)], await h.groups(), h.ctx)).toEqual([
{ kind: 'update', id: gid, patch: { [`members/${appleCard().uid as string}`]: true } },
]);
});
it('uploads the edits it can next to a membership it puts back, and settles', async () => {
const h = new Harness();
const gid = await readOnlyGroup(h);
const { id, rawId } = await h.seed(appleCard());
h.device.user.addToGroup(rawId, (await localGroup(h, gid)).groupId);
h.device.user.updateData(h.rowsByKey(rawId)['emails:k1']._id as number, { data1: 'anna@new.example' });
const plan = await h.upload(rawId);
expect(actionsOf(plan)).toEqual([{ kind: 'update', id, patch: { 'emails/k1/address': 'anna@new.example' } }]);
expect(h.membershipGroups(rawId)).toEqual([]);
expect(h.dirty(rawId)).toBe(false);
});
it('does not take a server-side addition for a local removal while the contact is dirty', async () => {
const { h, gid } = await withGroup({});
const { rawId } = await h.seed(googleCard());
h.device.user.updateData(h.rowsByKey(rawId)['phones:k1']._id as number, { data1: '0171 999' });
h.server.serverUpdate('ContactCard', JMAP, gid, { members: { [googleCard().uid as string]: true } });
await h.download(gid);
const local = await h.contact(rawId);
await h.applyOk(h.planner.planDownload(local.shadow!, local, h.ctx).ops);
expect(h.membershipGroups(rawId)).toEqual([`${JMAP}/${gid}`]);
expect(h.planner.planMembershipUploads([await h.contact(rawId)], await h.groups(), h.ctx)).toEqual([]);
});
});
describe('contacts groups: device edits of groups', () => {
it('uploads a rename as `name/full`', async () => {
const { h, gid } = await withGroup();
const g = await localGroup(h, gid);
h.device.user.updateGroup(g.groupId, { [Groups.TITLE]: 'Close friends' });
const plan = h.planner.planGroupUpload(await localGroup(h, gid), h.ctx);
expect(plan).toEqual({ kind: 'upload', actions: [{ kind: 'update', id: gid, patch: { 'name/full': 'Close friends' } }] });
h.send(actionsOf(plan));
const accepted = h.planner.planGroupAccepted(await localGroup(h, gid), h.card(gid), h.ctx);
await h.applyOk(accepted.ops);
expect(await localGroup(h, gid)).toMatchObject({ dirty: false, title: 'Close friends' });
expect(h.planner.planGroupDownload(h.card(gid), await localGroup(h, gid), h.ctx).effect).toBe('none');
});
it('claims and creates a group made on the device', async () => {
const h = new Harness();
const groupId = h.device.user.insertGroup('usera@example.org', 'Book club');
const decode = async () => (await h.groups()).find((g) => g.groupId === groupId)!;
const claim = h.planner.planGroupUpload(await decode(), h.ctx);
expect(claim.kind).toBe('claim');
await h.applyOk(opsOf(claim));
const pending = (await decode()).pending!;
const plan = h.planner.planGroupUpload(await decode(), h.ctx);
expect(plan).toEqual({
kind: 'upload',
actions: [{
kind: 'create', uid: pending.uid, collectionId: h.book,
object: { '@type': 'Card', version: '1.0', uid: pending.uid, kind: 'group', addressBookIds: { [h.book]: true }, name: { full: 'Book club' } },
}],
});
const id = h.send(actionsOf(plan))!;
await h.applyOk(h.planner.planGroupAccepted(await decode(), h.card(id), h.ctx).ops);
expect(await decode()).toMatchObject({ sourceId: `${JMAP}/${id}`, dirty: false, pending: null });
});
it('destroys a group deleted on the device', async () => {
const { h, gid } = await withGroup();
h.device.user.deleteGroup((await localGroup(h, gid)).groupId);
expect(h.planner.planGroupUpload(await localGroup(h, gid), h.ctx)).toEqual({
kind: 'upload', actions: [{ kind: 'destroy', id: gid, uid: 'urn:uuid:group-friends' }],
});
});
it('only leaves the synced book when the server filed the group in an unsynced one after the device deleted it', async () => {
const { h, gid } = await withGroup();
const archive = h.server.addAddressBook(JMAP, { name: 'Archive' });
h.unselected.add(`${JMAP}/${archive}`);
h.device.user.deleteGroup((await localGroup(h, gid)).groupId);
h.server.serverUpdate('ContactCard', JMAP, gid, { [`addressBookIds/${archive}`]: true, 'name/full': 'Old friends' });
const plan = h.planner.planGroupDownload(h.card(gid), await localGroup(h, gid), h.ctx);
await h.applyOk(plan.ops);
// The delete wins over the rename; only the shadow follows the card.
expect(await localGroup(h, gid)).toMatchObject({ deleted: true, title: 'Friends' });
expect(h.planner.planGroupUpload(await localGroup(h, gid), h.ctx)).toEqual({
kind: 'upload', actions: [{ kind: 'update', id: gid, patch: { [`addressBookIds/${h.book}`]: null } }],
});
});
it('reverts a rename of a read-only group', async () => {
const { h, gid } = await withGroup();
h.readOnlyBooks.add(h.book);
const g = await localGroup(h, gid);
h.device.user.updateGroup(g.groupId, { [Groups.TITLE]: 'Mine now' });
const plan = h.planner.planGroupUpload(await localGroup(h, gid), h.ctx);
expect(plan).toMatchObject({ kind: 'revert', reason: 'readOnly' });
await h.applyOk(opsOf(plan));
expect(await localGroup(h, gid)).toMatchObject({ title: 'Friends', dirty: false });
});
it('deletes the group row for a server delete', async () => {
const { h, gid, rawId } = await withGroup();
await h.applyOk(h.planner.planGroupLocalDelete(await localGroup(h, gid)));
expect(h.device.rows('groups', 'sourceid = ?', [`${JMAP}/${gid}`])).toEqual([]);
expect(h.membershipGroups(rawId)).toEqual([]);
});
});
@@ -0,0 +1,240 @@
/**
* Test rig for the contacts planner: the fake device providers, the fake
* Stalwart, and a ContactsContext wired between them. Plans are applied the
* way the engine applies them (one op group per batch); uploads go through
* the fake server's patch handling and come back through its normalisation.
*/
import { GroupMembership, Groups, RawContacts } from '../../android-columns';
import { makeKeyMinter, parseObjectRef, uuidFrom } from '../../common/ids';
import { contactsPlanner } from '../../contacts/planner';
import type {
ContactCardWire,
ContactsContext,
LocalContact,
LocalGroup,
OpGroup,
UploadAction,
UploadPlan,
} from '../../planner';
import { CONTACTS_AUTHORITY, type BatchResult, type ProviderPort, type Row } from '../../types';
import { FakeJmapServer } from '../fakes/fake-jmap-server';
import { FakeDeviceProviders } from '../fakes/fake-provider';
export const ACCOUNT = 'usera@example.org';
export const JMAP = 'c';
export function seeded(seed = 7): () => number {
return () => {
seed = (seed * 1103515245 + 12345) % 2147483648;
return seed / 2147483648;
};
}
function rowsOf(res: { columns: string[]; rows: unknown[][] }): Row[] {
return res.rows.map((cells) => Object.fromEntries(res.columns.map((c, i) => [c, cells[i]])) as Row);
}
export class Harness {
readonly device = new FakeDeviceProviders();
readonly port: ProviderPort = this.device.port(ACCOUNT, CONTACTS_AUTHORITY);
readonly server = new FakeJmapServer();
readonly book: string;
readonly planner = contactsPlanner;
/** Base64 of blob-backed photos by blobId, and the device photo per raw contact. */
readonly blobs = new Map<string, string>();
readonly devicePhotos = new Map<number, string>();
readonly names = new Map<string, string>();
readonly readOnlyBooks = new Set<string>();
readonly unselected = new Set<string>();
/** When false the context has no `groupsOf` (memberships not synced). */
groupIndex = true;
readonly ctx: ContactsContext;
constructor() {
this.server.addAccount(JMAP, { name: ACCOUNT });
this.book = this.server.addAddressBook(JMAP, { name: 'Personal' });
const random = seeded();
const mintKey = makeKeyMinter(random);
const self = this;
this.ctx = {
jmapAccountId: JMAP,
now: 1_800_000_000_000,
mintKey,
mintUid: () => `urn:uuid:${uuidFrom(random)}`,
selectedCollections: (card) =>
Object.keys(card.addressBookIds ?? {}).map((id) => `${JMAP}/${id}`).filter((k) => !self.unselected.has(k)),
isReadOnly: (card) => Object.keys(card.addressBookIds ?? {}).every((id) => self.readOnlyBooks.has(id)),
isSelected: (key) => !self.unselected.has(key),
createTarget: () => `${JMAP}/${self.book}`,
photoBytes: (entry) => (entry.blobId ? self.blobs.get(entry.blobId) ?? null : null),
devicePhoto: (id) => self.devicePhotos.get(id) ?? null,
nameForUid: (uid) => self.names.get(uid) ?? null,
groupRowIdBySourceId: (sourceId) => {
const row = self.device.rows('groups', 'sourceid = ?', [sourceId])[0];
return row ? Number(row[Groups._ID]) : null;
},
groupsOf(uid: string) {
return self.server
.all('ContactCard', JMAP)
.filter((c) => c.kind === 'group' && (c.members as Record<string, boolean> | undefined)?.[uid] === true)
.map((c) => `${JMAP}/${c.id as string}`);
},
groupReadOnly(sourceId: string) {
const ref = parseObjectRef(sourceId);
const card = ref ? self.server.get('ContactCard', ref.accountId, ref.id) : undefined;
const books = Object.keys((card?.addressBookIds as Record<string, boolean> | undefined) ?? {});
return books.length > 0 && books.every((id) => self.readOnlyBooks.has(id));
},
};
}
/** The context as the engine may build it without a group index. */
context(): ContactsContext {
if (this.groupIndex) return this.ctx;
const { groupsOf: _drop, ...rest } = this.ctx;
return rest as ContactsContext;
}
async apply(group: OpGroup): Promise<BatchResult> {
if (!group.ops.length) return { ok: true, results: [] };
return this.port.applyBatch(group.ops);
}
async applyOk(group: OpGroup): Promise<void> {
const res = await this.apply(group);
if (!res.ok) throw new Error(`batch failed: ${res.reason} ${res.message}`);
}
async contacts(): Promise<LocalContact[]> {
const raw = rowsOf(await this.port.query({ table: 'raw_contacts', columns: [...this.planner.rawContactColumns] }));
const data = rowsOf(await this.port.query({ table: 'data', columns: [...this.planner.dataColumns] }));
return raw.map((r) => this.planner.decodeContact(r, data.filter((d) => d.raw_contact_id === r._id)));
}
async contact(rawContactId: number): Promise<LocalContact> {
const found = (await this.contacts()).find((c) => c.rawContactId === rawContactId);
if (!found) throw new Error(`no raw contact ${rawContactId}`);
return found;
}
async bySource(cardId: string): Promise<LocalContact | null> {
return (await this.contacts()).find((c) => c.sourceId === `${JMAP}/${cardId}`) ?? null;
}
async groups(): Promise<LocalGroup[]> {
const rows = rowsOf(await this.port.query({ table: 'groups', columns: [...this.planner.groupColumns] }));
return rows.map((r) => this.planner.decodeGroup(r));
}
card(id: string): ContactCardWire {
const card = this.server.get('ContactCard', JMAP, id);
if (!card) throw new Error(`no card ${id}`);
return card as ContactCardWire;
}
addCard(card: Record<string, unknown>): string {
return this.server.addCard(JMAP, { addressBookIds: { [this.book]: true }, ...card });
}
/** Downloads a server card into its local contact (or a new one); returns the raw contact id. */
async download(cardId: string): Promise<number> {
const card = this.card(cardId);
const local = await this.bySource(cardId);
const plan = card.kind === 'group'
? this.planner.planGroupDownload(card, (await this.groups()).find((g) => g.sourceId === `${JMAP}/${cardId}`) ?? null, this.context())
: this.planner.planDownload(card, local, this.context());
await this.applyOk(plan.ops);
if (card.kind === 'group') return -1;
const after = await this.bySource(cardId);
if (!after) throw new Error('download wrote no contact');
const heal = this.planner.planBaselineHeal(after);
if (heal) await this.applyOk(heal);
return after.rawContactId;
}
/** A card on the server and on the device, clean. */
async seed(card: Record<string, unknown>): Promise<{ id: string; rawId: number }> {
const id = this.addCard(card);
return { id, rawId: await this.download(id) };
}
/** Sends upload actions to the fake server the way the engine would; returns the touched card id. */
send(actions: UploadAction<ContactCardWire>[]): string | null {
let touched: string | null = null;
for (const a of actions) {
if (a.kind === 'update') {
this.server.serverUpdate('ContactCard', JMAP, a.id, a.patch);
touched = a.id;
} else if (a.kind === 'create') {
touched = this.server.addCard(JMAP, a.object as Record<string, unknown>);
} else if (a.id) {
this.server.serverDestroy('ContactCard', JMAP, a.id);
}
}
return touched;
}
/** Plans an upload for a contact, claiming it first when it is new. */
async planUpload(rawContactId: number): Promise<UploadPlan<ContactCardWire>> {
let plan = this.planner.planUpload(await this.contact(rawContactId), this.context());
if (plan.kind === 'claim') {
await this.applyOk(plan.ops);
plan = this.planner.planUpload(await this.contact(rawContactId), this.context());
}
return plan;
}
/**
* One upload round for a contact: plan, send, re-read the card, apply the
* accepted plan (or its keep-dirty fallback). Returns the plan.
*/
async upload(rawContactId: number, between?: () => void): Promise<UploadPlan<ContactCardWire>> {
const plan = await this.planUpload(rawContactId);
const local = await this.contact(rawContactId);
if (plan.kind === 'upload') {
const touched = this.send(plan.actions);
between?.();
if (local.deleted) {
const accepted = this.planner.planAccepted(local, {} as ContactCardWire, this.context());
await this.applyOk(accepted.ops);
return plan;
}
const server = this.card(touched!);
const accepted = this.planner.planAccepted(local, server, this.context());
const res = await this.apply(accepted.ops);
if (!res.ok) await this.applyOk(accepted.keepDirtyOps);
} else if (plan.kind !== 'skip') {
between?.();
await this.applyOk(plan.ops);
}
return plan;
}
cardIdOf(local: LocalContact): string {
return parseObjectRef(local.sourceId)!.id;
}
/** Data rows of a raw contact as the provider holds them, keyed by DATA_SYNC1 (or `row:<id>`). */
rowsByKey(rawContactId: number): Record<string, Row> {
const out: Record<string, Row> = {};
for (const r of this.device.rows('data', 'raw_contact_id = ?', [rawContactId])) {
out[(r.data_sync1 as string | null) ?? `row:${r._id}`] = r;
}
return out;
}
rawContact(rawContactId: number): Row {
return this.device.row('raw_contacts', rawContactId)!;
}
dirty(rawContactId: number): boolean {
return Number(this.rawContact(rawContactId)[RawContacts.DIRTY]) === 1;
}
membershipGroups(rawContactId: number): string[] {
return this.device
.rows('data', 'raw_contact_id = ? AND mimetype = ?', [rawContactId, 'vnd.android.cursor.item/group_membership'])
.map((r) => r[GroupMembership.GROUP_SOURCE_ID] as string)
.sort();
}
}
@@ -0,0 +1,262 @@
import { describe, expect, it } from 'vitest';
import { Data, MimeType } from '../../android-columns';
import { MAX_PHOTO_BASE64 } from '../../contacts/photo';
import type { LocalContact } from '../../planner';
import { JPEG, JPEG2, probeCard } from './fixtures';
import { Harness, JMAP } from './harness';
/** The engine's view after every write: a clean contact whose rows the server card describes. */
async function expectSettled(h: Harness, id: string, rawId: number): Promise<LocalContact> {
const local = await h.contact(rawId);
expect(local.dirty).toBe(false);
expect(h.planner.planDownload(h.card(id), local, h.ctx).effect).toBe('none');
expect(h.planner.planBaselineHeal(local)).toBeNull();
return local;
}
describe('contacts lifecycle', () => {
it('settles after device edits, an editor rewrite and server edits in turn', async () => {
const h = new Harness();
const { id, rawId } = await h.seed(probeCard());
let rows = h.rowsByKey(rawId);
// Several fields at once in AOSP Contacts.
h.device.user.updateData(rows.name._id as number, { data2: 'Probert', data1: 'Dr. Probert Person' });
h.device.user.updateData(rows['emails:zz9']._id as number, { data1: 'home@example.org' });
h.device.user.updateData(rows['addresses:home']._id as number, { data9: '10117' });
h.device.user.insertData(rawId, { [Data.MIMETYPE]: MimeType.PHONE, data1: '+49 30 7777', data2: 3 });
await h.upload(rawId);
const card = h.card(id);
expect(card.name).toMatchObject({ full: 'Dr. Probert Person' });
expect(card.emails!.zz9.address).toBe('home@example.org');
expect(card.addresses!.home.components).toContainEqual({ kind: 'postcode', value: '10117' });
expect(Object.values(card.phones!).map((p) => p.number)).toContain('+49 30 7777');
expect(card.onlineServices).toEqual(probeCard().onlineServices);
expect(card.notes!.n2).toEqual({ note: 'second note' });
await expectSettled(h, id, rawId);
// Fossify rewrites everything but the name; nothing uploads, the rows come back whole.
rows = h.rowsByKey(rawId);
h.device.user.fossifySave(rawId, null, Object.values(rows)
.filter((r) => r[Data.MIMETYPE] === MimeType.EMAIL || r[Data.MIMETYPE] === MimeType.PHONE)
.map((r) => ({ [Data.MIMETYPE]: r[Data.MIMETYPE], data1: r.data1, data2: r.data2 })));
expect((await h.upload(rawId)).kind).toBe('clean');
await expectSettled(h, id, rawId);
expect(h.rowsByKey(rawId)['organizations:o1|titles:t1,r1'].data5).toBe('R&D');
// The server edits and removes things.
h.server.serverUpdate('ContactCard', JMAP, id, { 'nicknames/nk/name': 'Probo', 'links/l1': null, 'addresses/home/components': [{ kind: 'locality', value: 'Hamburg' }] });
await h.download(id);
await expectSettled(h, id, rawId);
rows = h.rowsByKey(rawId);
expect(rows['nicknames:nk'].data1).toBe('Probo');
expect(rows['links:l1']).toBeUndefined();
expect(rows['addresses:home']).toMatchObject({ data7: 'Hamburg', data4: null });
});
it('follows Stalwart re-keying the whole card by position, without rewriting a row', async () => {
const h = new Harness();
const { id, rawId } = await h.seed(probeCard());
const before = h.rowsByKey(rawId);
h.server.rekeyPositionally(JMAP, id, 'ContactCard');
const plan = h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx);
for (const op of plan.ops.ops) {
if (op.op === 'update' && op.table === 'data') expect(Object.keys(op.values).every((c) => c.startsWith('data_sync'))).toBe(true);
expect(op.op === 'insert' || op.op === 'delete').toBe(false);
}
await h.applyOk(plan.ops);
const after = h.rowsByKey(rawId);
expect(after['organizations:k1|titles:k1,k2']._id).toBe(before['organizations:o1|titles:t1,r1']._id);
expect(after['emails:k2']._id).toBe(before['emails:x/y']._id);
await expectSettled(h, id, rawId);
// The next device edit patches the entry under its new key.
h.device.user.updateData(after['emails:k2']._id as number, { data1: 'slash3@example.org' });
const upload = h.planner.planUpload(await h.contact(rawId), h.ctx);
expect(upload).toMatchObject({ kind: 'upload', actions: [{ patch: { 'emails/k2/address': 'slash3@example.org' } }] });
});
it('writes a blob-backed photo from the bytes the engine fetched, and nothing for its echo', async () => {
const h = new Harness();
h.blobs.set('B1', JPEG);
const { id, rawId } = await h.seed({ name: { full: 'Blob Photo' }, media: { p: { kind: 'photo', blobId: 'B1', mediaType: 'image/jpeg' } } });
expect((await h.port.readPhoto(rawId, 512))?.jpegBase64).toBe(JPEG);
expect((await h.contact(rawId)).shadow?.media).toEqual({ p: { kind: 'photo', blobId: 'B1', mediaType: 'image/jpeg' } });
await expectSettled(h, id, rawId);
});
it('keeps the photo row when the photo bytes could not be fetched, and never reads it as deleted', async () => {
const h = new Harness();
h.blobs.set('B1', JPEG);
const { id, rawId } = await h.seed({ name: { full: 'Blob Photo' }, emails: { e1: { address: 'a@example.org' } }, media: { p: { kind: 'photo', blobId: 'B1' } } });
const photoRow = h.rowsByKey(rawId)['media:p']._id;
h.blobs.delete('B1');
h.server.serverUpdate('ContactCard', JMAP, id, { 'emails/e1/address': 'b@example.org' });
await h.download(id);
expect(h.rowsByKey(rawId)['media:p']?._id).toBe(photoRow);
h.device.user.updateData(h.rowsByKey(rawId)['emails:e1']._id as number, { data1: 'c@example.org' });
expect(h.planner.planUpload(await h.contact(rawId), h.ctx)).toMatchObject({ kind: 'upload', actions: [{ patch: { 'emails/e1/address': 'c@example.org' } }] });
const plan = h.planner.planUpload(await h.contact(rawId), h.ctx);
expect(plan.kind === 'upload' && Object.keys((plan.actions[0] as { patch: object }).patch)).toEqual(['emails/e1/address']);
});
it('keeps a photo too large for a provider batch on the server only, and never reads it as deleted', async () => {
const h = new Harness();
// About 900 KB of JPEG: the fake provider refuses a batch over 1 MB, as Binder does.
const big = 'A'.repeat(1_200_000);
const { id, rawId } = await h.seed({
name: { full: 'Big Photo' },
emails: { e1: { address: 'a@example.org' } },
media: { p: { kind: 'photo', uri: `data:image/jpeg;base64,${big}`, mediaType: 'image/jpeg' } },
});
// The contact is there, without the photo; its echo writes nothing.
expect(Object.keys(h.rowsByKey(rawId)).sort()).toEqual(['emails:e1', 'name']);
await expectSettled(h, id, rawId);
h.device.user.updateData(h.rowsByKey(rawId)['emails:e1']._id as number, { data1: 'b@example.org' });
expect(h.planner.planUpload(await h.contact(rawId), h.ctx)).toEqual({
kind: 'upload', actions: [{ kind: 'update', id, patch: { 'emails/e1/address': 'b@example.org' } }],
});
await h.upload(rawId);
expect(h.card(id).media).toEqual({ p: { kind: 'photo', uri: `data:image/jpeg;base64,${big}`, mediaType: 'image/jpeg' } });
await expectSettled(h, id, rawId);
// A photo set on the device replaces it.
h.device.user.setPhoto(rawId, JPEG2);
h.devicePhotos.set(rawId, JPEG2);
expect(h.planner.planUpload(await h.contact(rawId), h.ctx)).toEqual({
kind: 'upload', actions: [{ kind: 'update', id, patch: { 'media/p/uri': `data:image/jpeg;base64,${JPEG2}`, 'media/p/mediaType': 'image/jpeg' } }],
});
await h.upload(rawId);
expect(h.device.photo(Number(h.rowsByKey(rawId)['media:p'].data14))).toBe(JPEG2);
await expectSettled(h, id, rawId);
});
it('writes a photo up to the budget of a provider batch, and none beyond it', async () => {
const h = new Harness();
const seedPhoto = (b64: string) => h.seed({ name: { full: 'Sized' }, media: { p: { kind: 'photo', uri: `data:image/jpeg;base64,${b64}` } } });
const fits = 'A'.repeat(MAX_PHOTO_BASE64);
const within = await seedPhoto(fits);
expect(h.device.photo(Number(h.rowsByKey(within.rawId)['media:p'].data14))).toBe(fits);
await expectSettled(h, within.id, within.rawId);
const beyond = await seedPhoto('A'.repeat(MAX_PHOTO_BASE64 + 4));
expect(h.rowsByKey(beyond.rawId)['media:p']).toBeUndefined();
await expectSettled(h, beyond.id, beyond.rawId);
});
it('never reads a photo whose bytes could not be fetched as deleted, and writes it once they can be', async () => {
const h = new Harness();
const { id, rawId } = await h.seed({
name: { full: 'Blob Photo' },
emails: { e1: { address: 'a@example.org' } },
media: { p: { kind: 'photo', blobId: 'B1', mediaType: 'image/jpeg' } },
});
expect(h.rowsByKey(rawId)['media:p']).toBeUndefined();
h.device.user.updateData(h.rowsByKey(rawId)['emails:e1']._id as number, { data1: 'b@example.org' });
expect(h.planner.planUpload(await h.contact(rawId), h.ctx)).toEqual({
kind: 'upload', actions: [{ kind: 'update', id, patch: { 'emails/e1/address': 'b@example.org' } }],
});
await h.upload(rawId);
expect(h.card(id).media).toEqual({ p: { kind: 'photo', blobId: 'B1', mediaType: 'image/jpeg' } });
// With the bytes at hand, the next write of the card brings the photo.
h.blobs.set('B1', JPEG);
h.server.serverUpdate('ContactCard', JMAP, id, { 'emails/e1/address': 'c@example.org' });
await h.download(id);
expect((await h.port.readPhoto(rawId, 512))?.jpegBase64).toBe(JPEG);
await expectSettled(h, id, rawId);
});
it('does not read a server photo it could not write as deleted after merging it into an edited contact', async () => {
const h = new Harness();
const { id, rawId } = await h.seed({ name: { full: 'Merge Photo' }, emails: { e1: { address: 'a@example.org' } }, phones: { p1: { number: '1' } } });
// The server adds a photo too large to write while the device edits the phone.
h.device.user.updateData(h.rowsByKey(rawId)['phones:p1']._id as number, { data1: '2' });
const big = 'A'.repeat(1_200_000);
h.server.serverUpdate('ContactCard', JMAP, id, { media: { p: { kind: 'photo', uri: `data:image/jpeg;base64,${big}` } } });
await h.applyOk(h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx).ops);
expect(h.planner.planUpload(await h.contact(rawId), h.ctx)).toEqual({
kind: 'upload', actions: [{ kind: 'update', id, patch: { 'phones/p1/number': '2' } }],
});
});
describe('a small photo the provider keeps as its thumbnail only (no PHOTO_FILE_ID)', () => {
const photo = { ph: { kind: 'photo', uri: `data:image/jpeg;base64,${JPEG}`, mediaType: 'image/jpeg' } };
const small = () => ({ name: { full: 'Photo Smalltest' }, emails: { e1: { address: 'small@example.org' } }, notes: { n1: { note: 'v1' } }, media: photo });
const photos = (h: Harness, rawId: number) => h.device.rows('data', 'raw_contact_id = ? AND mimetype = ?', [rawId, MimeType.PHOTO]);
it('keeps one row through downloads and merges, and never reads it as deleted', async () => {
const h = new Harness();
const { id, rawId } = await h.seed(small());
expect(photos(h, rawId)).toMatchObject([{ data14: null, data_sync1: 'media:ph' }]);
await expectSettled(h, id, rawId);
for (const note of ['v2', 'v3']) {
h.server.serverUpdate('ContactCard', JMAP, id, { 'notes/n1/note': note });
await h.download(id);
expect(photos(h, rawId)).toHaveLength(1);
}
// A merge into an edited contact.
h.device.user.updateData(h.rowsByKey(rawId)['emails:e1']._id as number, { data1: 'small2@example.org' });
h.server.serverUpdate('ContactCard', JMAP, id, { 'notes/n1/note': 'v4' });
await h.applyOk(h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx).ops);
expect(photos(h, rawId)).toHaveLength(1);
// An edit of another field uploads alone.
expect(h.planner.planUpload(await h.contact(rawId), h.ctx)).toEqual({
kind: 'upload', actions: [{ kind: 'update', id, patch: { 'emails/e1/address': 'small2@example.org' } }],
});
await h.upload(rawId);
expect(h.card(id).media).toEqual(photo);
expect(photos(h, rawId)).toHaveLength(1);
await expectSettled(h, id, rawId);
// A photo set on the device (it gets a display photo) replaces it.
h.device.user.setPhoto(rawId, JPEG2);
h.devicePhotos.set(rawId, JPEG2);
expect(h.planner.planUpload(await h.contact(rawId), h.ctx)).toEqual({
kind: 'upload', actions: [{ kind: 'update', id, patch: { 'media/ph/uri': `data:image/jpeg;base64,${JPEG2}`, 'media/ph/mediaType': 'image/jpeg' } }],
});
});
it('heals the photo rows the same photo was inserted as again, keeping one and the server photo', async () => {
const h = new Harness();
const { id, rawId } = await h.seed(small());
const [row] = photos(h, rawId);
const again = { [Data.MIMETYPE]: MimeType.PHOTO, [Data.DATA15]: { b64: JPEG }, data_sync1: row.data_sync1, data_sync2: row.data_sync2, data_sync3: row.data_sync3 };
for (let i = 0; i < 2; i++) await h.applyOk({ ref: 'again', ops: [{ op: 'insert', table: 'data', values: { ...again, [Data.RAW_CONTACT_ID]: rawId } }] });
expect(photos(h, rawId)).toHaveLength(3);
// An edit on the device uploads without touching the photo, and its accepted write keeps one row.
h.device.user.updateData(h.rowsByKey(rawId)['notes:n1']._id as number, { data1: 'v2 edited on device' });
expect(h.planner.planUpload(await h.contact(rawId), h.ctx)).toEqual({
kind: 'upload', actions: [{ kind: 'update', id, patch: { 'notes/n1/note': 'v2 edited on device' } }],
});
await h.upload(rawId);
expect(photos(h, rawId)).toHaveLength(1);
expect(h.card(id).media).toEqual(photo);
await expectSettled(h, id, rawId);
// An unchanged card downloaded again (a full reconcile) heals too.
for (let i = 0; i < 2; i++) await h.applyOk({ ref: 'again', ops: [{ op: 'insert', table: 'data', values: { ...again, [Data.RAW_CONTACT_ID]: rawId } }] });
await h.applyOk(h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx).ops);
expect(photos(h, rawId)).toHaveLength(1);
});
});
it('heals a baseline the provider stored differently from the prediction', async () => {
const h = new Harness();
const { id, rawId } = await h.seed({ name: { full: 'Dr. Jane Q. Public' } });
const name = h.rowsByKey(rawId).name;
// The real NameSplitter knows titles and middle names; the prediction does not.
await h.applyOk({ ref: 'split', ops: [{ op: 'update', table: 'data', id: name._id as number, values: { data4: 'Dr.', data2: 'Jane', data5: 'Q.', data3: 'Public' } }] });
const heal = h.planner.planBaselineHeal(await h.contact(rawId));
expect(heal?.ops.map((o) => o.op)).toEqual(['assert', 'update']);
await h.applyOk(heal!);
await expectSettled(h, id, rawId);
h.device.user.star(rawId);
expect((await h.upload(rawId)).kind).toBe('clean');
});
it('does not heal a dirty contact: its edit must still count', async () => {
const h = new Harness();
const { rawId } = await h.seed(probeCard());
h.device.user.updateData(h.rowsByKey(rawId)['notes:n1']._id as number, { data1: 'edited' });
expect(h.planner.planBaselineHeal(await h.contact(rawId))).toBeNull();
});
});
@@ -0,0 +1,181 @@
import { describe, expect, it } from 'vitest';
import { Data, MimeType, RawContacts } from '../../android-columns';
import type { ContactCardWire } from '../../planner';
import { appleCard, googleCard, probeCard } from './fixtures';
import { Harness, JMAP } from './harness';
/** A seeded card, edited on the device (in place, like AOSP) and on the server. */
async function bothEdited(
make: () => Record<string, unknown>,
device: (h: Harness, rawId: number) => void,
server: Record<string, unknown>,
) {
const h = new Harness();
const { id, rawId } = await h.seed(make());
device(h, rawId);
h.server.serverUpdate('ContactCard', JMAP, id, server);
const plan = h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx);
await h.applyOk(plan.ops);
return { h, id, rawId, plan };
}
const edit = (key: string, values: Record<string, string | number | null>) => (h: Harness, rawId: number) =>
h.device.user.updateData(h.rowsByKey(rawId)[key]._id as number, values);
async function patchOf(h: Harness, rawId: number) {
const plan = h.planner.planUpload(await h.contact(rawId), h.ctx);
return plan.kind === 'upload' && plan.actions[0].kind === 'update' ? plan.actions[0].patch : plan.kind;
}
describe('contacts merge: a dirty contact meets a new server version', () => {
it('lets the server win a real conflict and counts it', async () => {
const { h, rawId, plan } = await bothEdited(appleCard, edit('emails:k1', { data1: 'mine@example.com' }), { 'emails/k1/address': 'theirs@example.com' });
expect(plan).toMatchObject({ conflicts: 1, stillDirty: false, effect: 'update' });
expect(h.rowsByKey(rawId)['emails:k1'].data1).toBe('theirs@example.com');
expect(h.dirty(rawId)).toBe(true);
expect(await patchOf(h, rawId)).toBe('clean');
});
it('keeps a local edit of one unit and takes the server edit of another', async () => {
const { h, rawId, plan } = await bothEdited(appleCard, edit('phones:k1', { data1: '+1 555 0199' }), { 'emails/k2/address': 'anna@new.example' });
expect(plan).toMatchObject({ conflicts: 0, stillDirty: true });
expect(h.rowsByKey(rawId)['emails:k2'].data1).toBe('anna@new.example');
expect(await patchOf(h, rawId)).toEqual({ 'phones/k1/number': '+1 555 0199' });
});
it('treats the same change on both sides as converged', async () => {
const { h, rawId, plan } = await bothEdited(appleCard, edit('emails:k1', { data1: 'same@example.com' }), { 'emails/k1/address': 'same@example.com' });
expect(plan).toMatchObject({ conflicts: 0, stillDirty: false });
expect(await patchOf(h, rawId)).toBe('clean');
});
it('does not count a server change to a sub-field Android does not show', async () => {
const { h, id, rawId, plan } = await bothEdited(appleCard, edit('emails:k1', { data1: 'mine@example.com' }), { 'emails/k1/contexts': { private: true, school: true } });
expect(plan.conflicts).toBe(0);
expect(await patchOf(h, rawId)).toEqual({ 'emails/k1/address': 'mine@example.com' });
await h.upload(rawId);
expect(h.card(id).emails!.k1).toMatchObject({ address: 'mine@example.com', contexts: { private: true, school: true } });
});
it('deletes a row the server deleted, over a local edit (counted)', async () => {
const { h, rawId, plan } = await bothEdited(appleCard, edit('phones:k3', { data1: '+1 555 0177' }), { 'phones/k3': null });
expect(plan.conflicts).toBe(1);
expect(h.rowsByKey(rawId)['phones:k3']).toBeUndefined();
});
it('puts back an entry deleted on the device that the server changed (counted)', async () => {
const { h, rawId, plan } = await bothEdited(
appleCard,
(hh, raw) => hh.device.user.deleteData(hh.rowsByKey(raw)['phones:k3']._id as number),
{ 'phones/k3/number': '+1 555 0188' },
);
expect(plan.conflicts).toBe(1);
expect(h.rowsByKey(rawId)['phones:k3'].data1).toBe('+1 555 0188');
});
it('keeps a device deletion of an entry the server left alone', async () => {
const { h, rawId, plan } = await bothEdited(
appleCard,
(hh, raw) => hh.device.user.deleteData(hh.rowsByKey(raw)['phones:k3']._id as number),
{ 'emails/k2/address': 'anna@new.example' },
);
expect(plan).toMatchObject({ conflicts: 0, stillDirty: true });
const patch = await patchOf(h, rawId) as Record<string, unknown>;
expect(Object.keys(patch)).toEqual(['phones']);
expect(Object.keys(patch.phones as object)).toEqual(['k1', 'k2']);
});
it('still uploads a photo deleted on the device after merging a server edit of another entry', async () => {
const { h, rawId, plan } = await bothEdited(
probeCard,
(hh, raw) => hh.device.user.deleteData(hh.rowsByKey(raw)['media:ph']._id as number),
{ 'emails/zz9/address': 'new@example.org' },
);
expect(plan).toMatchObject({ conflicts: 0, stillDirty: true });
expect(await patchOf(h, rawId)).toEqual({ media: null });
});
it('matches an entry both sides added instead of duplicating it', async () => {
const { h, rawId, plan } = await bothEdited(
appleCard,
(hh, raw) => hh.device.user.insertData(raw, { [Data.MIMETYPE]: MimeType.EMAIL, data1: 'both@example.com', data2: 3 }),
{ 'emails/k3': { address: 'both@example.com' } },
);
expect(plan.conflicts).toBe(0);
const emails = h.device.rows('data', 'raw_contact_id = ? AND mimetype = ?', [rawId, MimeType.EMAIL]);
expect(emails.filter((r) => r.data1 === 'both@example.com')).toHaveLength(1);
expect(await patchOf(h, rawId)).toBe('clean');
});
it('inserts what the server added next to a pending local edit', async () => {
const { h, rawId, plan } = await bothEdited(appleCard, edit('phones:k1', { data1: '+1 555 0199' }), { 'emails/k3': { address: 'third@example.com' } });
expect(plan.stillDirty).toBe(true);
expect(h.rowsByKey(rawId)['emails:k3'].data1).toBe('third@example.com');
expect(await patchOf(h, rawId)).toEqual({ 'phones/k1/number': '+1 555 0199' });
});
it('follows shifted keys and uploads the local edit under the new key', async () => {
const { h, rawId, plan } = await bothEdited(appleCard, edit('emails:k2', { data1: 'anna@work2.example' }), {
emails: {
k1: { address: 'first@example.com' },
k2: { address: 'anna@example.com', contexts: { private: true }, pref: 1 },
k3: { address: 'anna@work.example', contexts: { work: true } },
},
});
expect(plan.conflicts).toBe(0);
const rows = h.rowsByKey(rawId);
expect(rows['emails:k3'].data1).toBe('anna@work2.example');
expect(rows['emails:k1'].data1).toBe('first@example.com');
expect(await patchOf(h, rawId)).toEqual({ 'emails/k3/address': 'anna@work2.example' });
});
it('merges the name as one unit: a typed name on the server beats a local component edit', async () => {
const { h, rawId, plan } = await bothEdited(appleCard, edit('name', { data2: 'Annie', data1: 'Annie Apfel' }), { 'name/full': 'A. Apfel' });
expect(plan.conflicts).toBe(1);
expect(h.rowsByKey(rawId).name).toMatchObject({ data1: 'A. Apfel', data2: 'Anna' });
});
it('never gives keys to a kind an editor rewrote, so a nickname it could not show is not deleted later', async () => {
const h = new Harness();
const { id, rawId } = await h.seed(googleCard());
// Fossify keeps one nickname, re-inserted without its key.
h.device.user.fossifySave(rawId, null, [{ [Data.MIMETYPE]: MimeType.NICKNAME, data1: 'Gü' }]);
h.server.serverUpdate('ContactCard', JMAP, id, { 'nicknames/k1/name': 'Guenther' });
const plan = h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx);
await h.applyOk(plan.ops);
const nick = h.device.rows('data', 'raw_contact_id = ? AND mimetype = ?', [rawId, MimeType.NICKNAME]);
expect(nick.map((r) => [r.data1, r.data_sync1])).toEqual([['Guenther', null]]);
const patch = await patchOf(h, rawId);
expect(typeof patch === 'object' && Object.keys(patch).some((k) => k.startsWith('nicknames'))).toBe(false);
});
it('leaves the rows of a contact deleted on the device alone: the delete wins and uploads', async () => {
const h = new Harness();
const { id, rawId } = await h.seed(appleCard());
h.device.user.deleteContact(rawId);
h.server.serverUpdate('ContactCard', JMAP, id, { 'emails/k1/address': 'x@example.com' });
const plan = h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx);
// Only the shadow follows the card, for the deletion's upload.
expect(plan).toMatchObject({ effect: 'update', stillDirty: true, conflicts: 0 });
expect(plan.ops.ops.filter((o) => o.op !== 'assert')).toEqual([{
op: 'update', table: 'raw_contacts', id: rawId, expectCount: 1,
values: { [RawContacts.SYNC2]: expect.any(String) },
}]);
await h.applyOk(plan.ops);
expect(h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx)).toMatchObject({ effect: 'none', ops: { ops: [] } });
expect(h.planner.planUpload(await h.contact(rawId), h.ctx)).toMatchObject({ kind: 'upload', actions: [{ kind: 'destroy', id }] });
});
it('replaces the rows of a different object behind the same id (another uid)', async () => {
const h = new Harness();
const { id, rawId } = await h.seed(appleCard());
edit('emails:k1', { data1: 'mine@example.com' })(h, rawId);
const other = { ...(googleCard() as object), id, addressBookIds: { [h.book]: true } } as unknown as ContactCardWire;
const plan = h.planner.planDownload(other, await h.contact(rawId), h.ctx);
await h.applyOk(plan.ops);
const rows = h.rowsByKey(rawId);
expect(rows['emails:k1']).toBeUndefined();
expect(rows['nicknames:k1'].data1).toBe('Gü');
expect(h.dirty(rawId)).toBe(false);
});
});
@@ -0,0 +1,200 @@
// The contacts planner driven by the engine (fake providers, fake Stalwart):
// cases that only show end to end, over several runs. The planner's rules
// themselves are tested in the other files of this folder.
import { describe, expect, it } from 'vitest';
import { Data, Groups, MimeType } from '../../android-columns';
import { calendarPlanner } from '../../calendar/planner';
import { contactsPlanner } from '../../contacts/planner';
import { addServerCards, ANDROID_ACCOUNT, CONTACTS_AUTHORITY, createHarness, renameDeviceContact, rowWrites, type Harness } from '../engine/harness';
import type { SetTarget } from '../fakes/fake-jmap-server';
import { JPEG } from './fixtures';
function real(options: Parameters<typeof createHarness>[0] = {}): Harness {
const h = createHarness(options);
h.deps.planners = { contacts: contactsPlanner, calendar: calendarPlanner };
return h;
}
function memberships(h: Harness, rawContactId: number) {
return h.device.rows('data').filter((d) => Number(d.raw_contact_id) === rawContactId && d.mimetype === MimeType.GROUP_MEMBERSHIP);
}
/** An address book of the personal account that this device does not sync. */
function unsyncedBook(h: Harness, name = 'Archive'): string {
const id = h.server.addAddressBook('a', { name });
h.prefs.contactsSelection[`a/${id}`] = false;
return id;
}
describe('contacts through the engine', () => {
it('keeps a card deleted on the device that the server meanwhile also filed in an unsynced book', async () => {
const h = real();
const archive = unsyncedBook(h);
const [ada] = addServerCards(h, ['Ada Lovelace']);
expect((await h.run()).outcome).toBe('ok');
h.device.user.deleteContact(h.contactNamed('Ada Lovelace')!.id);
h.server.serverUpdate('ContactCard', 'a', ada, { [`addressBookIds/${archive}`]: true });
const report = await h.run();
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { deleted: 1 } } });
expect(h.server.get('ContactCard', 'a', ada)).toMatchObject({ addressBookIds: { [archive]: true } });
expect(h.contacts()).toEqual([]);
});
it('keeps a group deleted on the device that the server meanwhile also filed in an unsynced book', async () => {
const h = real();
const archive = unsyncedBook(h);
const group = h.server.addCard('a', { uid: 'g1', kind: 'group', name: { full: 'Friends' }, addressBookIds: { [h.book]: true } });
expect((await h.run()).outcome).toBe('ok');
const row = h.device.rows('groups').find((g) => g.sourceid === `a/${group}`)!;
h.device.user.deleteGroup(Number(row._id));
h.server.serverUpdate('ContactCard', 'a', group, { [`addressBookIds/${archive}`]: true });
const report = await h.run();
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { deleted: 1 } } });
expect(h.server.get('ContactCard', 'a', group)).toMatchObject({ addressBookIds: { [archive]: true } });
expect(h.device.rows('groups')).toEqual([]);
});
it('puts back a membership in a group of another account, so no change waits for ever', async () => {
const h = real({ accounts: 'withShared' });
const book = h.server.all('AddressBook', 'team')[0].id as string;
h.prefs.contactsSelection[`team/${book}`] = true;
const team = h.server.addCard('team', { uid: 'g-team', kind: 'group', name: { full: 'Team' }, addressBookIds: { [book]: true } });
addServerCards(h, ['Ada Lovelace']);
expect((await h.run()).outcome).toBe('ok');
const ada = h.contactNamed('Ada Lovelace')!.id;
h.device.user.addToGroup(ada, Number(h.device.rows('groups').find((g) => g.sourceid === `team/${team}`)!._id));
const report = await h.run();
expect(report).toMatchObject({ outcome: 'ok', stats: { skipped: 1 } });
expect(report.itemErrors).toEqual([expect.objectContaining({ side: 'upload', type: 'groupNotWritable' })]);
expect(h.contactNamed('Ada Lovelace')).toMatchObject({ dirty: false });
expect(memberships(h, ada)).toEqual([]);
expect(h.server.get('ContactCard', 'team', team)?.members).toBeUndefined();
// Turning sync off finds nothing waiting.
expect(await h.teardown(CONTACTS_AUTHORITY)).toEqual({ pending: 0 });
});
it('puts back a membership in a group of a read-only book, so no change waits for ever', async () => {
const h = real();
const shared = h.server.addAddressBook('a', { name: 'Shared', myRights: { mayRead: true, mayWrite: false, mayShare: false, mayDelete: false } });
h.prefs.contactsSelection[`a/${shared}`] = true;
const team = h.server.addCard('a', { uid: 'g-team', kind: 'group', name: { full: 'Team' }, members: { other: true }, addressBookIds: { [shared]: true } });
addServerCards(h, ['Ada Lovelace']);
expect((await h.run()).outcome).toBe('ok');
const ada = h.contactNamed('Ada Lovelace')!.id;
h.device.user.addToGroup(ada, Number(h.device.rows('groups').find((g) => g.sourceid === `a/${team}`)!._id));
const report = await h.run();
expect(report).toMatchObject({ outcome: 'ok', stats: { skipped: 1 } });
expect(report.itemErrors).toEqual([expect.objectContaining({ side: 'upload', type: 'groupNotWritable' })]);
expect(h.contactNamed('Ada Lovelace')).toMatchObject({ dirty: false });
expect(memberships(h, ada)).toEqual([]);
expect(h.server.get('ContactCard', 'a', team)?.members).toEqual({ other: true });
// Turning sync off finds nothing waiting.
expect(await h.teardown(CONTACTS_AUTHORITY)).toEqual({ pending: 0 });
});
it('puts back an edit of a card that is only in a read-only book, without asking the server', async () => {
const h = real();
const shared = h.server.addAddressBook('a', { name: 'Shared', myRights: { mayRead: true, mayWrite: false, mayShare: false, mayDelete: false } });
h.prefs.contactsSelection[`a/${shared}`] = true;
const [ada] = addServerCards(h, ['Ada Lovelace'], 'a', shared);
expect((await h.run()).outcome).toBe('ok');
renameDeviceContact(h, h.contactNamed('Ada Lovelace')!.id, 'Ada King');
const report = await h.run();
expect(report).toMatchObject({ outcome: 'ok', stats: { skipped: 1, uploaded: { updated: 0 } } });
expect(report.itemErrors).toEqual([expect.objectContaining({ side: 'upload', type: 'readOnly' })]);
expect(h.contactNamed('Ada Lovelace')).toMatchObject({ dirty: false });
expect(h.server.get('ContactCard', 'a', ada)).toMatchObject({ name: { full: 'Ada Lovelace' } });
});
it('puts a contact created on the device into the group it was added to', async () => {
const h = real();
const group = h.server.addCard('a', { uid: 'g1', kind: 'group', name: { full: 'Friends' }, members: { other: true }, addressBookIds: { [h.book]: true } });
expect((await h.run()).outcome).toBe('ok');
const id = h.device.user.insertContact(ANDROID_ACCOUNT, [
{ [Data.MIMETYPE]: MimeType.STRUCTURED_NAME, [Data.DATA1]: 'New Person' },
{ [Data.MIMETYPE]: MimeType.GROUP_MEMBERSHIP, [Data.DATA1]: Number(h.device.rows('groups')[0]._id) },
]);
expect(await h.run()).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 1, updated: 1 } } });
const uid = h.server.all('ContactCard', 'a').find((c) => c.kind !== 'group')!.uid as string;
expect(h.server.get('ContactCard', 'a', group)?.members).toEqual({ other: true, [uid]: true });
expect(memberships(h, id)).toHaveLength(1);
});
it('keeps one row for a small photo the provider stores as its thumbnail, and never deletes it on the server', async () => {
const h = real();
const media = { ph: { kind: 'photo', uri: `data:image/jpeg;base64,${JPEG}`, mediaType: 'image/jpeg' } };
const card = h.server.addCard('a', { uid: 'u-small', name: { full: 'Photo Smalltest' }, notes: { n1: { note: 'v1' } }, addressBookIds: { [h.book]: true }, media });
expect((await h.run()).outcome).toBe('ok');
const id = h.contactNamed('Photo Smalltest')!.id;
const rowsOf = (mimetype: string) => h.device.rows('data').filter((d) => Number(d.raw_contact_id) === id && d.mimetype === mimetype);
expect(rowsOf(MimeType.PHOTO)).toMatchObject([{ data14: null }]);
for (const note of ['v2', 'v3']) {
h.server.serverUpdate('ContactCard', 'a', card, { 'notes/n1/note': note });
expect((await h.run()).outcome).toBe('ok');
expect(rowsOf(MimeType.PHOTO)).toHaveLength(1);
}
h.device.user.updateData(Number(rowsOf(MimeType.NOTE)[0]._id), { [Data.DATA1]: 'v4 edited on device' });
expect(await h.run()).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { updated: 1 } } });
expect(h.server.get('ContactCard', 'a', card)).toMatchObject({ notes: { n1: { note: 'v4 edited on device' } }, media });
expect(rowsOf(MimeType.PHOTO)).toHaveLength(1);
});
it('syncs a contact whose photo is too large for a provider batch, without the photo', async () => {
const h = real();
// About 900 KB of JPEG as a data: URI (a camera photo set over CardDAV).
const media = { p1: { '@type': 'Media', kind: 'photo', uri: `data:image/jpeg;base64,${'A'.repeat(1_200_000)}`, mediaType: 'image/jpeg' } };
const card = h.server.addCard('a', { uid: 'u-photo', name: { full: 'Photo Person' }, emails: { e1: { address: 'photo@example.org' } }, addressBookIds: { [h.book]: true }, media });
addServerCards(h, ['Plain Person']);
expect(await h.run()).toMatchObject({ outcome: 'ok', itemErrors: [] });
expect(h.contacts().map((c) => c.name).sort()).toEqual(['Photo Person', 'Plain Person']);
expect(h.state()?.accounts.a.stale).toEqual([]);
const before = h.batches.log.length;
expect((await h.run()).outcome).toBe('ok');
expect(rowWrites(h.batches.log.slice(before))).toEqual([]);
// Its device edits upload, and the photo stays on the server.
const id = h.contactNamed('Photo Person')!.id;
const email = h.device.rows('data').find((d) => Number(d.raw_contact_id) === id && d.mimetype === MimeType.EMAIL)!;
h.device.user.updateData(Number(email._id), { data1: 'photo2@example.org' });
expect(await h.run()).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { updated: 1 } } });
expect(h.server.get('ContactCard', 'a', card)).toMatchObject({ emails: { e1: { address: 'photo2@example.org' } }, media });
});
it('keeps a group rename the server refused when a membership patch of that group goes through', async () => {
const h = real();
const [ada] = addServerCards(h, ['Ada Lovelace']);
const group = h.server.addCard('a', { uid: 'g1', kind: 'group', name: { full: 'Friends' }, members: { other: true }, addressBookIds: { [h.book]: true } });
expect((await h.run()).outcome).toBe('ok');
const row = () => h.device.rows('groups').find((g) => g.sourceid === `a/${group}`)!;
const groupId = Number(row()._id);
h.device.user.updateGroup(groupId, { [Groups.TITLE]: 'Best friends' });
h.device.user.addToGroup(h.contactNamed('Ada Lovelace')!.id, groupId);
const rename = (t: SetTarget) => t.op === 'update' && t.id === group && !!t.object && 'name/full' in t.object;
h.server.setErrorFor('ContactCard', 'a', rename, { type: 'invalidProperties', properties: ['name'] });
const report = await h.run();
expect(report.itemErrors).toMatchObject([{ ref: `a/${group}`, type: 'invalidProperties' }]);
const uid = h.server.get('ContactCard', 'a', ada)!.uid as string;
expect(h.server.get('ContactCard', 'a', group)).toMatchObject({ name: { full: 'Friends' }, members: { other: true, [uid]: true } });
expect(row()).toMatchObject({ [Groups.TITLE]: 'Best friends', [Groups.DIRTY]: 1 });
// The rename is still the device's and goes up once the server takes it.
expect((await h.run()).outcome).toBe('ok');
expect(h.server.get('ContactCard', 'a', group)).toMatchObject({ name: { full: 'Best friends' } });
expect(row()).toMatchObject({ [Groups.TITLE]: 'Best friends', [Groups.DIRTY]: 0 });
});
});
@@ -0,0 +1,186 @@
import { describe, expect, it } from 'vitest';
import { Data, EmailType, MimeType, PhoneType, RelationType, WebsiteType } from '../../android-columns';
import { contactsPlanner } from '../../contacts/planner';
import { entryKey, orgKey, parseEntryKey, parseOrgKey } from '../../contacts/keys';
import { editPostalComponents, postalCells, resplitPostal } from '../../contacts/postal';
import { base64ToBytes, dataUriBase64, shadowOf } from '../../contacts/photo';
import { fullIsDerived, nameCells } from '../../contacts/name';
import { emailType, phoneFlags, phoneType, relationName, relationType, websiteType } from '../../contacts/types-map';
import type { ContactCardWire } from '../../planner';
import { JPEG } from './fixtures';
describe('contacts decode', () => {
it('queries the columns the planner reads', () => {
expect(contactsPlanner.rawContactColumns).toEqual(expect.arrayContaining([
'_id', 'sourceid', 'version', 'dirty', 'deleted', 'sync1', 'sync2', 'sync3', 'sync4',
]));
// The providers refuse it in every projection ("Invalid column"): it is only written.
expect(contactsPlanner.rawContactColumns).not.toContain('raw_contact_is_read_only');
expect(contactsPlanner.dataColumns).toEqual(expect.arrayContaining([
'_id', 'raw_contact_id', 'mimetype', 'is_primary', 'data1', 'data14', 'data_sync1', 'data_sync2', 'data_sync3', 'group_sourceid',
]));
expect(contactsPlanner.dataColumns).not.toContain('data15');
expect(contactsPlanner.groupColumns).toEqual(expect.arrayContaining(['_id', 'sourceid', 'version', 'dirty', 'deleted', 'title', 'sync2', 'sync3', 'sync4']));
});
it('reads numbers given as text (data columns are TEXT) and tolerates what other apps left in sync columns', () => {
const local = contactsPlanner.decodeContact(
{ _id: '5', sourceid: 'c/e1', version: '3', dirty: '1', deleted: null, sync1: 'c/b,garbage,,c/x', sync2: '{"not":"a card"}', sync3: '{"uid":1}', sync4: 'nope' },
[
{ _id: '9', raw_contact_id: '5', mimetype: MimeType.EMAIL, data1: 'a@b', data2: '2', data_sync1: 'emails:e1', data_sync2: '', data_sync3: '[1]' },
{ _id: 10, raw_contact_id: 6, mimetype: MimeType.NOTE, data1: 'another contact' },
],
);
expect(local).toMatchObject({
rawContactId: 5, sourceId: 'c/e1', version: 3, dirty: true, deleted: false,
collections: ['c/b', 'c/x'], shadow: null, pending: null, poison: null,
});
expect(local.rows).toEqual([{ id: 9, mimetype: MimeType.EMAIL, cells: { data1: 'a@b', data2: '2' }, key: 'emails:e1', photoHash: null, baseline: null }]);
});
it('decodes a pending create, a poison marker and a group', () => {
const local = contactsPlanner.decodeContact({
_id: 1, sourceid: null, version: 1, dirty: 1, deleted: 0,
sync3: JSON.stringify({ uid: 'urn:uuid:x', target: 'c/b' }),
sync4: JSON.stringify({ fp: 'f', type: 'invalidProperties', n: 2, until: 5 }),
}, []);
expect(local.pending).toEqual({ uid: 'urn:uuid:x', target: 'c/b' });
expect(local.poison).toEqual({ fp: 'f', type: 'invalidProperties', n: 2, until: 5 });
expect(contactsPlanner.decodeGroup({ _id: 4, sourceid: 'c/g', version: 2, dirty: 0, deleted: 1, title: 'T', sync2: '{"id":"g","kind":"group"}' }))
.toMatchObject({ groupId: 4, sourceId: 'c/g', deleted: true, title: 'T', shadow: { id: 'g', kind: 'group' } });
});
});
describe('contacts keys', () => {
it('round-trips entry keys with any character', () => {
for (const key of ['work1', 'x/y', 'a,b|c%d', 'urn:uuid:1', 'k:1']) {
expect(parseEntryKey(entryKey('emails', key))).toEqual({ map: 'emails', key });
}
expect(parseEntryKey('name')).toBeNull();
expect(parseEntryKey('garbage')).toBeNull();
});
it('round-trips organization keys, titles positional as title then role', () => {
expect(orgKey({ org: 'o1', title: 't1', role: 'r1' })).toBe('organizations:o1|titles:t1,r1');
expect(orgKey({ org: null, title: null, role: 'r1' })).toBe('titles:,r1');
expect(orgKey({ org: 'o1', title: null, role: null })).toBe('organizations:o1');
for (const parts of [{ org: 'o|1', title: 't,1', role: null }, { org: null, title: 't1', role: 'r1' }, { org: 'o', title: null, role: null }]) {
expect(parseOrgKey(orgKey(parts))).toEqual(parts);
}
expect(parseOrgKey('emails:e1')).toBeNull();
});
});
describe('contacts type tables', () => {
it.each([
[{ features: { mobile: true }, contexts: { work: true } }, PhoneType.WORK_MOBILE],
[{ features: { cell: true } }, PhoneType.MOBILE],
[{ features: { fax: true }, contexts: { private: true } }, PhoneType.FAX_HOME],
[{ features: { fax: true } }, PhoneType.OTHER_FAX],
[{ features: { pager: true }, contexts: { work: true } }, PhoneType.WORK_PAGER],
[{ features: { textphone: true } }, PhoneType.TTY_TDD],
[{ features: { 'main-number': true } }, PhoneType.MAIN],
[{ features: { voice: true }, contexts: { work: true } }, PhoneType.WORK],
[{ contexts: { private: true } }, PhoneType.HOME],
[{ features: { video: true } }, PhoneType.OTHER],
[{ features: { mobile: true }, label: 'Boat' }, PhoneType.CUSTOM],
])('phone %j → TYPE %i', (entry, type) => {
expect(phoneType(entry).type).toBe(type);
});
it('reads the phone table right to left, OTHER meaning no features', () => {
expect(phoneFlags(PhoneType.OTHER, null)).toEqual({ contexts: [], features: [], label: null });
expect(phoneFlags(PhoneType.FAX_WORK, null)).toEqual({ contexts: ['work'], features: ['fax'], label: null });
expect(phoneFlags(PhoneType.CAR, null)).toEqual({ contexts: [], features: ['voice'], label: 'car' });
});
it('reads back the types RFC 9553 lacks from the label they upload with', () => {
expect(phoneType({ features: { 'main-number': true }, contexts: { work: true } }).type).toBe(PhoneType.COMPANY_MAIN);
expect(phoneType({ features: { voice: true }, label: 'car' })).toEqual({ type: PhoneType.CAR, label: null });
expect(phoneType({ features: { text: true }, label: 'mms' })).toEqual({ type: PhoneType.MMS, label: null });
// A custom label uploads without the feature, so it stays custom; a lone `text` is no MMS.
expect(phoneType({ label: 'car' })).toEqual({ type: PhoneType.CUSTOM, label: 'car' });
expect(phoneType({ features: { text: true } }).type).toBe(PhoneType.OTHER);
expect(emailType({ label: 'mobile' })).toEqual({ type: EmailType.MOBILE, label: null });
expect(websiteType({ label: 'blog', contexts: { work: true } })).toEqual({ type: WebsiteType.BLOG, label: null });
});
it('maps relations both ways, device-only types to the nearest RFC type', () => {
expect(relationType({ friend: true })).toEqual({ type: RelationType.FRIEND, label: null });
expect(relationType({ 'co-worker': true })).toEqual({ type: RelationType.CUSTOM, label: 'co-worker' });
expect(relationType({ agent: true })).toEqual({ type: RelationType.ASSISTANT, label: null });
expect(relationName(RelationType.BROTHER, null)).toBe('sibling');
expect(relationName(RelationType.REFERRED_BY, null)).toBe('contact');
expect(relationName(RelationType.CUSTOM, 'Emergency')).toBe('emergency');
});
});
describe('contacts name and postal helpers', () => {
it('fills the family name from surname and surname2, the suffix from the first of its kinds, and a missing `full` with deriveFullName', () => {
// deriveFullName (the app's) leaves out surname2 and credential.
expect(nameCells({ components: [{ kind: 'surname', value: 'García' }, { kind: 'surname2', value: 'López' }, { kind: 'credential', value: 'MD' }] }))
.toMatchObject({ data3: 'García López', data6: 'MD', data1: 'García' });
});
it('knows a derived `full` in the app’s and in Stalwart’s form', () => {
const components = [{ kind: 'given', value: 'A' }, { kind: 'surname', value: 'B' }, { kind: 'surname2', value: 'C' }];
expect(fullIsDerived({ components, full: 'A B' })).toBe(true);
expect(fullIsDerived({ components, full: 'A B C' })).toBe(true);
expect(fullIsDerived({ components, full: 'B, A' })).toBe(false);
});
it('reads the parts of a rewritten one-line address back along the old line', () => {
// Apple's layout: street / city region postcode / country.
const old = { data1: '1 Infinite Loop\nCupertino CA 95014\nUSA', data4: '1 Infinite Loop', data7: 'Cupertino', data8: 'CA', data9: '95014', data10: 'USA' };
const parts = (line: string) => {
const out = resplitPostal(old, line);
return out && { street: out.data4, city: out.data7, region: out.data8, postcode: out.data9, country: out.data10 };
};
expect(parts('1 Apple Park Way\nCupertino CA 95014\nUSA')).toEqual({ street: '1 Apple Park Way', city: 'Cupertino', region: 'CA', postcode: '95014', country: 'USA' });
expect(parts('1 Infinite Loop\nSan Jose CA 95014\nUSA')).toMatchObject({ city: 'San Jose', region: 'CA', postcode: '95014' });
expect(parts('1 Infinite Loop\nCupertino 95014\nUSA')).toMatchObject({ city: 'Cupertino', region: null, postcode: '95014' });
expect(parts('1 Infinite Loop, Cupertino CA 95014, USA')).toMatchObject({ street: '1 Infinite Loop', city: 'Cupertino' });
// Two parts of one segment changed, or the layout changed: nothing to pin the words to.
expect(parts('1 Infinite Loop\nSan Jose NV 95014\nUSA')).toBeNull();
expect(parts('1 Infinite Loop\nCupertino CA 95014\nCalifornia\nUSA')).toBeNull();
// An old line that isn't made of the parts (a `full` written apart from its components).
expect(resplitPostal({ ...old, data1: 'Apple HQ, Cupertino' }, 'Apple Park, Cupertino')).toBeNull();
});
it('keeps separators between street parts and falls back to the legacy flat fields', () => {
expect(postalCells({ street: 'Old Rd 1', locality: 'Town' })).toMatchObject({ data4: 'Old Rd 1', data7: 'Town' });
const address = { components: [{ kind: 'number', value: '12' }, { kind: 'separator', value: '-' }, { kind: 'name', value: 'Rue X' }] };
expect(postalCells(address).data4).toBe('12-Rue X');
expect(editPostalComponents(address, { data4: 'Rue Y 3' }, new Set(['data4']))).toEqual([{ kind: 'name', value: 'Rue Y 3' }]);
});
});
describe('contacts photos', () => {
it('decodes base64 and data URIs, also without a media type', () => {
expect(base64ToBytes('AAECAw==')).toEqual(new Uint8Array([0, 1, 2, 3]));
expect(base64ToBytes('!!')).toBeNull();
expect(dataUriBase64(`data:image/jpeg;base64,${JPEG}`)).toBe(JPEG);
expect(dataUriBase64(`data:base64,${JPEG}`)).toBe(JPEG);
expect(dataUriBase64('https://example.org/p.jpg')).toBeNull();
});
it('keeps hashes, not bytes, in the shadow and leaves blob-backed entries alone', () => {
const card = { id: 'e1', media: { a: { kind: 'photo', uri: `data:image/jpeg;base64,${JPEG}` }, b: { kind: 'logo', blobId: 'B1' } } } as unknown as ContactCardWire;
const shadow = shadowOf(card);
expect((shadow.media as Record<string, { uri?: string }>).a.uri).toMatch(/^sha256:[0-9a-f]{64}$/);
expect((shadow.media as Record<string, unknown>).b).toEqual({ kind: 'logo', blobId: 'B1' });
expect((card.media as Record<string, { uri?: string }>).a.uri).toContain('base64');
});
});
describe('contacts rows of kinds the planner does not map', () => {
it('ignores them on download and upload', async () => {
const { Harness } = await import('./harness');
const { appleCard } = await import('./fixtures');
const h = new Harness();
const { id, rawId } = await h.seed(appleCard());
h.device.user.insertData(rawId, { [Data.MIMETYPE]: 'vnd.android.cursor.item/im', data1: 'anna@jabber.example' });
expect(h.planner.planUpload(await h.contact(rawId), h.ctx).kind).toBe('clean');
expect(h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx).effect).toBe('none');
});
});
@@ -0,0 +1,418 @@
import { describe, expect, it } from 'vitest';
import { Data, MimeType, RawContacts } from '../../android-columns';
import { contactFingerprint } from '../../contacts/planner';
import type { Row } from '../../types';
import { appleCard, googleCard, JPEG2, probeCard } from './fixtures';
import { ACCOUNT, Harness, JMAP } from './harness';
/**
* The rows Fossify Contacts writes back on save: every kind it models
* re-inserted without DATA_SYNC, IS_PRIMARY or the columns it has no field
* for (a department, a job description, a website's type), one nickname and
* one organization only.
*/
function fossifyRows(h: Harness, rawId: number, edit: (rows: Row[]) => void = () => {}): Row[] {
const out: Row[] = [];
let nickname = false;
let organization = false;
for (const r of h.device.rows('data', 'raw_contact_id = ?', [rawId])) {
const pick = (...columns: string[]) => {
const row: Row = { [Data.MIMETYPE]: r[Data.MIMETYPE] };
for (const c of columns) row[c] = r[c];
out.push(row);
};
switch (r[Data.MIMETYPE]) {
case MimeType.NICKNAME: if (!nickname) pick('data1'); nickname = true; break;
case MimeType.PHONE: case MimeType.EMAIL: pick('data1', 'data2', 'data3'); break;
case MimeType.STRUCTURED_POSTAL: pick('data1', 'data2'); break;
case MimeType.EVENT: pick('data1', 'data2'); break;
case MimeType.NOTE: pick('data1'); break;
case MimeType.ORGANIZATION: if (!organization) pick('data1', 'data4'); organization = true; break;
case MimeType.WEBSITE: out.push({ [Data.MIMETYPE]: MimeType.WEBSITE, data1: r.data1, data2: 1 }); break;
case MimeType.GROUP_MEMBERSHIP: pick('data1'); break;
}
}
edit(out);
return out;
}
async function uploadPlan(h: Harness, rawId: number) {
return h.planner.planUpload(await h.contact(rawId), h.ctx);
}
async function patchOf(h: Harness, rawId: number) {
const plan = await uploadPlan(h, rawId);
if (plan.kind !== 'upload' || plan.actions[0].kind !== 'update') throw new Error(`expected an update, got ${plan.kind}`);
return plan.actions[0].patch;
}
describe('contacts upload: Fossify re-inserts rows', () => {
it('uploads nothing for a save without changes, then restores what it dropped', async () => {
const h = new Harness();
const { rawId } = await h.seed(probeCard());
h.device.user.fossifySave(rawId, null, fossifyRows(h, rawId));
const plan = await uploadPlan(h, rawId);
expect(plan.kind).toBe('clean');
await h.upload(rawId);
const rows = h.rowsByKey(rawId);
expect(rows['organizations:o1|titles:t1,r1']).toMatchObject({ data1: 'ACME', data4: 'Chief Prober', data5: 'R&D', data6: 'Lead' });
expect(Number(rows['links:l1'].data2)).toBe(5);
expect(Number(rows['emails:work1'].is_primary)).toBe(1);
expect(h.dirty(rawId)).toBe(false);
expect(h.planner.planBaselineHeal(await h.contact(rawId))).toBeNull();
});
it('never clears the department it drops (a value-matched row has no baseline)', async () => {
const h = new Harness();
const { rawId } = await h.seed(probeCard());
h.device.user.fossifySave(rawId, null, fossifyRows(h, rawId, (rows) => {
rows.find((r) => r[Data.MIMETYPE] === MimeType.ORGANIZATION)!.data4 = 'Chief Tester';
}));
expect(await patchOf(h, rawId)).toEqual({ 'titles/t1/name': 'Chief Tester' });
});
it('uploads the one email it changed, matching the others by value', async () => {
const h = new Harness();
const { rawId } = await h.seed(appleCard());
h.device.user.fossifySave(rawId, null, fossifyRows(h, rawId, (rows) => {
rows.find((r) => r.data1 === 'anna@work.example')!.data1 = 'anna@new.example';
}));
expect(await patchOf(h, rawId)).toEqual({ 'emails/k2/address': 'anna@new.example' });
});
it('keeps the second nickname it could not show, and puts it back on the device', async () => {
const h = new Harness();
const { id, rawId } = await h.seed(googleCard());
h.device.user.fossifySave(rawId, null, fossifyRows(h, rawId));
expect((await uploadPlan(h, rawId)).kind).toBe('clean');
await h.upload(rawId);
expect(h.rowsByKey(rawId)['nicknames:k2'].data1).toBe('GKG');
expect(h.card(id).nicknames).toEqual(googleCard().nicknames);
});
it('does not delete an entry it rewrote away (a documented limitation)', async () => {
const h = new Harness();
const { rawId } = await h.seed(appleCard());
h.device.user.fossifySave(rawId, null, fossifyRows(h, rawId, (rows) => {
rows.splice(rows.findIndex((r) => r.data1 === 'anna@work.example'), 1);
}));
expect((await uploadPlan(h, rawId)).kind).toBe('clean');
});
});
describe('contacts upload: deletions (in-place editor)', () => {
it('resends the map minus the entry, its other entries as the server has them', async () => {
const h = new Harness();
const { id, rawId } = await h.seed(probeCard());
h.device.user.deleteData(h.rowsByKey(rawId)['emails:zz9']._id as number);
const { emails } = h.card(id) as { emails: Record<string, unknown> };
expect(await patchOf(h, rawId)).toEqual({ emails: { work1: emails.work1, 'x/y': emails['x/y'] } });
});
it('deletes an address with a null (clean on Stalwart)', async () => {
const h = new Harness();
const { rawId } = await h.seed(probeCard());
h.device.user.deleteData(h.rowsByKey(rawId)['addresses:home']._id as number);
expect(await patchOf(h, rawId)).toEqual({ 'addresses/home': null });
});
it('keeps the notes after the first when the note is deleted', async () => {
const h = new Harness();
const { rawId } = await h.seed(probeCard());
h.device.user.deleteData(h.rowsByKey(rawId)['notes:n1']._id as number);
expect(await patchOf(h, rawId)).toEqual({ notes: { n2: { note: 'second note' } } });
});
it('removes a map whose last entry went, when other rows show an in-place editor', async () => {
const h = new Harness();
const { rawId } = await h.seed(probeCard());
h.device.user.deleteData(h.rowsByKey(rawId)['nicknames:nk']._id as number);
expect(await patchOf(h, rawId)).toEqual({ nicknames: null });
});
it('deletes an organization with its titles', async () => {
const h = new Harness();
const { rawId } = await h.seed(probeCard());
h.device.user.deleteData(h.rowsByKey(rawId)['organizations:o1|titles:t1,r1']._id as number);
expect(await patchOf(h, rawId)).toEqual({ organizations: null, titles: null });
});
it('deletes the photo', async () => {
const h = new Harness();
const { rawId } = await h.seed(probeCard());
h.device.user.deleteData(h.rowsByKey(rawId)['media:ph']._id as number);
expect(await patchOf(h, rawId)).toEqual({ media: null });
});
it('deletes a relation by resending relatedTo (Stalwart ignores a null there)', async () => {
const h = new Harness();
const { rawId } = await h.seed(probeCard());
h.device.user.deleteData(h.rowsByKey(rawId)['relatedTo:urn:uuid:other-person']._id as number);
expect(await patchOf(h, rawId)).toEqual({ relatedTo: null });
});
it('folds an edit into a map it resends', async () => {
const h = new Harness();
const { id, rawId } = await h.seed(probeCard());
const rows = h.rowsByKey(rawId);
h.device.user.deleteData(rows['emails:zz9']._id as number);
h.device.user.updateData(rows['emails:work1']._id as number, { data1: 'new@work.example' });
const { emails } = h.card(id) as unknown as { emails: Record<string, Record<string, unknown>> };
expect(await patchOf(h, rawId)).toEqual({ emails: { work1: { ...emails.work1, address: 'new@work.example' }, 'x/y': emails['x/y'] } });
});
});
describe('contacts upload: additions', () => {
it('adds an entry to an existing map under a new key', async () => {
const h = new Harness();
const { rawId } = await h.seed(probeCard());
h.device.user.insertData(rawId, { [Data.MIMETYPE]: MimeType.EMAIL, data1: 'added@example.org', data2: 2 });
const patch = await patchOf(h, rawId);
const [pointer] = Object.keys(patch);
expect(pointer).toMatch(/^emails\/b[0-9a-z]{8}$/);
expect(patch[pointer]).toEqual({ address: 'added@example.org', contexts: { work: true } });
});
it('sends the whole map for the first entry of a map', async () => {
const h = new Harness();
const { rawId } = await h.seed(appleCard());
h.device.user.insertData(rawId, { [Data.MIMETYPE]: MimeType.WEBSITE, data1: 'https://anna.example', data2: 7 });
const patch = await patchOf(h, rawId);
expect(Object.keys(patch)).toEqual(['links']);
expect(Object.values(patch.links as object)).toEqual([{ uri: 'https://anna.example' }]);
});
it('adds a work phone with its features and contexts', async () => {
const h = new Harness();
const { rawId } = await h.seed(probeCard());
h.device.user.insertData(rawId, { [Data.MIMETYPE]: MimeType.PHONE, data1: '+49 89 1', data2: 3 });
expect(Object.values(await patchOf(h, rawId))).toEqual([{ number: '+49 89 1', features: { voice: true }, contexts: { work: true } }]);
});
it('adds an organization and a title that points at it', async () => {
const h = new Harness();
const { rawId } = await h.seed(appleCard());
h.device.user.insertData(rawId, { [Data.MIMETYPE]: MimeType.ORGANIZATION, data1: 'Apple', data4: 'Engineer' });
const patch = await patchOf(h, rawId) as { organizations: Record<string, unknown>; titles: Record<string, Record<string, unknown>> };
const [orgKey] = Object.keys(patch.organizations);
expect(patch.organizations[orgKey]).toEqual({ name: 'Apple' });
expect(Object.values(patch.titles)).toEqual([{ name: 'Engineer', kind: 'title', organizationId: orgKey }]);
});
it('adds a relation keyed by its name', async () => {
const h = new Harness();
const { rawId } = await h.seed(probeCard());
h.device.user.insertData(rawId, { [Data.MIMETYPE]: MimeType.RELATION, data1: 'Jane Doe', data2: 14 });
expect(await patchOf(h, rawId)).toEqual({ 'relatedTo/Jane Doe': { relation: { spouse: true } } });
});
it('adds a photo from the device as a JPEG data URI', async () => {
const h = new Harness();
const { rawId } = await h.seed(appleCard());
h.device.user.setPhoto(rawId, JPEG2);
h.devicePhotos.set(rawId, JPEG2);
const patch = await patchOf(h, rawId);
expect(Object.values(patch.media as object)).toEqual([{ kind: 'photo', uri: `data:image/jpeg;base64,${JPEG2}`, mediaType: 'image/jpeg' }]);
});
it('replaces a changed photo in place', async () => {
const h = new Harness();
const { rawId } = await h.seed(probeCard());
h.device.user.setPhoto(rawId, JPEG2);
h.devicePhotos.set(rawId, JPEG2);
expect(await patchOf(h, rawId)).toEqual({ 'media/ph/uri': `data:image/jpeg;base64,${JPEG2}`, 'media/ph/mediaType': 'image/jpeg' });
});
});
describe('contacts upload: new contacts', () => {
function insertNew(h: Harness): number {
return h.device.user.insertContact(ACCOUNT, [
{ [Data.MIMETYPE]: MimeType.STRUCTURED_NAME, data1: 'Neu Person', data2: 'Neu', data3: 'Person' },
{ [Data.MIMETYPE]: MimeType.EMAIL, data1: 'neu@example.org', data2: 1, is_primary: 1 },
{ [Data.MIMETYPE]: MimeType.PHONE, data1: '+49 1', data2: 2 },
{ [Data.MIMETYPE]: MimeType.STRUCTURED_POSTAL, data1: 'Weg 1, 12345 Ort', data2: 1, data4: 'Weg 1', data7: 'Ort', data9: '12345' },
{ [Data.MIMETYPE]: MimeType.EVENT, data1: '1990-01-02', data2: 3 },
{ [Data.MIMETYPE]: MimeType.NOTE, data1: 'hello' },
{ [Data.MIMETYPE]: MimeType.NICKNAME, data1: 'Neuling' },
]);
}
it('claims a uid and target first, then creates the card from its rows', async () => {
const h = new Harness();
const rawId = insertNew(h);
const claim = h.planner.planUpload(await h.contact(rawId), h.ctx);
expect(claim.kind).toBe('claim');
await h.applyOk((claim as { ops: import('../../planner').OpGroup }).ops);
const pending = (await h.contact(rawId)).pending!;
expect(pending.target).toBe(`${JMAP}/${h.book}`);
const plan = h.planner.planUpload(await h.contact(rawId), h.ctx);
if (plan.kind !== 'upload' || plan.actions[0].kind !== 'create') throw new Error(plan.kind);
const { object, uid, collectionId } = plan.actions[0];
expect(uid).toBe(pending.uid);
expect(collectionId).toBe(h.book);
const values = (map: unknown) => Object.values(map as object);
expect(object).toMatchObject({ '@type': 'Card', version: '1.0', uid: pending.uid, addressBookIds: { [h.book]: true } });
expect(object.name).toEqual({ components: [{ kind: 'given', value: 'Neu' }, { kind: 'surname', value: 'Person' }], full: 'Neu Person' });
expect(values(object.emails)).toEqual([{ address: 'neu@example.org', contexts: { private: true }, pref: 1 }]);
expect(values(object.phones)).toEqual([{ number: '+49 1', features: { mobile: true } }]);
expect(values(object.addresses)).toEqual([{
components: [{ kind: 'name', value: 'Weg 1' }, { kind: 'locality', value: 'Ort' }, { kind: 'postcode', value: '12345' }],
full: 'Weg 1, 12345 Ort',
contexts: { private: true },
}]);
expect(values(object.anniversaries)).toEqual([{ kind: 'birth', date: { '@type': 'PartialDate', year: 1990, month: 1, day: 2 } }]);
expect(values(object.notes)).toEqual([{ note: 'hello' }]);
expect(values(object.nicknames)).toEqual([{ name: 'Neuling' }]);
expect(Object.keys(object).sort()).toEqual([
'@type', 'addressBookIds', 'addresses', 'anniversaries', 'emails', 'name', 'nicknames', 'notes', 'phones', 'uid', 'version',
]);
});
it('takes the identity after the create and writes nothing when the card comes back', async () => {
const h = new Harness();
const rawId = insertNew(h);
await h.upload(rawId);
const local = await h.contact(rawId);
expect(local).toMatchObject({ dirty: false, pending: null });
expect(local.sourceId).toMatch(/^c\//);
expect(local.rows.every((r) => r.key !== null)).toBe(true);
const plan = h.planner.planDownload(h.card(h.cardIdOf(local)), local, h.ctx);
expect(plan.effect).toBe('none');
});
it('is skipped when no writable book is selected', async () => {
const h = new Harness();
const rawId = insertNew(h);
const plan = h.planner.planUpload(await h.contact(rawId), { ...h.ctx, createTarget: () => null });
expect(plan).toEqual({ kind: 'skip', reason: 'noWritableAddressBook' });
});
});
describe('contacts upload: deleted contacts', () => {
it('destroys the card, then purges the rows', async () => {
const h = new Harness();
const { id, rawId } = await h.seed(appleCard());
h.device.user.deleteContact(rawId);
const plan = await uploadPlan(h, rawId);
expect(plan).toEqual({ kind: 'upload', actions: [{ kind: 'destroy', id, uid: appleCard().uid }] });
await h.upload(rawId);
expect(h.device.row('raw_contacts', rawId)).toBeUndefined();
expect(h.server.get('ContactCard', JMAP, id)).toBeUndefined();
});
it('only leaves the synced book of a card that is also in an unsynced one', async () => {
const h = new Harness();
const other = h.server.addAddressBook(JMAP, { name: 'Other' });
h.unselected.add(`${JMAP}/${other}`);
const { rawId } = await h.seed({ ...appleCard(), addressBookIds: { [h.book]: true, [other]: true } });
h.device.user.deleteContact(rawId);
expect(await patchOf(h, rawId)).toEqual({ [`addressBookIds/${h.book}`]: null });
});
it('only leaves the synced book when the server filed the card in an unsynced one after the device deleted it', async () => {
const h = new Harness();
const archive = h.server.addAddressBook(JMAP, { name: 'Archive' });
h.unselected.add(`${JMAP}/${archive}`);
const { id, rawId } = await h.seed(appleCard());
h.device.user.deleteContact(rawId);
h.server.serverUpdate('ContactCard', JMAP, id, { [`addressBookIds/${archive}`]: true, 'emails/k1/address': 'new@example.com' });
const rows = JSON.stringify(h.device.rows('data', 'raw_contact_id = ?', [rawId]));
await h.applyOk(h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx).ops);
// The delete wins over the server's edit: the rows stay as they were, only the shadow follows.
expect(JSON.stringify(h.device.rows('data', 'raw_contact_id = ?', [rawId]))).toBe(rows);
expect(await uploadPlan(h, rawId)).toEqual({ kind: 'upload', actions: [{ kind: 'update', id, patch: { [`addressBookIds/${h.book}`]: null } }] });
await h.upload(rawId);
expect(h.server.get('ContactCard', JMAP, id)).toMatchObject({ addressBookIds: { [archive]: true } });
expect(h.device.row('raw_contacts', rawId)).toBeUndefined();
});
it('leaves a card the server moved out of every synced book before the deletion reached it, and only purges the rows', async () => {
const h = new Harness();
const archive = h.server.addAddressBook(JMAP, { name: 'Archive' });
h.unselected.add(`${JMAP}/${archive}`);
const { id, rawId } = await h.seed(appleCard());
h.device.user.deleteContact(rawId);
h.server.serverUpdate('ContactCard', JMAP, id, { addressBookIds: { [archive]: true } });
await h.applyOk(h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx).ops);
expect(await uploadPlan(h, rawId)).toMatchObject({ kind: 'purge' });
await h.upload(rawId);
expect(h.device.row('raw_contacts', rawId)).toBeUndefined();
expect(h.server.get('ContactCard', JMAP, id)).toMatchObject({ addressBookIds: { [archive]: true } });
});
it('still destroys a card the server moved to another synced book, or whose book was deselected before the upload', async () => {
const moved = new Harness();
const work = moved.server.addAddressBook(JMAP, { name: 'Work' });
const a = await moved.seed(appleCard());
moved.device.user.deleteContact(a.rawId);
moved.server.serverUpdate('ContactCard', JMAP, a.id, { addressBookIds: { [work]: true } });
await moved.applyOk(moved.planner.planDownload(moved.card(a.id), await moved.contact(a.rawId), moved.ctx).ops);
expect(await uploadPlan(moved, a.rawId)).toEqual({ kind: 'upload', actions: [{ kind: 'destroy', id: a.id, uid: appleCard().uid }] });
const deselected = new Harness();
const b = await deselected.seed(appleCard());
deselected.device.user.deleteContact(b.rawId);
deselected.unselected.add(`${JMAP}/${deselected.book}`);
expect(await uploadPlan(deselected, b.rawId)).toEqual({ kind: 'upload', actions: [{ kind: 'destroy', id: b.id, uid: appleCard().uid }] });
});
it('deletes its own create by id once the download found it, not by a uid lookup', async () => {
const h = new Harness();
const rawId = h.device.user.insertContact(ACCOUNT, [{ [Data.MIMETYPE]: MimeType.NOTE, data1: 'y' }]);
const plan = await h.planUpload(rawId);
if (plan.kind !== 'upload') throw new Error(plan.kind);
// The create went through but its response was lost; then the user deleted the contact.
const id = h.send(plan.actions)!;
h.device.user.deleteContact(rawId);
await h.applyOk(h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx).ops);
expect(await h.contact(rawId)).toMatchObject({ sourceId: `${JMAP}/${id}`, pending: null, deleted: true });
expect(await uploadPlan(h, rawId)).toEqual({ kind: 'upload', actions: [{ kind: 'destroy', id, uid: h.card(id).uid }] });
});
it('purges a new contact that never reached the server, and destroys a claimed one by uid', async () => {
const h = new Harness();
const fresh = h.device.user.insertContact(ACCOUNT, [{ [Data.MIMETYPE]: MimeType.NOTE, data1: 'x' }]);
h.device.user.deleteContact(fresh);
expect((await uploadPlan(h, fresh)).kind).toBe('purge');
const claimed = h.device.user.insertContact(ACCOUNT, [{ [Data.MIMETYPE]: MimeType.NOTE, data1: 'y' }]);
await h.planUpload(claimed);
const uid = (await h.contact(claimed)).pending!.uid;
h.device.user.deleteContact(claimed);
expect(await uploadPlan(h, claimed)).toEqual({ kind: 'upload', actions: [{ kind: 'destroy', id: null, uid }] });
});
});
describe('contacts upload: read-only and poisoned contacts', () => {
it('reverts an edit of a read-only contact from the shadow', async () => {
const h = new Harness();
h.readOnlyBooks.add(h.book);
const { rawId } = await h.seed(appleCard());
h.device.user.updateData(h.rowsByKey(rawId)['emails:k1']._id as number, { data1: 'hacked@example.org' });
const plan = await uploadPlan(h, rawId);
expect(plan).toMatchObject({ kind: 'revert', reason: 'readOnly' });
await h.upload(rawId);
expect(h.rowsByKey(rawId)['emails:k1'].data1).toBe('anna@example.com');
expect(h.dirty(rawId)).toBe(false);
});
it('purges a deleted read-only contact and asks for it again', async () => {
const h = new Harness();
h.readOnlyBooks.add(h.book);
const { rawId } = await h.seed(appleCard());
h.device.user.deleteContact(rawId);
expect(await uploadPlan(h, rawId)).toMatchObject({ kind: 'revert', refetch: true, reason: 'readOnly' });
});
it('skips a poisoned contact until it changes or the back-off ends', async () => {
const h = new Harness();
const { rawId } = await h.seed(appleCard());
const email = h.rowsByKey(rawId)['emails:k1']._id as number;
h.device.user.updateData(email, { data1: 'bad@example.org' });
const poison = { fp: contactFingerprint(await h.contact(rawId)), type: 'invalidProperties', n: 1, until: h.ctx.now + 3_600_000 };
await h.applyOk({ ref: 'x', ops: [{ op: 'update', table: 'raw_contacts', id: rawId, values: { [RawContacts.SYNC4]: JSON.stringify(poison) } }] });
expect(await uploadPlan(h, rawId)).toEqual({ kind: 'skip', reason: 'poisoned:invalidProperties' });
expect(h.planner.planUpload(await h.contact(rawId), { ...h.ctx, now: poison.until + 1 }).kind).toBe('upload');
h.device.user.updateData(email, { data1: 'good@example.org' });
expect((await uploadPlan(h, rawId)).kind).toBe('upload');
});
});
@@ -0,0 +1,372 @@
import { describe, expect, it } from 'vitest';
import { Data, EmailType, MimeType, PhoneType, WebsiteType } from '../../android-columns';
import { deepEqual } from '../../common/json';
import { applyPatch, assertPatchWellFormed, type PatchObject } from '../../common/patch';
import type { ContactCardWire } from '../../planner';
import type { Row } from '../../types';
import { appleCard, googleCard, probeCard } from './fixtures';
import { Harness } from './harness';
/** Card data Android has no column for: every patch must leave it as it was. */
const UNMAPPED = [
'onlineServices', 'speakToAs', 'keywords', 'personalInfo', 'preferredLanguages', 'notes/n2', 'name/phoneticSystem',
'addresses/home/coordinates', 'addresses/home/timeZone', 'addresses/home/countryCode', 'kind', 'uid',
];
function at(card: unknown, path: string): unknown {
return path.split('/').reduce<unknown>((v, k) => (v && typeof v === 'object' ? (v as Record<string, unknown>)[k] : undefined), card);
}
/** Seeds a card, edits one row in place like AOSP Contacts, and returns the upload patch. */
async function editedPatch(make: () => Record<string, unknown>, key: string, values: Row): Promise<{ patch: PatchObject; card: ContactCardWire }> {
const h = new Harness();
const { id, rawId } = await h.seed(make());
const row = h.rowsByKey(rawId)[key];
if (!row) throw new Error(`no row ${key}`);
h.device.user.updateData(row._id as number, values);
const plan = h.planner.planUpload(await h.contact(rawId), h.ctx);
if (plan.kind !== 'upload' || plan.actions[0].kind !== 'update') throw new Error(`expected an update, got ${plan.kind}`);
return { patch: plan.actions[0].patch, card: h.card(id) };
}
const probeEdits: Array<[string, string, Row, PatchObject]> = [
['email address', 'emails:work1', { data1: 'new@work.example' }, { 'emails/work1/address': 'new@work.example' }],
['email type (home → work)', 'emails:zz9', { data2: 2 }, { 'emails/zz9/contexts': { work: true } }],
['email custom label', 'emails:work1', { data3: 'Desk' }, { 'emails/work1/label': 'Desk' }],
['a key with a slash', 'emails:x/y', { data1: 'slash2@example.org' }, { 'emails/x~1y/address': 'slash2@example.org' }],
['phone number', 'phones:p-a', { data1: '+49 30 5555' }, { 'phones/p-a/number': '+49 30 5555' }],
['phone type (mobile → work mobile)', 'phones:p-a', { data2: 17 }, { 'phones/p-a/contexts': { work: true }, 'phones/p-a/features': { mobile: true } }],
['nickname', 'nicknames:nk', { data1: 'Probster' }, { 'nicknames/nk/name': 'Probster' }],
['street', 'addresses:home', { data4: 'Hauptstr. 1' }, {
'addresses/home/components': [
{ kind: 'name', value: 'Hauptstr. 1' },
{ kind: 'locality', value: 'Berlin' },
{ kind: 'postcode', value: '10115' },
{ kind: 'country', value: 'Germany' },
],
}],
['city', 'addresses:home', { data7: 'Potsdam' }, {
'addresses/home/components': [
{ kind: 'name', value: 'Main St' }, { kind: 'separator', value: ' ' }, { kind: 'number', value: '12' },
{ kind: 'locality', value: 'Potsdam' }, { kind: 'postcode', value: '10115' }, { kind: 'country', value: 'Germany' },
{ kind: 'apartment', value: '4b' },
],
}],
['region, which the address did not have', 'addresses:home', { data8: 'Berlin' }, {
'addresses/home/components': [
{ kind: 'name', value: 'Main St' }, { kind: 'separator', value: ' ' }, { kind: 'number', value: '12' },
{ kind: 'locality', value: 'Berlin' }, { kind: 'region', value: 'Berlin' }, { kind: 'postcode', value: '10115' },
{ kind: 'country', value: 'Germany' }, { kind: 'apartment', value: '4b' },
],
}],
['formatted address', 'addresses:home', { data1: 'Main St 12\n10115 Berlin' }, { 'addresses/home/full': 'Main St 12\n10115 Berlin' }],
['address type (home → work)', 'addresses:home', { data2: 2 }, { 'addresses/home/contexts': { work: true } }],
['company', 'organizations:o1|titles:t1,r1', { data1: 'ACME Corp' }, { 'organizations/o1/name': 'ACME Corp' }],
['department', 'organizations:o1|titles:t1,r1', { data5: 'QA, Tools' }, { 'organizations/o1/units': [{ name: 'QA' }, { name: 'Tools' }] }],
['job title', 'organizations:o1|titles:t1,r1', { data4: 'Chief Tester' }, { 'titles/t1/name': 'Chief Tester' }],
['job description (role)', 'organizations:o1|titles:t1,r1', { data6: 'Mentor' }, { 'titles/r1/name': 'Mentor' }],
['website', 'links:l1', { data1: 'https://probe2.example' }, { 'links/l1/uri': 'https://probe2.example' }],
['website type (work → home)', 'links:l1', { data2: 4 }, { 'links/l1/contexts': { private: true } }],
['birthday date', 'anniversaries:bday', { data1: '--03-15' }, { 'anniversaries/bday/date': { '@type': 'PartialDate', month: 3, day: 15 } }],
['anniversary with a year', 'anniversaries:wed', { data1: '2011-06-01' }, { 'anniversaries/wed/date': { '@type': 'PartialDate', year: 2011, month: 6, day: 1 } }],
['anniversary type (birthday → other)', 'anniversaries:bday', { data2: 2 }, { 'anniversaries/bday/kind': 'other' }],
['relation type (friend → spouse)', 'relatedTo:urn:uuid:other-person', { data2: 14 }, { 'relatedTo/urn:uuid:other-person/relation': { spouse: true } }],
['relation type without an RFC twin (brother)', 'relatedTo:urn:uuid:other-person', { data2: 2 }, { 'relatedTo/urn:uuid:other-person/relation': { sibling: true } }],
['note', 'notes:n1', { data1: 'edited note' }, { 'notes/n1/note': 'edited note' }],
];
describe('contacts upload: one edited field, one pointer', () => {
it.each(probeEdits)('%s', async (_name, key, values, expected) => {
const { patch, card } = await editedPatch(probeCard, key, values);
expect(patch).toEqual(expected);
assertPatchWellFormed(patch);
const applied = applyPatch(card, patch);
expect(applied).not.toBeNull();
for (const path of UNMAPPED) expect(deepEqual(at(applied, path), at(card, path))).toBe(true);
});
it('moves IS_PRIMARY as pref', async () => {
const h = new Harness();
const { rawId } = await h.seed(probeCard());
const rows = h.rowsByKey(rawId);
h.device.user.updateData(rows['emails:x/y']._id as number, { is_primary: 1 });
h.device.user.updateData(rows['emails:work1']._id as number, { is_primary: 0 });
const plan = h.planner.planUpload(await h.contact(rawId), h.ctx);
expect(plan).toMatchObject({ kind: 'upload', actions: [{ kind: 'update', patch: { 'emails/x~1y/pref': 1, 'emails/work1/pref': null } }] });
});
it('renames a relation by moving it to the new key (relatedTo is keyed by value)', async () => {
const { patch } = await editedPatch(probeCard, 'relatedTo:urn:uuid:other-person', { data1: 'Jane Roe' });
expect(patch).toEqual({ relatedTo: { 'Jane Roe': { relation: { friend: true } } } });
});
it('keeps a free-text date the server cannot take on the device only', async () => {
const h = new Harness();
const { rawId } = await h.seed(probeCard());
h.device.user.updateData(h.rowsByKey(rawId)['anniversaries:bday']._id as number, { data1: 'next spring' });
expect(h.planner.planUpload(await h.contact(rawId), h.ctx).kind).toBe('clean');
});
});
describe('contacts upload: every device type comes back as the device wrote it', () => {
const cases = (types: Record<string, number>, custom: number) =>
Object.entries(types).map(([name, type]) => [name, type, type === custom ? 'Boat' : null] as const);
const kinds = [
{ mimetype: MimeType.PHONE, value: '+49 30 1', map: 'phones', entry: { number: '+49 30 1', features: { voice: true } }, types: cases(PhoneType, PhoneType.CUSTOM) },
{ mimetype: MimeType.EMAIL, value: 'typed@example.org', map: 'emails', entry: { address: 'typed@example.org' }, types: cases(EmailType, EmailType.CUSTOM) },
{ mimetype: MimeType.WEBSITE, value: 'https://typed.example', map: 'links', entry: { uri: 'https://typed.example' }, types: cases(WebsiteType, WebsiteType.CUSTOM) },
];
const typeOf = (row: Row) => [Number(row.data2), row.data3 ?? null];
describe.each(kinds)('$map', ({ mimetype, value, map, entry, types }) => {
it.each(types)('%s, on a contact created on the device', async (_name, type, label) => {
const h = new Harness();
const rawId = h.device.user.insertContact('usera@example.org', [
{ [Data.MIMETYPE]: MimeType.STRUCTURED_NAME, data1: 'Typed Person', data2: 'Typed', data3: 'Person' },
{ [Data.MIMETYPE]: mimetype, data1: value, data2: type, data3: label },
]);
await h.upload(rawId);
const [row] = h.device.rows('data', 'raw_contact_id = ? AND mimetype = ?', [rawId, mimetype]);
expect(typeOf(row)).toEqual([type, label]);
const local = await h.contact(rawId);
expect(local.dirty).toBe(false);
expect(h.planner.planDownload(h.card(h.cardIdOf(local)), local, h.ctx).effect).toBe('none');
});
it.each(types)('%s, chosen on the device for a synced entry', async (_name, type, label) => {
const h = new Harness();
const { id, rawId } = await h.seed({ name: { full: 'Typed Person' }, [map]: { e1: entry } });
h.device.user.updateData(h.rowsByKey(rawId)[`${map}:e1`]._id as number, { data2: type, data3: label });
await h.upload(rawId);
expect(typeOf(h.rowsByKey(rawId)[`${map}:e1`])).toEqual([type, label]);
expect(h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx).effect).toBe('none');
});
});
});
describe('contacts upload: an address edited as one line (AOSP Contacts)', () => {
const munich = () => ({
name: { full: 'Maria Muster' },
addresses: {
home: {
contexts: { private: true },
coordinates: 'geo:48.137,11.575',
components: [
{ kind: 'name', value: 'Marienplatz 1' },
{ kind: 'postcode', value: '80331' },
{ kind: 'locality', value: 'Munich' },
{ kind: 'region', value: 'Bavaria' },
{ kind: 'country', value: 'Germany' },
],
},
},
});
const LINE = 'Marienplatz 1, 80331 Munich, Bavaria, Germany';
/** AOSP writes the formatted address alone; the provider keeps it in STREET and empties the other parts. */
async function editLine(line: string) {
const h = new Harness();
const { id, rawId } = await h.seed(munich());
const row = h.rowsByKey(rawId)['addresses:home'];
expect(row.data1).toBe(LINE);
h.device.user.updateData(row._id as number, { data1: line, data4: null, data5: null, data6: null, data7: null, data8: null, data9: null, data10: null });
expect(h.rowsByKey(rawId)['addresses:home']).toMatchObject({ data1: line, data4: line, data7: null });
return { h, id, rawId };
}
async function patchOf(h: Harness, rawId: number): Promise<PatchObject> {
const plan = h.planner.planUpload(await h.contact(rawId), h.ctx);
if (plan.kind !== 'upload' || plan.actions[0].kind !== 'update') throw new Error(`expected an update, got ${plan.kind}`);
return plan.actions[0].patch;
}
const components = (street: string, locality = 'Munich', postcode = '80331') => [
{ kind: 'name', value: street },
{ kind: 'postcode', value: postcode },
{ kind: 'locality', value: locality },
{ kind: 'region', value: 'Bavaria' },
{ kind: 'country', value: 'Germany' },
];
it('reads the parts back from the new line, so `full` and the components agree', async () => {
const line = 'Industrieweg 42, 80331 Augsburg, Bavaria, Germany';
const { h, id, rawId } = await editLine(line);
expect(await patchOf(h, rawId)).toEqual({
'addresses/home/components': components('Industrieweg 42', 'Augsburg'),
'addresses/home/full': line,
});
await h.upload(rawId);
expect(h.card(id).addresses!.home).toEqual({
contexts: { private: true }, coordinates: 'geo:48.137,11.575', isOrdered: true, full: line,
components: components('Industrieweg 42', 'Augsburg'),
});
// The device gets its parts back and settles.
expect(h.rowsByKey(rawId)['addresses:home']).toMatchObject({ data1: line, data4: 'Industrieweg 42', data7: 'Augsburg', data9: '80331' });
const local = await h.contact(rawId);
expect(local.dirty).toBe(false);
expect(h.planner.planDownload(h.card(id), local, h.ctx).effect).toBe('none');
});
it('changes only the part whose words changed', async () => {
const { h, rawId } = await editLine('Marienplatz 2, 80331 Munich, Bavaria, Germany');
expect(await patchOf(h, rawId)).toEqual({
'addresses/home/components': components('Marienplatz 2'),
'addresses/home/full': 'Marienplatz 2, 80331 Munich, Bavaria, Germany',
});
});
it('keeps the whole line as the street when the change can not be pinned to one part', async () => {
const line = 'Industrieweg 42, 86150 Augsburg, Bavaria, Germany';
const { h, id, rawId } = await editLine(line);
expect(await patchOf(h, rawId)).toEqual({ 'addresses/home/components': [{ kind: 'name', value: line }], 'addresses/home/full': line });
await h.upload(rawId);
expect(h.card(id).addresses!.home).toMatchObject({ contexts: { private: true }, coordinates: 'geo:48.137,11.575', full: line, components: [{ kind: 'name', value: line }] });
const local = await h.contact(rawId);
expect(local.dirty).toBe(false);
expect(h.planner.planDownload(h.card(id), local, h.ctx).effect).toBe('none');
});
it('uploads nothing for the same line saved again', async () => {
const { h, rawId } = await editLine(LINE);
expect(h.planner.planUpload(await h.contact(rawId), h.ctx).kind).toBe('clean');
});
it('reads the parts back from a line an editor re-inserted without its key', async () => {
const line = 'Marienplatz 1, 80331 Munich, Upper Bavaria, Germany';
const h = new Harness();
const { rawId } = await h.seed(munich());
h.device.user.fossifySave(rawId, null, [{ [Data.MIMETYPE]: MimeType.STRUCTURED_POSTAL, data1: line, data2: 1 }]);
expect(await patchOf(h, rawId)).toEqual({
'addresses/home/components': components('Marienplatz 1').map((c) => (c.kind === 'region' ? { ...c, value: 'Upper Bavaria' } : c)),
'addresses/home/full': line,
});
});
it('edits the parts an editor kept as parts, whatever its line says', async () => {
const line = 'Marienplatz 1, 80331 Augsburg, Bavaria, Germany';
const h = new Harness();
const { rawId } = await h.seed(munich());
h.device.user.updateData(h.rowsByKey(rawId)['addresses:home']._id as number, { data7: 'Augsburg', data1: line });
expect(await patchOf(h, rawId)).toEqual({ 'addresses/home/components': components('Marienplatz 1', 'Augsburg'), 'addresses/home/full': line });
});
});
describe('contacts upload: the name', () => {
const NAME_KEY = 'name';
it('re-derives a derived `full` when a component changes (and the provider recomputed the display name)', async () => {
const { patch } = await editedPatch(probeCard, NAME_KEY, { data2: 'Probert', data1: 'Dr. Probert Person' });
expect(patch).toEqual({
'name/components': [
{ kind: 'title', value: 'Dr.' },
{ kind: 'given', value: 'Probert', phonetic: 'Proob' },
{ kind: 'surname', value: 'Person' },
],
'name/full': 'Dr. Probert Person',
});
});
it('uploads a typed display name as `full` alone', async () => {
const { patch } = await editedPatch(probeCard, NAME_KEY, { data1: 'Probe P.' });
expect(patch).toEqual({ 'name/full': 'Probe P.' });
});
it('leaves a custom `full` alone when only components change', async () => {
const { patch } = await editedPatch(googleCard, NAME_KEY, { data3: 'Gross', data1: 'Prof. Günther Karl Gross, PhD' });
expect(patch).toEqual({
'name/components': [
{ kind: 'title', value: 'Prof.' },
{ kind: 'given', value: 'Günther' },
{ kind: 'given2', value: 'Karl' },
{ kind: 'surname', value: 'Gross' },
{ kind: 'credential', value: 'PhD' },
],
});
});
it('adds a component the name did not have in display order', async () => {
const { patch } = await editedPatch(appleCard, NAME_KEY, { data5: 'Maria', data1: 'Anna Maria Apfel' });
expect(patch).toEqual({
'name/components': [{ kind: 'given', value: 'Anna' }, { kind: 'given2', value: 'Maria' }, { kind: 'surname', value: 'Apfel' }],
'name/full': 'Anna Maria Apfel',
});
});
it('removes a component whose column was emptied', async () => {
const { patch } = await editedPatch(probeCard, NAME_KEY, { data4: null, data1: 'Probe Person' });
expect(patch['name/components']).toEqual([{ kind: 'given', value: 'Probe', phonetic: 'Proob' }, { kind: 'surname', value: 'Person' }]);
expect(patch['name/full']).toBe('Probe Person');
});
it('uploads a phonetic name as the component phonetic', async () => {
const { patch } = await editedPatch(probeCard, NAME_KEY, { data9: 'Pörson' });
expect(patch).toEqual({
'name/components': [
{ kind: 'title', value: 'Dr.' },
{ kind: 'given', value: 'Probe', phonetic: 'Proob' },
{ kind: 'surname', value: 'Person', phonetic: 'Pörson' },
],
});
});
it('uploads a retyped name the server holds as `full` alone whole: `full` and every component', async () => {
const h = new Harness();
const { id, rawId } = await h.seed({ name: { full: 'Ada' } });
// A typed display name: the provider splits it (the fake does so when no component is given).
h.device.user.updateData(h.rowsByKey(rawId).name._id as number, { data1: 'Ada King', data2: null, data3: null, data4: null, data5: null, data6: null });
expect(h.rowsByKey(rawId).name).toMatchObject({ data1: 'Ada King', data2: 'Ada', data3: 'King' });
const plan = h.planner.planUpload(await h.contact(rawId), h.ctx);
expect(plan).toMatchObject({
kind: 'upload',
actions: [{ patch: { 'name/components': [{ kind: 'given', value: 'Ada' }, { kind: 'surname', value: 'King' }], 'name/full': 'Ada King' } }],
});
await h.upload(rawId);
expect(h.card(id).name).toMatchObject({ full: 'Ada King', components: [{ kind: 'given', value: 'Ada' }, { kind: 'surname', value: 'King' }] });
const local = await h.contact(rawId);
expect(local.dirty).toBe(false);
expect(h.rowsByKey(rawId).name).toMatchObject({ data1: 'Ada King', data2: 'Ada', data3: 'King' });
expect(h.planner.planDownload(h.card(id), local, h.ctx).effect).toBe('none');
});
it('re-derives a derived `full` when a retyped display name adds a component', async () => {
const h = new Harness();
const { id, rawId } = await h.seed({ name: { components: [{ kind: 'given', value: 'Ada' }], full: 'Ada' } });
h.device.user.updateData(h.rowsByKey(rawId).name._id as number, { data1: 'Ada King', data2: null, data3: null, data4: null, data5: null, data6: null });
await h.upload(rawId);
expect(h.card(id).name).toMatchObject({ full: 'Ada King', components: [{ kind: 'given', value: 'Ada' }, { kind: 'surname', value: 'King' }] });
});
it('drops surname2 only when the family name changed', async () => {
const card = () => ({ name: { components: [{ kind: 'given', value: 'Ana' }, { kind: 'surname', value: 'García' }, { kind: 'surname2', value: 'López' }], full: 'Ana García López' } });
const h = new Harness();
const { rawId } = await h.seed(card());
expect(h.rowsByKey(rawId).name.data3).toBe('García López');
const { patch } = await editedPatch(card, NAME_KEY, { data3: 'García Pérez', data1: 'Ana García Pérez' });
expect(patch['name/components']).toEqual([{ kind: 'given', value: 'Ana' }, { kind: 'surname', value: 'García Pérez' }]);
});
});
describe('contacts upload: nothing mapped changed', () => {
it.each([['probe', probeCard], ['apple', appleCard], ['google', googleCard]])('clears DIRTY without a request for a starred %s card', async (_n, make) => {
const h = new Harness();
const { rawId } = await h.seed(make());
const before = JSON.stringify(h.device.rows('data', 'raw_contact_id = ?', [rawId]));
h.device.user.star(rawId);
const plan = h.planner.planUpload(await h.contact(rawId), h.ctx);
expect(plan.kind).toBe('clean');
if (plan.kind !== 'clean') return;
expect(plan.ops.ops.filter((o) => o.op !== 'assert' && o.op !== 'syncState' && o.table === 'data')).toEqual([]);
await h.applyOk(plan.ops);
expect(h.dirty(rawId)).toBe(false);
expect(JSON.stringify(h.device.rows('data', 'raw_contact_id = ?', [rawId]))).toBe(before);
});
it('never uploads IS_PRIMARY of a row an editor re-inserted', async () => {
const h = new Harness();
const { rawId } = await h.seed(appleCard());
const rows = h.rowsByKey(rawId);
h.device.user.deleteData(rows['emails:k1']._id as number);
h.device.user.insertData(rawId, { [Data.MIMETYPE]: MimeType.EMAIL, [Data.DATA1]: 'anna@example.com', [Data.DATA2]: 1, is_primary: 0 });
expect(h.planner.planUpload(await h.contact(rawId), h.ctx).kind).toBe('clean');
});
});
@@ -0,0 +1,286 @@
// Engine runs for calendars, and the teardown the app calls when sync is
// turned off (docs/device-sync.md, "Calendar mapping", "App integration").
import { describe, expect, it, vi } from 'vitest';
import { Events } from '../../android-columns';
import { parseJsonColumn } from '../../common/json';
import { teardownAuthority } from '../../engine/teardown';
import type { PoisonMarker } from '../../planner';
import {
addDeviceContact,
addServerCards,
ANDROID_ACCOUNT,
CALENDAR_AUTHORITY,
CONTACTS_AUTHORITY,
createHarness,
REGISTRY_ID,
renameDeviceContact,
type Harness,
} from './harness';
const START = '2026-09-28T09:00:00';
function addServerEvent(h: Harness, title: string, calendarId = h.calendar, extra: Record<string, unknown> = {}): string {
return h.server.addEvent('a', {
uid: `uid-${title}`,
title,
start: START,
duration: 'PT1H',
timeZone: 'Etc/UTC',
calendarIds: { [calendarId]: true },
...extra,
});
}
function calendarRow(h: Harness, calendarId: string) {
return h.device.rows('calendars').find((c) => c._sync_id === `a/${calendarId}`);
}
function titles(h: Harness): string[] {
return h.events().map((e) => String(e[Events.TITLE])).sort();
}
describe('device sync engine: calendar', () => {
it('writes calendar rows for the synced calendars and downloads their events, never tasks', async () => {
const h = createHarness();
const standup = addServerEvent(h, 'Standup');
h.server.addEvent('a', { uid: 't1', '@type': 'Task', title: 'Buy milk', calendarIds: { [h.calendar]: true } });
const report = await h.run(CALENDAR_AUTHORITY);
expect(report).toMatchObject({ outcome: 'ok', stats: { downloaded: { created: 1 } } });
expect(calendarRow(h, h.calendar)).toMatchObject({ ownerAccount: 'alice@example.com', sync_events: 1, name: 'Calendar' });
expect(h.events()).toEqual([expect.objectContaining({ title: 'Standup', _sync_id: `a/${standup}`, uid2445: 'uid-Standup' })]);
expect(h.state(CALENDAR_AUTHORITY)).toMatchObject({
accounts: { a: { selected: [`a/${h.calendar}`], reconcile: null } },
deviceZone: 'Europe/Berlin',
reminderOwner: 'device',
});
});
it('uploads a new device event, an edit and a deletion', async () => {
const h = createHarness();
const standup = addServerEvent(h, 'Standup');
const retro = addServerEvent(h, 'Retro');
await h.run(CALENDAR_AUTHORITY);
const rowId = Number(calendarRow(h, h.calendar)!._id);
const byTitle = (t: string) => h.events().find((e) => e.title === t)!;
h.device.user.insertEvent(rowId, { title: 'Lunch', dtstart: Date.UTC(2026, 8, 28, 12), dtend: Date.UTC(2026, 8, 28, 13), eventTimezone: 'UTC' });
h.device.user.updateEvent(Number(byTitle('Standup')._id), { title: 'Daily standup' });
h.device.user.deleteEvent(Number(byTitle('Retro')._id));
const report = await h.run(CALENDAR_AUTHORITY);
expect(report).toMatchObject({ outcome: 'ok', stats: { uploaded: { created: 1, updated: 1, deleted: 1 } } });
expect(h.server.get('CalendarEvent', 'a', standup)).toMatchObject({ title: 'Daily standup' });
expect(h.server.get('CalendarEvent', 'a', retro)).toBeUndefined();
const lunch = h.server.all('CalendarEvent', 'a').find((e) => e.title === 'Lunch')!;
expect(lunch).toMatchObject({ start: '2026-09-28T12:00:00', calendarIds: { [h.calendar]: true } });
expect(titles(h)).toEqual(['Daily standup', 'Lunch']);
expect(byTitle('Lunch')).toMatchObject({ _sync_id: `a/${lunch.id}`, dirty: 0 });
});
it('gives a calendar that holds only tasks no row', async () => {
const h = createHarness();
const todo = h.server.addCalendar('a', { name: 'To do' });
for (let i = 0; i < 3; i++) h.server.addEvent('a', { uid: `t${i}`, '@type': 'Task', title: `Task ${i}`, calendarIds: { [todo]: true } });
addServerEvent(h, 'Standup');
await h.run(CALENDAR_AUTHORITY);
expect(calendarRow(h, todo)).toBeUndefined();
expect(h.state(CALENDAR_AUTHORITY)?.accounts.a.taskOnly).toEqual([todo]);
expect(titles(h)).toEqual(['Standup']);
});
it('drops a deselected calendar once its events are uploaded, keeping the others', async () => {
const h = createHarness();
const work = h.server.addCalendar('a', { name: 'Work' });
addServerEvent(h, 'Standup');
const review = addServerEvent(h, 'Review', work);
addServerEvent(h, 'Planning', work);
await h.run(CALENDAR_AUTHORITY);
expect(titles(h)).toEqual(['Planning', 'Review', 'Standup']);
h.device.user.updateEvent(Number(h.events().find((e) => e.title === 'Review')!._id), { title: 'Code review' });
h.prefs.calendarSelection[`a/${work}`] = false;
await h.run(CALENDAR_AUTHORITY);
expect(h.server.get('CalendarEvent', 'a', review)).toMatchObject({ title: 'Code review' });
expect(calendarRow(h, work)).toBeUndefined();
expect(titles(h)).toEqual(['Standup']);
expect(h.state(CALENDAR_AUTHORITY)?.accounts.a.selected).toEqual([`a/${h.calendar}`]);
});
it('keeps a deselected calendar while one of its events could not be uploaded', async () => {
const h = createHarness();
const work = h.server.addCalendar('a', { name: 'Work' });
const review = addServerEvent(h, 'Review', work);
addServerEvent(h, 'Planning', work);
await h.run(CALENDAR_AUTHORITY);
h.device.user.updateEvent(Number(h.events().find((e) => e.title === 'Review')!._id), { title: 'Code review' });
h.server.setErrorFor('CalendarEvent', 'a', review, { type: 'tooLarge' });
h.prefs.calendarSelection[`a/${work}`] = false;
const report = await h.run(CALENDAR_AUTHORITY);
expect(report.itemErrors).toEqual([expect.objectContaining({ ref: `a/${review}`, type: 'tooLarge' })]);
expect(calendarRow(h, work)).toBeDefined();
expect(titles(h)).toEqual(['Code review']);
// Not complete any more (selected again, it is loaded again); its row is dropped again next run.
expect(h.state(CALENDAR_AUTHORITY)?.accounts.a.selected).toEqual([`a/${h.calendar}`]);
h.clock.now += 2 * 3_600_000;
await h.run(CALENDAR_AUTHORITY);
expect(h.server.get('CalendarEvent', 'a', review)).toMatchObject({ title: 'Code review' });
expect(calendarRow(h, work)).toBeUndefined();
});
it('marks a calendar that mirrors a feed subscription read-only', async () => {
const h = createHarness();
const feed = h.server.addCalendar('a', { name: 'Holidays' });
h.deps.subscriptionCalendars = async () => [{ jmapAccountId: null, calendarId: feed }];
await h.run(CALENDAR_AUTHORITY);
expect(calendarRow(h, feed)).toMatchObject({ calendar_access_level: 200 });
expect(calendarRow(h, h.calendar)).toMatchObject({ calendar_access_level: 700 });
});
it('never gives a device event the identity of an unrelated event that has its uid (event uids are unique per account)', async () => {
const h = createHarness();
const hidden = h.server.addCalendar('a', { name: 'Hidden' });
h.prefs.calendarSelection[`a/${hidden}`] = false;
const other = addServerEvent(h, 'Other', hidden, { uid: 'dup' });
await h.run(CALENDAR_AUTHORITY);
const rowId = Number(calendarRow(h, h.calendar)!._id);
const id = h.device.user.insertEvent(rowId, { title: 'Mine', dtstart: Date.UTC(2026, 8, 28, 12), dtend: Date.UTC(2026, 8, 28, 13), eventTimezone: 'UTC' });
// An app wrote the uid, and the claim keeps it.
await h.device.port(ANDROID_ACCOUNT, CALENDAR_AUTHORITY).applyBatch([
{
op: 'update',
table: 'events',
id,
values: { _sync_id: '~pending/dup', uid2445: 'dup', sync_data3: JSON.stringify({ uid: 'dup', target: `a/${h.calendar}` }) },
},
]);
const report = await h.run(CALENDAR_AUTHORITY);
expect(report.itemErrors).toEqual([expect.objectContaining({ type: 'uidConflict' })]);
expect(h.server.get('CalendarEvent', 'a', other)).toMatchObject({ title: 'Other', calendarIds: { [hidden]: true } });
const row = h.events().find((e) => Number(e._id) === id)!;
expect(row).toMatchObject({ title: 'Mine', _sync_id: '~pending/dup' });
expect(parseJsonColumn<PoisonMarker>(row.sync_data5)).toMatchObject({ type: 'uidConflict', n: 1 });
});
it('adopts an event its lost create left on the server, with a lagging uid index', async () => {
const h = createHarness();
addServerEvent(h, 'Standup');
await h.run(CALENDAR_AUTHORITY);
h.server.setUidIndexLag(3);
const rowId = Number(calendarRow(h, h.calendar)!._id);
const id = h.device.user.insertEvent(rowId, { title: 'Lunch', dtstart: Date.UTC(2026, 8, 28, 12), dtend: Date.UTC(2026, 8, 28, 13), eventTimezone: 'UTC' });
h.server.applyThenLoseResponse({ match: 'CalendarEvent/set' });
expect((await h.run(CALENDAR_AUTHORITY)).outcome).toBe('io');
expect((await h.run(CALENDAR_AUTHORITY)).outcome).toBe('ok');
expect(h.server.all('CalendarEvent', 'a').map((e) => e.title).sort()).toEqual(['Lunch', 'Standup']);
expect(h.events()).toHaveLength(2);
expect(h.events().find((e) => Number(e._id) === id)).toMatchObject({ dirty: 0, _sync_id: expect.stringMatching(/^a\//) });
});
});
describe('device sync engine: teardown', () => {
it('uploads waiting changes, then removes every row and the SyncState of the authority', async () => {
const h = createHarness();
const [ada] = addServerCards(h, ['Ada']);
await h.run();
renameDeviceContact(h, h.contactNamed('Ada')!.id, 'Ada King');
addDeviceContact(h, 'Hedy');
const result = await h.teardown(CONTACTS_AUTHORITY);
expect(result).toEqual({ pending: 0 });
expect(h.server.get('ContactCard', 'a', ada)).toMatchObject({ name: { full: 'Ada King' } });
expect(h.serverNames()).toEqual(['Ada King', 'Hedy']);
expect(h.device.rows('raw_contacts')).toEqual([]);
expect(h.device.rows('groups')).toEqual([]);
expect(h.device.rows('settings')).toEqual([]);
expect(h.device.readSyncState(ANDROID_ACCOUNT, CONTACTS_AUTHORITY)).toBe('');
});
it('deletes nothing while changes could not be uploaded, unless forced', async () => {
const h = createHarness();
addServerCards(h, ['Ada']);
await h.run();
addDeviceContact(h, 'Hedy');
h.deps.jmap = async () => {
const error = new Error('Network request failed');
error.name = 'NetworkError';
throw error;
};
expect(await h.teardown(CONTACTS_AUTHORITY)).toEqual({ pending: 1 });
expect(h.contacts()).toHaveLength(2);
expect(await h.teardown(CONTACTS_AUTHORITY, { force: true })).toEqual({ pending: 0 });
expect(h.contacts()).toHaveLength(0);
expect(h.serverNames()).toEqual(['Ada']);
});
it('holds mass deletions back like a sync run, and asks', async () => {
const h = createHarness();
addServerCards(h, Array.from({ length: 80 }, (_, i) => `P${i}`));
await h.run();
for (const contact of h.contacts().slice(0, 60)) h.device.user.deleteContact(contact.id);
expect(await h.teardown(CONTACTS_AUTHORITY)).toEqual({ pending: 60 });
expect(h.serverNames()).toHaveLength(80);
});
it("finishes while JS timers are paused (the UI's teardown with the app in the background)", async () => {
const h = createHarness();
const [ada] = addServerCards(h, ['Ada']);
await h.run();
renameDeviceContact(h, h.contactNamed('Ada')!.id, 'Ada King');
// The app passes neither a yield nor a sleep: the engine's own must not need a timer here.
const { yieldThread: _yield, sleep: _sleep, ...deps } = h.deps;
vi.useFakeTimers();
try {
await expect(teardownAuthority(deps, REGISTRY_ID, ANDROID_ACCOUNT, CONTACTS_AUTHORITY)).resolves.toEqual({ pending: 0 });
} finally {
vi.useRealTimers();
}
expect(h.server.get('ContactCard', 'a', ada)).toMatchObject({ name: { full: 'Ada King' } });
expect(h.contacts()).toEqual([]);
}, 3_000);
it('removes the calendar rows, and their events with them', async () => {
const h = createHarness();
addServerEvent(h, 'Standup');
await h.run(CALENDAR_AUTHORITY);
expect(await h.teardown(CALENDAR_AUTHORITY)).toEqual({ pending: 0 });
expect(h.device.rows('calendars')).toEqual([]);
expect(h.device.rows('events')).toEqual([]);
});
it('stops a running sync of the account and waits for it', async () => {
const h = createHarness();
addServerCards(h, Array.from({ length: 200 }, (_, i) => `P${i}`));
let teardown: Promise<{ pending: number }> | null = null;
h.checkpoints.onCheckpoint = (n) => {
if (n === 3) teardown = h.teardown(CONTACTS_AUTHORITY);
};
const report = await h.run();
expect(report).toMatchObject({ outcome: 'cancelled' });
expect(await teardown).toEqual({ pending: 0 });
expect(h.contacts()).toHaveLength(0);
});
});
@@ -0,0 +1,119 @@
// The calendar passes that need no server writes (device zone change,
// reminder owner change) and the local checks before an upload (a series
// split through CONTENT_EXCEPTION_URI).
import { describe, expect, it, vi } from 'vitest';
import { Events } from '../../android-columns';
import type { CalendarPlanner } from '../../planner';
import { ANDROID_ACCOUNT, CALENDAR_AUTHORITY, createHarness, type Harness } from './harness';
import { toyCalendarPlanner } from './toy-planners';
function withPlanner(h: Harness, overrides: Partial<CalendarPlanner>): CalendarPlanner {
const planner = { ...toyCalendarPlanner, ...overrides } as CalendarPlanner;
h.deps.planners = { ...h.deps.planners, calendar: planner };
return planner;
}
function addServerEvent(h: Harness, title: string): string {
return h.server.addEvent('a', { uid: `uid-${title}`, title, start: '2026-09-28T09:00:00', duration: 'PT1H', calendarIds: { [h.calendar]: true } });
}
describe('device sync engine: calendar passes', () => {
it('rewrites clean events for a new device zone, and stores the zone with the last chunk', async () => {
const h = createHarness({ tuning: { chunkSize: 2 } });
for (const title of ['A', 'B', 'C']) addServerEvent(h, title);
await h.run(CALENDAR_AUTHORITY);
const planZoneChange = vi.fn((local, previousZone: string, ctx) => ({
ref: String(local.syncId),
ops: [{ op: 'update' as const, table: 'events' as const, id: local.eventId, values: { eventTimezone: ctx.deviceZone } }],
previousZone,
}));
withPlanner(h, { planZoneChange });
h.deps.deviceZone = () => 'America/New_York';
const report = await h.run(CALENDAR_AUTHORITY);
expect(report.outcome).toBe('ok');
expect(planZoneChange).toHaveBeenCalledTimes(3);
expect(planZoneChange.mock.calls.every(([, previous]) => previous === 'Europe/Berlin')).toBe(true);
expect(h.events().map((e) => e[Events.EVENT_TIMEZONE])).toEqual(['America/New_York', 'America/New_York', 'America/New_York']);
expect(h.state(CALENDAR_AUTHORITY)).toMatchObject({ deviceZone: 'America/New_York', deviceZonePending: null });
});
it('finishes an interrupted zone pass in the next run', async () => {
const h = createHarness({ tuning: { chunkSize: 1 } });
for (const title of ['A', 'B', 'C']) addServerEvent(h, title);
await h.run(CALENDAR_AUTHORITY);
const planZoneChange = vi.fn((local: { eventId: number; syncId: string | null }) => ({
ref: String(local.syncId),
ops: [{ op: 'update' as const, table: 'events' as const, id: local.eventId, values: { eventTimezone: 'America/New_York' } }],
}));
withPlanner(h, { planZoneChange });
h.deps.deviceZone = () => 'America/New_York';
let zoneChunks = 0;
h.checkpoints.onCheckpoint = () => {
if (planZoneChange.mock.calls.length > 0 && ++zoneChunks === 2) h.checkpoints.crashAt = h.checkpoints.count;
};
await h.run(CALENDAR_AUTHORITY);
expect(h.state(CALENDAR_AUTHORITY)).toMatchObject({ deviceZone: 'Europe/Berlin', deviceZonePending: 'America/New_York' });
h.checkpoints.crashAt = null;
h.checkpoints.onCheckpoint = undefined;
await h.run(CALENDAR_AUTHORITY);
expect(h.state(CALENDAR_AUTHORITY)).toMatchObject({ deviceZone: 'America/New_York', deviceZonePending: null });
expect(h.events().every((e) => e[Events.EVENT_TIMEZONE] === 'America/New_York')).toBe(true);
});
it('uploads with the old reminder owner, then rewrites the reminders and the calendar rows for the new one', async () => {
const h = createHarness();
addServerEvent(h, 'Standup');
await h.run(CALENDAR_AUTHORITY);
expect(h.device.rows('calendars')[0]).toMatchObject({ maxReminders: 5 });
const owners: string[] = [];
const planReminderOwnerChange = vi.fn((local: { eventId: number; syncId: string | null }, ctx: { reminderOwner: string }) => {
owners.push(ctx.reminderOwner);
return { ref: String(local.syncId), ops: [{ op: 'delete' as const, table: 'reminders' as const, where: 'event_id = ?', args: [local.eventId] }] };
});
const planUpload = vi.fn(toyCalendarPlanner.planUpload);
withPlanner(h, { planReminderOwnerChange, planUpload });
h.device.user.setReminders(Number(h.events()[0]._id), [{ minutes: 10 }]);
h.device.user.updateEvent(Number(h.events()[0]._id), { title: 'Daily standup' });
h.prefs.reminderOwner = 'bulwark';
await h.run(CALENDAR_AUTHORITY);
expect(planUpload.mock.calls.map(([, ctx]) => ctx.reminderOwner)).toEqual(['device']);
expect(owners).toEqual(['bulwark']);
expect(h.device.rows('reminders')).toEqual([]);
expect(h.device.rows('calendars')[0]).toMatchObject({ maxReminders: 0 });
expect(h.state(CALENDAR_AUTHORITY)?.reminderOwner).toBe('bulwark');
});
it('uploads the clone of a CONTENT_EXCEPTION_URI split as a new event after its source', async () => {
const h = createHarness();
const series = addServerEvent(h, 'Series');
await h.run(CALENDAR_AUTHORITY);
const master = h.events()[0];
// A recurring master, as a calendar app sees it.
await h.device.port(ANDROID_ACCOUNT, CALENDAR_AUTHORITY).applyBatch([
{ op: 'update', table: 'events', id: Number(master._id), values: { rrule: 'FREQ=DAILY', duration: 'P3600S', dtend: null } },
]);
const planUpload = vi.fn(toyCalendarPlanner.planUpload);
withPlanner(h, { planUpload });
const clone = h.device.user.splitViaUri(Number(master._id), Date.UTC(2026, 9, 1, 9), '20260930T235959Z', { title: 'Series (later)' });
const report = await h.run(CALENDAR_AUTHORITY);
expect(report.outcome).toBe('ok');
// The source was planned while the split was visible, before its clone was claimed.
const splits = planUpload.mock.calls.map(([local]) => local.split);
expect(splits.indexOf('source')).toBeLessThan(splits.indexOf('clone'));
const events = h.server.all('CalendarEvent', 'a');
expect(events.map((e) => e.title).sort()).toEqual(['Series', 'Series (later)']);
const cloneRow = h.events().find((e) => Number(e._id) === clone)!;
expect(cloneRow._sync_id).not.toBe(`a/${series}`);
expect(cloneRow._sync_id).toMatch(/^a\//);
expect(new Set(h.events().map((e) => e._sync_id)).size).toBe(2);
});
});
@@ -0,0 +1,390 @@
// Engine runs for contacts against the fake provider and the fake JMAP
// server: first sync, incremental /changes, uploads, echoes, selection
// changes and groups (docs/device-sync.md, "A sync run").
import { describe, expect, it } from 'vitest';
import { Data, MimeType } from '../../android-columns';
import { runDeviceSync } from '../../engine/run';
import {
addDeviceContact,
addServerCards,
ANDROID_ACCOUNT,
CONTACTS_AUTHORITY,
createHarness,
REGISTRY_ID,
renameDeviceContact,
RUN_BUDGET,
rowWrites,
type Harness,
} from './harness';
function runAs(h: Harness, registryId: string) {
return runDeviceSync(
{ runId: 'other', accountName: ANDROID_ACCOUNT, registryId, authority: CONTACTS_AUTHORITY, extras: {}, deadline: h.clock.now + RUN_BUDGET },
h.deps,
);
}
describe('device sync engine: contacts', () => {
it('downloads every card of the synced address books on the first sync (a full reconcile)', async () => {
const h = createHarness();
addServerCards(h, ['Ada', 'Grace', 'Linus']);
const report = await h.run();
expect(report).toMatchObject({ outcome: 'ok', stats: { downloaded: { created: 3, updated: 0, deleted: 0 } } });
expect(h.contacts().map((c) => c.name).sort()).toEqual(['Ada', 'Grace', 'Linus']);
expect(h.contacts().every((c) => c.sourceId?.startsWith('a/') && !c.dirty)).toBe(true);
const state = h.state();
expect(state?.owner).toEqual({ registryId: REGISTRY_ID, origin: 'https://mail.example.com' });
expect(state?.accounts.a).toMatchObject({
itemsState: h.server.state('ContactCard', 'a'),
reconcile: null,
selected: [`a/${h.book}`],
stale: [],
});
expect(h.device.rows('settings')).toEqual([expect.objectContaining({ ungrouped_visible: 1, should_sync: 1 })]);
expect(h.deps.recordStatus).toHaveBeenCalledWith(REGISTRY_ID, CONTACTS_AUTHORITY, expect.objectContaining({ outcome: 'ok' }));
});
it('applies server creates, updates and deletes from /changes', async () => {
const h = createHarness();
const [ada, grace] = addServerCards(h, ['Ada', 'Grace']);
await h.run();
h.server.serverUpdate('ContactCard', 'a', ada, { 'name/full': 'Ada Lovelace' });
h.server.serverDestroy('ContactCard', 'a', grace);
addServerCards(h, ['Katherine']);
const report = await h.run();
expect(report.stats.downloaded).toEqual({ created: 1, updated: 1, deleted: 1 });
expect(h.contacts().map((c) => c.name).sort()).toEqual(['Ada Lovelace', 'Katherine']);
expect(h.server.calls('ContactCard/changes').length).toBeGreaterThan(0);
expect(h.state()?.accounts.a.itemsState).toBe(h.server.state('ContactCard', 'a'));
});
it('pages through /changes and stores the state with every page', async () => {
const h = createHarness();
await h.run();
addServerCards(h, Array.from({ length: 300 }, (_, i) => `Person ${i}`));
const before = h.server.calls('ContactCard/changes').length;
const report = await h.run();
const pages = h.server.calls('ContactCard/changes').slice(before);
expect(pages.map(([, args]) => args.maxChanges)).toEqual([256, 256]);
expect(report.stats.downloaded.created).toBe(300);
expect(h.contacts()).toHaveLength(300);
// One state op per page, each the last op of its batch.
const stateBatches = h.batches.log.filter((ops) => ops.some((op) => op.op === 'syncState'));
for (const ops of stateBatches) expect(ops[ops.length - 1].op).toBe('syncState');
});
it('uploads a device edit as a patch and a new device contact as a create with a client uid', async () => {
const h = createHarness();
const [ada] = addServerCards(h, ['Ada']);
await h.run();
renameDeviceContact(h, h.contactNamed('Ada')!.id, 'Ada King');
const fresh = addDeviceContact(h, 'Hedy');
const report = await h.run();
expect(report).toMatchObject({ outcome: 'ok', stats: { uploaded: { created: 1, updated: 1, deleted: 0 } } });
expect(h.server.get('ContactCard', 'a', ada)).toMatchObject({ name: { full: 'Ada King' } });
const created = h.server.all('ContactCard', 'a').find((c) => (c.name as { full: string }).full === 'Hedy')!;
expect(created.uid).toMatch(/^[0-9a-f-]{36}$/);
expect(h.contacts().find((c) => c.id === fresh)).toMatchObject({ sourceId: `a/${created.id}`, dirty: false });
expect(h.contacts().every((c) => !c.dirty)).toBe(true);
const set = h.server.calls('ContactCard/set');
expect(set.every(([, args]) => typeof args.ifInState === 'string')).toBe(true);
});
it('uploads to an account without any card yet, whose /changes state is spelled differently from its type state', async () => {
const h = createHarness();
await h.run();
addDeviceContact(h, 'Hedy');
const report = await h.run();
expect(report.outcome).toBe('ok');
expect(h.serverNames()).toEqual(['Hedy']);
expect(h.contacts()).toEqual([expect.objectContaining({ name: 'Hedy', dirty: false, sourceId: expect.stringMatching(/^a\//) })]);
});
it('writes nothing but the state when its own upload comes back through /changes (echo)', async () => {
const h = createHarness();
addServerCards(h, ['Ada']);
await h.run();
renameDeviceContact(h, h.contactNamed('Ada')!.id, 'Ada King');
addDeviceContact(h, 'Hedy');
await h.run();
const before = h.batches.log.length;
const report = await h.run();
expect(report.stats.downloaded).toEqual({ created: 0, updated: 0, deleted: 0 });
expect(rowWrites(h.batches.log.slice(before))).toEqual([]);
expect(h.contacts().map((c) => c.name).sort()).toEqual(['Ada King', 'Hedy']);
});
it('keeps a contact dirty when the user edits it again while its upload is on the way', async () => {
const h = createHarness();
const [ada] = addServerCards(h, ['Ada']);
await h.run();
const id = h.contactNamed('Ada')!.id;
renameDeviceContact(h, id, 'Ada King');
const stop = h.server.onAfterRequest((request) => {
if (request.methods.includes('ContactCard/set')) renameDeviceContact(h, id, 'Ada, Countess');
});
await h.run();
stop();
expect(h.server.get('ContactCard', 'a', ada)).toMatchObject({ name: { full: 'Ada King' } });
expect(h.contacts().find((c) => c.id === id)).toMatchObject({ name: 'Ada, Countess', dirty: true });
await h.run();
expect(h.server.get('ContactCard', 'a', ada)).toMatchObject({ name: { full: 'Ada, Countess' } });
expect(h.contacts().find((c) => c.id === id)).toMatchObject({ dirty: false });
});
it('lets the server win a conflicting edit of the same unit and counts it', async () => {
const h = createHarness();
const [ada] = addServerCards(h, ['Ada']);
await h.run();
renameDeviceContact(h, h.contactNamed('Ada')!.id, 'Ada (device)');
h.server.serverUpdate('ContactCard', 'a', ada, { 'name/full': 'Ada (server)' });
const report = await h.run();
expect(report.conflicts).toBe(1);
expect(h.contacts().map((c) => c.name)).toEqual(['Ada (server)']);
expect(h.server.get('ContactCard', 'a', ada)).toMatchObject({ name: { full: 'Ada (server)' } });
});
it('deletes on the server what the user deleted, and purges the rows', async () => {
const h = createHarness();
const [ada] = addServerCards(h, ['Ada', 'Grace']);
await h.run();
h.device.user.deleteContact(h.contactNamed('Ada')!.id);
const report = await h.run();
expect(report.stats.uploaded.deleted).toBe(1);
expect(h.server.get('ContactCard', 'a', ada)).toBeUndefined();
expect(h.contacts().map((c) => c.name)).toEqual(['Grace']);
});
it('drops only the clean rows of a deselected address book, after uploading its dirty ones', async () => {
const h = createHarness();
const work = h.server.addAddressBook('a', { name: 'Work' });
addServerCards(h, ['Ada']);
const [boss, colleague] = addServerCards(h, ['Boss', 'Colleague'], 'a', work);
await h.run();
expect(h.contacts()).toHaveLength(3);
renameDeviceContact(h, h.contactNamed('Boss')!.id, 'The Boss');
h.prefs.contactsSelection[`a/${work}`] = false;
const report = await h.run();
expect(report.outcome).toBe('ok');
expect(h.contacts().map((c) => c.name)).toEqual(['Ada']);
expect(h.server.get('ContactCard', 'a', boss)).toMatchObject({ name: { full: 'The Boss' } });
expect(h.server.get('ContactCard', 'a', colleague)).toBeDefined();
expect(h.state()?.accounts.a.selected).toEqual([`a/${h.book}`]);
});
it("leaves the webmail's Trusted Senders book off the device unless the user picked it", async () => {
const h = createHarness();
const trusted = h.server.addAddressBook('a', { name: 'Trusted Senders' });
addServerCards(h, ['Ada']);
addServerCards(h, ['newsletter@example.com'], 'a', trusted);
await h.run();
expect(h.contacts().map((c) => c.name)).toEqual(['Ada']);
h.prefs.contactsSelection[`a/${trusted}`] = true;
await h.run();
expect(h.contacts().map((c) => c.name).sort()).toEqual(['Ada', 'newsletter@example.com']);
});
it('loads a newly selected address book once the account is up to date', async () => {
const h = createHarness({ accounts: 'withShared' });
addServerCards(h, ['Ada']);
const teamBook = h.server.all('AddressBook', 'team')[0].id as string;
h.server.addCard('team', { uid: 'u-team', name: { full: 'Teammate' }, addressBookIds: { [teamBook]: true } });
await h.run();
expect(h.contacts().map((c) => c.name)).toEqual(['Ada']);
h.prefs.contactsSelection[`team/${teamBook}`] = true;
const report = await h.run();
expect(report.stats.downloaded.created).toBe(1);
expect(h.contacts().map((c) => c.name).sort()).toEqual(['Ada', 'Teammate']);
expect(h.contactNamed('Teammate')?.sourceId).toMatch(/^team\//);
expect(h.state()?.accounts.team.selected).toEqual([`team/${teamBook}`]);
});
it('writes group cards before the contacts that are their members', async () => {
const h = createHarness();
const [ada] = addServerCards(h, ['Ada']);
const adaUid = h.server.get('ContactCard', 'a', ada)!.uid as string;
h.server.addCard('a', { uid: 'g1', kind: 'group', name: { full: 'Friends' }, members: { [adaUid]: true } });
await h.run();
const groups = h.device.rows('groups');
expect(groups).toHaveLength(1);
expect(groups[0]).toMatchObject({ title: 'Friends', group_visible: 1 });
const memberships = h.device.rows('data').filter((d) => d.mimetype === MimeType.GROUP_MEMBERSHIP);
expect(memberships).toHaveLength(1);
// data1 is a TEXT column: the group row id may read back as text.
expect(String(memberships[0][Data.DATA1])).toBe(String(groups[0]._id));
expect(memberships[0].group_sourceid).toBe(groups[0].sourceid);
expect(h.state()?.accounts.a.groups).toEqual([groups[0].sourceid]);
});
it("updates a member's rows when only the group card's members changed on the server", async () => {
const h = createHarness();
const [ada, grace] = addServerCards(h, ['Ada', 'Grace']);
const adaUid = h.server.get('ContactCard', 'a', ada)!.uid as string;
const graceUid = h.server.get('ContactCard', 'a', grace)!.uid as string;
const group = h.server.addCard('a', { uid: 'g1', kind: 'group', name: { full: 'Friends' }, members: { [adaUid]: true } });
await h.run();
const members = () =>
h.device
.rows('data')
.filter((d) => d.mimetype === MimeType.GROUP_MEMBERSHIP)
.map((d) => h.contacts().find((c) => c.id === Number(d.raw_contact_id))?.name)
.sort();
expect(members()).toEqual(['Ada']);
// Another client moves the membership: only the group card changes.
h.server.serverUpdate('ContactCard', 'a', group, { members: { [graceUid]: true } });
await h.run();
expect(members()).toEqual(['Grace']);
expect(h.contacts().every((c) => !c.dirty)).toBe(true);
});
it('uploads a membership added on the device as a patch of the group card', async () => {
const h = createHarness();
const [ada] = addServerCards(h, ['Ada']);
const group = h.server.addCard('a', { uid: 'g1', kind: 'group', name: { full: 'Friends' }, members: {} });
await h.run();
const groupRow = h.device.rows('groups')[0];
h.device.user.addToGroup(h.contactNamed('Ada')!.id, Number(groupRow._id));
await h.run();
const adaUid = h.server.get('ContactCard', 'a', ada)!.uid as string;
expect(h.server.get('ContactCard', 'a', group)?.members).toEqual({ [adaUid]: true });
expect(h.contacts().every((c) => !c.dirty)).toBe(true);
});
it('skips the network for an upload-only sync with nothing to upload', async () => {
const h = createHarness();
addServerCards(h, ['Ada']);
await h.run();
const requests = h.server.requests.length;
const report = await h.run(CONTACTS_AUTHORITY, { upload: true });
expect(report.outcome).toBe('ok');
expect(h.server.requests.length).toBe(requests);
});
it("keeps the last run's status when an upload-only sync finds nothing to do, moving only its time on", async () => {
const h = createHarness();
addServerCards(h, ['Ada']);
await h.run();
const status = h.lastStatus()!;
expect(status).toMatchObject({ outcome: 'ok', stats: { downloaded: { created: 1 } } });
// The upload sync Android starts right after a manual one: nothing waits on the device.
h.clock.now += 1_000;
expect((await h.run(CONTACTS_AUTHORITY, { upload: true })).outcome).toBe('ok');
expect(h.lastStatus()).toEqual({ ...status, at: h.clock.now });
// One that has something to upload is a run like any other.
renameDeviceContact(h, h.contactNamed('Ada')!.id, 'Ada King');
await h.run(CONTACTS_AUTHORITY, { upload: true });
expect(h.lastStatus()).toMatchObject({ stats: { downloaded: { created: 0 }, uploaded: { updated: 1 } } });
});
it('reports disabled, unsupported and rows of another owner without touching anything', async () => {
const off = createHarness();
off.deps.isSyncEnabled = async () => false;
expect((await off.run()).outcome).toBe('disabled');
expect(off.server.requests).toHaveLength(0);
const bare = createHarness();
const port = bare.server.port();
bare.deps.jmap = async () => ({
origin: 'https://mail.example.com',
port: { ...port, session: () => ({ ...port.session(), primaryAccounts: {}, accounts: {} }) },
});
expect((await bare.run()).outcome).toBe('unsupported');
const other = createHarness();
addServerCards(other, ['Ada']);
await other.run();
other.deps.jmap = async () => ({ port: other.server.port(), origin: 'https://other.example.com' });
const moved = await other.run();
expect(moved.outcome).toBe('internal');
expect(moved.message).toMatch(/another server/);
expect(other.contacts()).toHaveLength(1);
const foreign = await runAs(other, 'bob@mail.example.com');
expect(foreign.outcome).toBe('internal');
expect(foreign.message).toMatch(/another account/);
});
it('keeps one request in flight and stays within the server limits', async () => {
const h = createHarness();
h.server.setLimits({ maxObjectsInGet: 20, maxObjectsInSet: 10 });
addServerCards(h, Array.from({ length: 60 }, (_, i) => `Server ${i}`));
await h.run();
for (let i = 0; i < 25; i++) addDeviceContact(h, `Device ${i}`);
const report = await h.run();
expect(report.outcome).toBe('ok');
expect(h.server.maxInFlight).toBe(1);
for (const [, args] of h.server.calls('ContactCard/get')) {
expect(Array.isArray(args.ids)).toBe(true);
expect((args.ids as string[]).length).toBeLessThanOrEqual(20);
}
for (const [, args] of h.server.calls('ContactCard/set')) {
const count = Object.keys(args.create ?? {}).length + Object.keys(args.update ?? {}).length + ((args.destroy as string[]) ?? []).length;
expect(count).toBeLessThanOrEqual(10);
}
expect(h.serverNames()).toHaveLength(85);
expect(h.contacts()).toHaveLength(85);
expect(ANDROID_ACCOUNT).toBe('alice@example.com');
});
it('packs provider batches of at most 400 ops with a yield point at every group and the state op last', async () => {
const h = createHarness();
addServerCards(h, Array.from({ length: 450 }, (_, i) => `Person ${i}`));
await h.run();
expect(h.contacts()).toHaveLength(450);
for (const ops of h.batches.log) {
expect(ops.length).toBeLessThanOrEqual(400);
const state = ops.findIndex((op) => op.op === 'syncState');
if (state >= 0) {
expect(state).toBe(ops.length - 1);
expect((ops[state] as { yieldAllowed?: boolean }).yieldAllowed).toBeFalsy();
}
// Every download insert starts a group with its "not there yet" guard: a yield point, and nothing inside a group is one.
ops.forEach((op, i) => {
if (op.op === 'insert' && op.table === 'raw_contacts') {
expect(ops[i - 1]).toMatchObject({ op: 'assert', table: 'raw_contacts', expectCount: 0, yieldAllowed: true });
expect(op.yieldAllowed).toBeFalsy();
}
});
}
});
});
@@ -0,0 +1,97 @@
// Items created on the device and deleted before any run claimed them.
// ContactsProvider soft-deletes every raw contact and group of a sync account
// (DELETED=1, SOURCE_ID NULL), CalendarProvider hard-deletes an event without
// `_SYNC_ID` at once. Such an item never reached the server: it is purged,
// nothing uploads, and it never counts as a change waiting for an upload.
import { describe, expect, it } from 'vitest';
import { calendarPlanner } from '../../calendar/planner';
import { contactsPlanner } from '../../contacts/planner';
import {
addDeviceContact,
addServerCards,
ANDROID_ACCOUNT,
CALENDAR_AUTHORITY,
CONTACTS_AUTHORITY,
createHarness,
type Harness,
} from './harness';
function real(): Harness {
const h = createHarness();
h.deps.planners = { contacts: contactsPlanner, calendar: calendarPlanner };
return h;
}
/** Synced once, then a contact and a group created on the device and deleted again. */
async function createdAndDeleted(): Promise<{ h: Harness; contact: number; group: number }> {
const h = real();
addServerCards(h, ['Ada Lovelace']);
expect((await h.run()).outcome).toBe('ok');
const contact = addDeviceContact(h, 'Hedy Lamarr');
h.device.user.deleteContact(contact);
const group = h.device.user.insertGroup(ANDROID_ACCOUNT, 'Friends');
h.device.user.deleteGroup(group);
return { h, contact, group };
}
function offline(h: Harness): void {
h.deps.jmap = async () => {
const error = new Error('Network request failed');
error.name = 'NetworkError';
throw error;
};
}
describe('device sync engine: an item deleted on the device before it was claimed', () => {
it('purges a contact and a group, and uploads nothing', async () => {
const { h, contact, group } = await createdAndDeleted();
const report = await h.run();
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 0, updated: 0, deleted: 0 } } });
expect(h.device.row('raw_contacts', contact)).toBeUndefined();
expect(h.device.rows('data', 'raw_contact_id = ?', [contact])).toEqual([]);
expect(h.device.row('groups', group)).toBeUndefined();
expect(h.server.all('ContactCard', 'a').map((c) => c.name)).toEqual([{ full: 'Ada Lovelace' }]);
});
it('does not turn an upload-only sync into a full one', async () => {
const { h } = await createdAndDeleted();
const requests = h.server.requests.length;
expect((await h.run(CONTACTS_AUTHORITY, { upload: true })).outcome).toBe('ok');
expect(h.server.requests.length).toBe(requests);
});
it('is no change that turning sync off would lose, also while offline', async () => {
const { h } = await createdAndDeleted();
offline(h);
expect(await h.teardown(CONTACTS_AUTHORITY)).toEqual({ pending: 0 });
expect(h.device.rows('raw_contacts')).toEqual([]);
expect(h.device.rows('groups')).toEqual([]);
});
it('leaves nothing behind for an event: CalendarProvider removes it at once', async () => {
const h = real();
expect((await h.run(CALENDAR_AUTHORITY)).outcome).toBe('ok');
const calendarRow = Number(h.device.rows('calendars')[0]._id);
const event = h.device.user.insertEvent(calendarRow, {
title: 'Dentist',
dtstart: Date.UTC(2026, 9, 1, 8),
dtend: Date.UTC(2026, 9, 1, 9),
eventTimezone: 'Europe/Berlin',
});
h.device.user.deleteEvent(event);
expect(h.events()).toEqual([]);
const requests = h.server.requests.length;
expect((await h.run(CALENDAR_AUTHORITY, { upload: true })).outcome).toBe('ok');
expect(h.server.requests.length).toBe(requests);
offline(h);
expect(await h.teardown(CALENDAR_AUTHORITY)).toEqual({ pending: 0 });
});
});
@@ -0,0 +1,672 @@
// How the engine survives crashes, lost responses and server trouble, and
// its guards (docs/device-sync.md, "Invariants", "Failure matrix",
// "Deletion threshold").
import { describe, expect, it } from 'vitest';
import { parseJsonColumn } from '../../common/json';
import type { PoisonMarker } from '../../planner';
import type { JmapInvocation, JmapResponse } from '../../types';
import {
addDeviceContact,
addServerCards,
CALENDAR_AUTHORITY,
CONTACTS_AUTHORITY,
createHarness,
CrashError,
renameDeviceContact,
REGISTRY_ID,
type Harness,
} from './harness';
const FINAL = ['Ada King', 'Grace Hopper', 'Hedy', 'Margaret'];
/** Work on both sides: device edit, create and delete; server update, create and delete. */
async function scenario(options: Parameters<typeof createHarness>[0] = {}): Promise<Harness> {
const h = createHarness(options);
const [, grace, , katherine] = addServerCards(h, ['Ada', 'Grace', 'Linus', 'Katherine']);
await h.run();
renameDeviceContact(h, h.contactNamed('Ada')!.id, 'Ada King');
addDeviceContact(h, 'Hedy');
h.device.user.deleteContact(h.contactNamed('Linus')!.id);
h.server.serverUpdate('ContactCard', 'a', grace, { 'name/full': 'Grace Hopper' });
addServerCards(h, ['Margaret']);
h.server.serverDestroy('ContactCard', 'a', katherine);
h.checkpoints.count = 0;
h.batches.applied = 0;
return h;
}
/** Both sides hold exactly the expected contacts, once each, and nothing waits for an upload. */
function expectConverged(h: Harness, names = FINAL): void {
const device = h.contacts();
expect(device.map((c) => c.name).sort()).toEqual(names);
expect(device.every((c) => !c.dirty && !c.deleted && c.sourceId)).toBe(true);
expect(h.serverNames()).toEqual(names);
const refs = device.map((c) => c.sourceId);
expect(new Set(refs).size).toBe(refs.length);
for (const contact of device) {
const card = h.server.get('ContactCard', 'a', contact.sourceId!.split('/')[1]);
expect((card?.name as { full: string }).full).toBe(contact.name);
}
const uids = h.server.all('ContactCard', 'a').map((c) => c.uid);
expect(new Set(uids).size).toBe(uids.length);
}
describe('device sync engine: crashes and lost responses', () => {
it('converges after a crash at any checkpoint, with no duplicates and no lost edits', async () => {
const probe = await scenario();
await probe.run();
expectConverged(probe);
const checkpoints = probe.checkpoints.count;
expect(checkpoints).toBeGreaterThan(5);
for (let n = 1; n <= checkpoints; n++) {
const h = await scenario();
h.checkpoints.crashAt = n;
const crashed = await h.run();
expect(crashed.outcome, `crash at checkpoint ${n}`).toBe('internal');
h.checkpoints.crashAt = null;
const report = await h.run();
expect(report.outcome, `after a crash at checkpoint ${n}`).toBe('ok');
expectConverged(h);
}
});
it('converges after a crash after any number of provider batches', async () => {
const probe = await scenario();
await probe.run();
const batches = probe.batches.applied;
expect(batches).toBeGreaterThan(3);
for (let n = 0; n < batches; n++) {
const h = await scenario();
h.batches.crashAfter = n;
await h.run();
h.batches.crashAfter = null;
const report = await h.run();
expect(report.outcome, `after a crash following batch ${n}`).toBe('ok');
expectConverged(h);
}
});
it('converges when the server applied any request but its response was lost', async () => {
const probe = await scenario();
const before = probe.server.requests.length;
await probe.run();
const requests = probe.server.requests.length - before;
for (let n = 1; n <= requests; n++) {
const h = await scenario();
// The uid index lags behind, as on Stalwart: a lookup right after the lost create misses it.
h.server.setUidIndexLag(2);
let seen = 0;
const stop = h.server.onBeforeRequest(() => {
if (++seen === n) h.server.applyThenLoseResponse();
});
const lost = await h.run();
stop();
expect(lost.outcome, `lost response ${n}`).toBe('io');
const report = await h.run();
expect(report.outcome, `after losing response ${n}`).toBe('ok');
expectConverged(h);
}
});
it('converges from a crash anywhere in a first sync (a checkpointed full reconcile)', async () => {
const names = Array.from({ length: 120 }, (_, i) => `P${String(i).padStart(3, '0')}`);
const fresh = () => {
const h = createHarness();
addServerCards(h, names);
return h;
};
const probe = fresh();
await probe.run();
const checkpoints = probe.checkpoints.count;
for (let n = 1; n <= checkpoints; n += 2) {
const h = fresh();
h.checkpoints.crashAt = n;
await h.run();
h.checkpoints.crashAt = null;
expect((await h.run()).outcome).toBe('ok');
expect(h.contacts().map((c) => c.name).sort()).toEqual(names);
expect(new Set(h.contacts().map((c) => c.sourceId)).size).toBe(120);
}
});
it('resumes a reconcile after its last stored chunk instead of starting over', async () => {
const names = Array.from({ length: 150 }, (_, i) => `P${String(i).padStart(3, '0')}`);
const h = createHarness();
addServerCards(h, names);
// Batches: the Settings row, the reconcile marker, then one per chunk of 50. Stop after the second chunk.
h.batches.crashAfter = 4;
await h.run();
const marker = h.state()?.accounts.a.reconcile;
expect(marker).toMatchObject({ phase: 'objects', position: 100 });
h.batches.crashAfter = null;
const gets = h.server.calls('ContactCard/get').length;
await h.run();
const fetched = h.server
.calls('ContactCard/get')
.slice(gets)
.filter(([, args]) => (args.properties as string[]).includes('name'))
.flatMap(([, args]) => args.ids as string[]);
expect(fetched).toHaveLength(50);
expect(h.contacts()).toHaveLength(150);
expect(h.state()?.accounts.a.reconcile).toBeNull();
});
it('adopts its own create whose response was lost instead of creating it twice', async () => {
const h = createHarness();
addServerCards(h, ['Ada']);
await h.run();
h.server.setUidIndexLag(3);
const id = addDeviceContact(h, 'Hedy');
h.server.applyThenLoseResponse({ match: 'ContactCard/set' });
expect((await h.run()).outcome).toBe('io');
expect(h.serverNames()).toEqual(['Ada', 'Hedy']);
expect(h.contacts().find((c) => c.id === id)?.sourceId).toBeNull();
expect((await h.run()).outcome).toBe('ok');
expect(h.serverNames()).toEqual(['Ada', 'Hedy']);
expect(h.contacts().find((c) => c.id === id)).toMatchObject({ name: 'Hedy', dirty: false, sourceId: expect.stringMatching(/^a\//) });
expect(h.contacts()).toHaveLength(2);
});
it("adopts the card a duplicate-uid error names (the client's automatic retry of a create)", async () => {
const h = createHarness();
await h.run();
addDeviceContact(h, 'Hedy');
// The first attempt was applied; the retry answers with Stalwart's duplicate-uid error.
const port = h.server.port();
let retried = false;
h.deps.jmap = async () => ({
origin: 'https://mail.example.com',
port: {
...port,
request: async (calls: JmapInvocation[], using: string[]): Promise<JmapResponse> => {
const response = await port.request(calls, using);
const [name, args] = response.methodResponses[0];
if (retried || name !== 'ContactCard/set' || !args.created) return response;
retried = true;
const [[key, created]] = Object.entries(args.created as Record<string, { id: string }>);
const uid = (calls[0][1].create as Record<string, { uid: string }>)[key].uid;
const notCreated = {
[key]: { type: 'invalidProperties', properties: ['uid'], description: `Contact with UID ${uid} already exists with id ${created.id}.` },
};
return { ...response, methodResponses: [[name, { ...args, created: undefined, notCreated }, response.methodResponses[0][2]]] };
},
},
});
const report = await h.run();
expect(report.outcome).toBe('ok');
expect(retried).toBe(true);
expect(h.serverNames()).toEqual(['Hedy']);
expect(h.contacts()).toEqual([expect.objectContaining({ name: 'Hedy', dirty: false, sourceId: expect.stringMatching(/^a\//) })]);
});
});
describe('device sync engine: server trouble', () => {
it('downloads again and retries the upload after a stateMismatch', async () => {
const h = createHarness();
const [ada, grace] = addServerCards(h, ['Ada', 'Grace']);
await h.run();
renameDeviceContact(h, h.contactNamed('Ada')!.id, 'Ada King');
let sets = 0;
const stop = h.server.onBeforeRequest((request) => {
// Another client writes right before our first upload.
if (request.methods.includes('ContactCard/set') && ++sets === 1) {
h.server.serverUpdate('ContactCard', 'a', grace, { 'name/full': 'Grace Hopper' });
}
});
const report = await h.run();
stop();
expect(report.outcome).toBe('ok');
const set = h.server.requests.filter((r) => r.methods.includes('ContactCard/set'));
expect(set[0].responses?.[0]).toEqual(['error', { type: 'stateMismatch' }, 's']);
expect(h.server.get('ContactCard', 'a', ada)).toMatchObject({ name: { full: 'Ada King' } });
expect(h.contacts().map((c) => c.name).sort()).toEqual(['Ada King', 'Grace Hopper']);
});
it('gives up with io and no progress when the server keeps changing', async () => {
const h = createHarness();
const [, grace] = addServerCards(h, ['Ada', 'Grace']);
await h.run();
renameDeviceContact(h, h.contactNamed('Ada')!.id, 'Ada King');
let n = 0;
const stop = h.server.onBeforeRequest((request) => {
if (request.methods.includes('ContactCard/set')) h.server.serverUpdate('ContactCard', 'a', grace, { 'name/full': `Grace ${++n}` });
});
const report = await h.run();
stop();
expect(report.outcome).toBe('io');
expect(report.stats.downloaded).toEqual({ created: 0, updated: 0, deleted: 0 });
expect(n).toBe(4);
expect(h.contactNamed('Ada King')?.dirty).toBe(true);
});
it('holds back the uploads of an account that refuses writes, and uploads the other accounts', async () => {
const h = createHarness({ accounts: 'withShared' });
const teamBook = h.server.all('AddressBook', 'team')[0].id as string;
h.prefs.contactsSelection[`team/${teamBook}`] = true;
addServerCards(h, ['Ada']);
const [mate] = addServerCards(h, ['Teammate'], 'team', teamBook);
await h.run();
renameDeviceContact(h, h.contactNamed('Ada')!.id, 'Ada King');
renameDeviceContact(h, h.contactNamed('Teammate')!.id, 'Team mate');
h.server.failNextMethod('ContactCard/set', { type: 'accountReadOnly' }, { accountId: 'team' });
const report = await h.run();
expect(report.outcome).toBe('ok');
expect(report.itemErrors).toEqual([expect.objectContaining({ ref: 'team', type: 'accountReadOnly' })]);
expect(h.serverNames()).toEqual(['Ada King']);
expect(h.server.get('ContactCard', 'team', mate)).toMatchObject({ name: { full: 'Teammate' } });
expect(h.contactNamed('Team mate')?.dirty).toBe(true);
await h.run();
expect(h.server.get('ContactCard', 'team', mate)).toMatchObject({ name: { full: 'Team mate' } });
});
it('reports a 429 as io with delayUntil from Retry-After', async () => {
const h = createHarness();
await h.run();
h.server.failNextRequest('rateLimit', { retryAfterMs: 120_000 });
const report = await h.run();
expect(report.outcome).toBe('io');
expect(report.delayUntil).toBe(Math.ceil((h.clock.now + 120_000) / 1000));
});
it('rebuilds the client once when the server rejects the credentials, then reports auth', async () => {
const h = createHarness();
addServerCards(h, ['Ada']);
h.server.failNextRequest('auth');
expect((await h.run()).outcome).toBe('ok');
expect(h.deps.notifyAuthProblem).not.toHaveBeenCalled();
h.server.failNextRequest('auth', { times: 2 });
const report = await h.run();
expect(report.outcome).toBe('auth');
expect(h.deps.notifyAuthProblem).toHaveBeenCalledWith(REGISTRY_ID, 'alice@example.com', CONTACTS_AUTHORITY);
expect(h.contacts()).toHaveLength(1);
});
it('reports auth without touching anything when the account has no stored credentials', async () => {
const h = createHarness();
addServerCards(h, ['Ada']);
await h.run();
const writes = h.batches.log.length;
h.deps.jmap = async () => {
const error = new Error('No usable credentials for this account');
error.name = 'AuthenticationError';
throw error;
};
const report = await h.run();
expect(report.outcome).toBe('auth');
expect(h.batches.log.length).toBe(writes);
});
it('full-reconciles an account after cannotCalculateChanges', async () => {
const h = createHarness();
const [ada, grace] = addServerCards(h, ['Ada', 'Grace', 'Linus']);
await h.run();
h.server.serverUpdate('ContactCard', 'a', ada, { 'name/full': 'Ada King' });
h.server.serverDestroy('ContactCard', 'a', grace);
h.server.truncateChangeLog('a', 'contacts');
const report = await h.run();
expect(report.outcome).toBe('ok');
expect(h.server.requests.some((r) => r.responses?.some(([, args]) => (args as { type?: string }).type === 'cannotCalculateChanges'))).toBe(true);
expect(h.contacts().map((c) => c.name).sort()).toEqual(['Ada King', 'Linus']);
expect(h.state()?.accounts.a).toMatchObject({ reconcile: null, itemsState: h.server.state('ContactCard', 'a') });
});
it('stops a reconcile that finds an empty server while the device is full (safetyAbort)', async () => {
const h = createHarness();
const ids = addServerCards(h, Array.from({ length: 12 }, (_, i) => `P${i}`));
await h.run();
for (const id of ids) h.server.serverDestroy('ContactCard', 'a', id);
h.server.truncateChangeLog('a', 'contacts');
const report = await h.run();
expect(report.outcome).toBe('safetyAbort');
expect(h.contacts()).toHaveLength(12);
});
});
describe('device sync engine: time and cancellation', () => {
it('stops cleanly near the deadline and asks for another sync', async () => {
const h = createHarness();
addServerCards(h, Array.from({ length: 150 }, (_, i) => `P${i}`));
h.checkpoints.onCheckpoint = (n) => {
// Time runs out in the middle of the download.
if (n === 4) h.clock.now += 9 * 60_000;
};
const report = await h.run();
expect(report).toMatchObject({ outcome: 'cancelled', moreRecordsToGet: true });
expect(h.contacts().length).toBeGreaterThan(0);
expect(h.contacts().length).toBeLessThan(150);
h.checkpoints.onCheckpoint = undefined;
expect((await h.run()).outcome).toBe('ok');
expect(h.contacts()).toHaveLength(150);
});
it('stops at the next checkpoint when the framework cancels the run', async () => {
const h = createHarness();
addServerCards(h, Array.from({ length: 150 }, (_, i) => `P${i}`));
h.deps.isCancelled = async () => true;
const report = await h.run();
expect(report).toMatchObject({ outcome: 'cancelled', moreRecordsToGet: true });
expect(h.contacts()).toHaveLength(0);
});
it('sends Core/echo when a local phase went 40 s without a JMAP request', async () => {
const h = createHarness();
addServerCards(h, Array.from({ length: 120 }, (_, i) => `P${i}`));
let n = 0;
const port = h.deps.provider;
h.deps.provider = (name, authority) => {
const inner = port(name, authority);
return {
...inner,
applyBatch: async (ops) => {
h.clock.now += 45_000;
n++;
return inner.applyBatch(ops);
},
};
};
await h.run();
expect(n).toBeGreaterThan(2);
expect(h.server.calls('Core/echo').length).toBeGreaterThan(0);
});
it('runs one sync of an account and authority at a time', async () => {
const h = createHarness();
addServerCards(h, Array.from({ length: 60 }, (_, i) => `P${i}`));
const [first, second] = await Promise.all([h.run(), h.run()]);
expect([first.outcome, second.outcome]).toEqual(['ok', 'ok']);
expect(h.server.maxInFlight).toBe(1);
expect(h.contacts()).toHaveLength(60);
});
});
describe('device sync engine: poisoned items', () => {
it('backs a refused item off, retries it after the back-off, and at once when it changes', async () => {
const h = createHarness();
const [ada] = addServerCards(h, ['Ada', 'Grace']);
await h.run();
const id = h.contactNamed('Ada')!.id;
renameDeviceContact(h, id, 'Ada King');
h.server.setErrorFor('ContactCard', 'a', (t) => t.op === 'update' && t.id === ada, {
type: 'invalidProperties',
properties: ['name'],
description: 'Invalid property.',
}, 3);
const first = await h.run();
expect(first.outcome).toBe('ok');
expect(first.stats.skipped).toBe(1);
expect(first.itemErrors).toEqual([
expect.objectContaining({ ref: `a/${ada}`, side: 'upload', type: 'invalidProperties', retryAt: h.clock.now + 3_600_000 }),
]);
const marker = parseJsonColumn<PoisonMarker>(h.contacts().find((c) => c.id === id)!.row.sync4);
expect(marker).toMatchObject({ type: 'invalidProperties', n: 1, until: h.clock.now + 3_600_000 });
// Not retried hot.
const sets = h.server.calls('ContactCard/set').length;
const again = await h.run();
expect(h.server.calls('ContactCard/set').length).toBe(sets);
expect(again.itemErrors).toEqual([expect.objectContaining({ type: 'invalidProperties' })]);
// Retried once the back-off is over; refused again, the back-off doubles.
h.clock.now += 3_600_001;
await h.run();
expect(h.server.calls('ContactCard/set').length).toBe(sets + 1);
expect(parseJsonColumn<PoisonMarker>(h.contacts().find((c) => c.id === id)!.row.sync4)).toMatchObject({ n: 2, until: h.clock.now + 7_200_000 });
// Changed by the user: retried at once (the server refuses once more), then accepted.
renameDeviceContact(h, id, 'Ada, Countess');
await h.run();
expect(h.server.calls('ContactCard/set').length).toBe(sets + 2);
renameDeviceContact(h, id, 'Ada Lovelace');
const fixed = await h.run();
expect(fixed.itemErrors).toEqual([]);
expect(h.server.get('ContactCard', 'a', ada)).toMatchObject({ name: { full: 'Ada Lovelace' } });
expect(h.contacts().find((c) => c.id === id)!.row.sync4).toBeNull();
});
it('never uploads an item whose rows could not be written until it was fetched again', async () => {
const h = createHarness();
const [ada] = addServerCards(h, ['Ada']);
await h.run();
h.server.serverUpdate('ContactCard', 'a', ada, { 'name/full': 'Ada King' });
// The provider refuses the item's rows (in its batch and alone): it goes on `stale`, the state still moves on.
const provider = h.deps.provider;
let refusals = 2;
h.deps.provider = (name, authority) => {
const port = provider(name, authority);
return {
...port,
applyBatch: async (ops) =>
refusals > 0 && ops.some((op) => op.op === 'update' && op.table === 'data') && refusals--
? { ok: false, reason: 'provider', message: 'SQLiteException' }
: port.applyBatch(ops),
};
};
const failing = await h.run();
expect(refusals).toBe(0);
expect(failing.itemErrors).toEqual([expect.objectContaining({ ref: `a/${ada}`, side: 'download', type: 'provider' })]);
expect(h.state()?.accounts.a.stale).toEqual([ada]);
expect(h.state()?.accounts.a.itemsState).toBe(h.server.state('ContactCard', 'a'));
const next = await h.run();
expect(next.outcome).toBe('ok');
expect(h.contacts().map((c) => c.name)).toEqual(['Ada King']);
expect(h.state()?.accounts.a.stale).toEqual([]);
});
});
describe('device sync engine: planner failures', () => {
it('keeps syncing everything else when a planner throws on one item, and tries that one again next run', async () => {
const h = createHarness();
const [, grace] = addServerCards(h, ['Ada', 'Grace', 'Linus']);
let broken = true;
const planner = h.deps.planners.contacts;
h.deps.planners = {
...h.deps.planners,
contacts: {
...planner,
planDownload: (card, local, ctx) => {
if (broken && card.id === grace) throw new TypeError('cannot read the card');
return planner.planDownload(card, local, ctx);
},
planUpload: (local, ctx) => {
if (broken && local.rows.some((r) => r.cells.data1 === 'Hedy')) throw new TypeError('cannot read the rows');
return planner.planUpload(local, ctx);
},
},
};
addDeviceContact(h, 'Hedy');
addDeviceContact(h, 'Mary');
const report = await h.run();
expect(report.outcome).toBe('ok');
expect(report.itemErrors).toEqual(
expect.arrayContaining([
expect.objectContaining({ ref: `a/${grace}`, side: 'download', type: 'plannerError' }),
expect.objectContaining({ side: 'upload', type: 'plannerError' }),
]),
);
expect(h.contacts().map((c) => c.name).sort()).toEqual(['Ada', 'Hedy', 'Linus', 'Mary']);
expect(h.serverNames()).toEqual(['Ada', 'Grace', 'Linus', 'Mary']);
// The reconcile completed past the item that could not be planned.
expect(h.state()?.accounts.a).toMatchObject({ stale: [grace], reconcile: null, itemsState: expect.any(String) });
broken = false;
expect((await h.run()).itemErrors).toEqual([]);
expect(h.contacts().map((c) => c.name).sort()).toEqual(['Ada', 'Grace', 'Hedy', 'Linus', 'Mary']);
expect(h.serverNames()).toEqual(['Ada', 'Grace', 'Hedy', 'Linus', 'Mary']);
expect(h.state()?.accounts.a.stale).toEqual([]);
});
});
describe('device sync engine: deletion guards', () => {
async function withDeletions(total: number, deleted: number): Promise<Harness> {
const h = createHarness();
addServerCards(h, Array.from({ length: total }, (_, i) => `P${String(i).padStart(3, '0')}`));
await h.run();
for (const contact of h.contacts().slice(0, deleted)) h.device.user.deleteContact(contact.id);
return h;
}
it('holds every deletion back above 50 and 20 % (tooManyDeletions)', async () => {
const h = await withDeletions(100, 55);
const report = await h.run();
expect(report).toMatchObject({ outcome: 'tooManyDeletions', tooManyDeletions: { count: 55, threshold: 50 } });
expect(h.serverNames()).toHaveLength(100);
expect(h.contacts().filter((c) => c.deleted)).toHaveLength(55);
});
it('uploads the deletions when the run overrides the threshold', async () => {
const h = await withDeletions(100, 55);
const report = await h.run(CONTACTS_AUTHORITY, { overrideTooManyDeletions: true });
expect(report.outcome).toBe('ok');
expect(report.stats.uploaded.deleted).toBe(55);
expect(h.serverNames()).toHaveLength(45);
expect(h.contacts()).toHaveLength(45);
});
it('restores the deleted contacts from the server when the run discards the deletions', async () => {
const h = await withDeletions(100, 55);
const discard = await h.run(CONTACTS_AUTHORITY, { discardLocalDeletions: true });
expect(discard.outcome).toBe('ok');
expect(h.serverNames()).toHaveLength(100);
const next = await h.run(CONTACTS_AUTHORITY, { upload: true });
expect(next.outcome).toBe('ok');
expect(h.contacts()).toHaveLength(100);
expect(h.contacts().every((c) => !c.deleted && !c.dirty)).toBe(true);
});
it('uploads deletions below the threshold', async () => {
const h = await withDeletions(100, 20);
const report = await h.run();
expect(report.outcome).toBe('ok');
expect(h.serverNames()).toHaveLength(80);
});
it('counts groups an app hard-deleted, found by their absence', async () => {
const h = createHarness();
const group = h.server.addCard('a', { uid: 'g1', kind: 'group', name: { full: 'Friends' } });
addServerCards(h, ['Ada']);
await h.run();
h.device.user.fossifyDeleteGroup(Number(h.device.rows('groups')[0]._id));
const report = await h.run();
expect(report.stats.uploaded.deleted).toBe(1);
expect(h.server.get('ContactCard', 'a', group)).toBeUndefined();
expect(h.state()?.accounts.a.groups).toEqual([]);
});
it('fails a crash-free run with CrashError only when injected', () => {
expect(new CrashError('x').name).toBe('CrashError');
});
});
describe('device sync engine: accounts that refuse access', () => {
// Stalwart lists an account shared for mail only with every capability,
// and its /get calls answer `forbidden`.
it('syncs the personal account when a shared one refuses to list its address books', async () => {
const h = createHarness({ accounts: 'withShared' });
addServerCards(h, ['Ada']);
h.server.failNextMethod('AddressBook/get', { type: 'forbidden', description: 'You do not have access to account team' }, { accountId: 'team' });
const report = await h.run();
expect(report.outcome).toBe('ok');
expect(report.itemErrors).toEqual([]);
expect(h.contactNamed('Ada')).toBeDefined();
});
it('keeps the contacts of an account that stops listing its address books, and syncs it again once it does', async () => {
const h = createHarness({ accounts: 'withShared' });
const teamBook = h.server.all('AddressBook', 'team')[0].id as string;
h.prefs.contactsSelection[`team/${teamBook}`] = true;
const [mate] = addServerCards(h, ['Teammate'], 'team', teamBook);
await h.run();
expect(h.contactNamed('Teammate')).toBeDefined();
addServerCards(h, ['Ada']);
h.server.serverUpdate('ContactCard', 'team', mate, { 'name/full': 'Team mate' });
h.server.failNextMethod('AddressBook/get', { type: 'forbidden' }, { accountId: 'team' });
const report = await h.run();
expect(report.outcome).toBe('ok');
expect(h.contactNamed('Ada')).toBeDefined();
expect(h.contactNamed('Teammate')).toBeDefined();
expect((await h.run()).itemErrors).toEqual([]);
expect(h.contactNamed('Team mate')).toBeDefined();
});
it('keeps the calendar and events of an account that refuses to list its calendars', async () => {
const h = createHarness({ accounts: 'withShared' });
const teamCalendar = h.server.all('Calendar', 'team')[0].id as string;
h.prefs.calendarSelection[`team/${teamCalendar}`] = true;
h.server.addEvent('team', {
uid: 'uid-team',
title: 'Team meeting',
start: '2026-09-28T09:00:00',
duration: 'PT1H',
timeZone: 'Etc/UTC',
calendarIds: { [teamCalendar]: true },
});
await h.run(CALENDAR_AUTHORITY);
expect(h.events()).toHaveLength(1);
h.server.failNextMethod('Calendar/get', { type: 'forbidden' }, { accountId: 'team' });
const report = await h.run(CALENDAR_AUTHORITY);
expect(report.outcome).toBe('ok');
expect(report.itemErrors).toEqual([]);
expect(h.events()).toHaveLength(1);
expect(h.device.rows('calendars').some((c) => c._sync_id === `team/${teamCalendar}`)).toBe(true);
});
});
@@ -0,0 +1,224 @@
// SyncState.groups lists the group cards present on the device, so a group an
// app hard-deleted (Fossify) is found by its absence and deleted on the server.
// The list must change in the same batch as the rows: a group row the engine
// removed but still listed reads as a hard delete and destroys the card, and a
// row it inserted but never listed hides a later hard delete.
import { describe, expect, it } from 'vitest';
import { MimeType } from '../../android-columns';
import { calendarPlanner } from '../../calendar/planner';
import { contactsPlanner } from '../../contacts/planner';
import { serializeSyncState } from '../../engine/sync-state';
import { addServerCards, ANDROID_ACCOUNT, CONTACTS_AUTHORITY, createHarness, RUN_BUDGET, type Harness } from './harness';
function real(): Harness {
const h = createHarness();
h.deps.planners = { contacts: contactsPlanner, calendar: calendarPlanner };
return h;
}
/** The personal book with Ada, and a Work book with Boss, Colleague and the group "Team" (Boss). */
async function withWorkGroup() {
const h = real();
const work = h.server.addAddressBook('a', { name: 'Work' });
addServerCards(h, ['Ada']);
const [boss] = addServerCards(h, ['Boss', 'Colleague'], 'a', work);
const bossUid = h.server.get('ContactCard', 'a', boss)!.uid as string;
const group = h.server.addCard('a', {
uid: 'g-team',
kind: 'group',
name: { full: 'Team' },
addressBookIds: { [work]: true },
members: { [bossUid]: true },
});
expect((await h.run()).outcome).toBe('ok');
expect(h.state()?.accounts.a.groups).toEqual([`a/${group}`]);
h.checkpoints.count = 0;
return { h, group, work };
}
/** Replaces the stored SyncState's `groups` of account `a` (a list that drifted from the rows). */
async function storeGroups(h: Harness, groups: string[]): Promise<void> {
const state = h.state()!;
state.accounts.a.groups = groups;
await h.device.port(ANDROID_ACCOUNT, CONTACTS_AUTHORITY).applyBatch([{ op: 'syncState', value: serializeSyncState(state) }]);
}
describe('device sync engine: the groups a SyncState lists', () => {
it('keeps a group card on the server after a deselection stopped at any checkpoint', async () => {
const probe = await withWorkGroup();
probe.h.prefs.contactsSelection[`a/${probe.work}`] = false;
await probe.h.run();
const total = probe.h.checkpoints.count;
expect(probe.h.server.get('ContactCard', 'a', probe.group)).toBeDefined();
for (let n = 1; n <= total; n++) {
const { h, group, work } = await withWorkGroup();
h.prefs.contactsSelection[`a/${work}`] = false;
h.checkpoints.crashAt = n;
await h.run();
h.checkpoints.crashAt = null;
expect((await h.run()).outcome, `run after a crash at checkpoint ${n}`).toBe('ok');
expect(h.server.get('ContactCard', 'a', group), `group card after a crash at checkpoint ${n}`).toBeDefined();
expect(h.device.rows('groups')).toEqual([]);
expect(h.state()?.accounts.a.groups).toEqual([]);
}
});
it('keeps a group card on the server when its book is selected again after a stopped deselection', async () => {
const { h, group, work } = await withWorkGroup();
h.prefs.contactsSelection[`a/${work}`] = false;
// Checkpoint 3 comes right after the deselected book's group rows went.
h.checkpoints.crashAt = 3;
await h.run();
h.checkpoints.crashAt = null;
expect(h.device.rows('groups')).toEqual([]);
h.prefs.contactsSelection[`a/${work}`] = true;
const report = await h.run();
expect(report).toMatchObject({ outcome: 'ok', stats: { uploaded: { deleted: 0 } } });
expect(h.server.get('ContactCard', 'a', group)).toBeDefined();
});
it('keeps a group card on the server when sync is turned off while a deselection drops it', async () => {
const { h, group, work } = await withWorkGroup();
h.prefs.contactsSelection[`a/${work}`] = false;
let teardown: Promise<{ pending: number }> | null = null;
h.checkpoints.onCheckpoint = (n) => {
// Right before the run drops the deselected book's rows.
if (n === 2 && !teardown) teardown = h.teardown(CONTACTS_AUTHORITY);
};
const run = await h.run();
h.checkpoints.onCheckpoint = undefined;
expect(run.outcome).toBe('cancelled');
expect(await teardown!).toEqual({ pending: 0 });
expect(h.contacts()).toHaveLength(0);
expect(h.server.get('ContactCard', 'a', group)).toBeDefined();
});
it('keeps a group card on the server when the deselecting run runs out of time after dropping the group', async () => {
const { h, group, work } = await withWorkGroup();
h.prefs.contactsSelection[`a/${work}`] = false;
h.checkpoints.onCheckpoint = (n) => {
if (n === 2) h.clock.now += RUN_BUDGET;
};
expect((await h.run()).outcome).toBe('cancelled');
h.checkpoints.onCheckpoint = undefined;
expect((await h.run()).outcome).toBe('ok');
expect(h.server.get('ContactCard', 'a', group)).toBeDefined();
});
it('keeps a group card the server moved to a book that does not sync when the run stops within the page', async () => {
const h = real();
const archive = h.server.addAddressBook('a', { name: 'Archive' });
h.prefs.contactsSelection[`a/${archive}`] = false;
const ids = addServerCards(h, Array.from({ length: 60 }, (_, i) => `P${String(i).padStart(2, '0')}`));
const group = h.server.addCard('a', { uid: 'g-team', kind: 'group', name: { full: 'Team' }, addressBookIds: { [h.book]: true }, members: {} });
await h.run();
expect(h.device.rows('groups')).toHaveLength(1);
// Another client moves the group to Archive and renames 60 contacts: one /changes page, several chunks.
h.server.serverUpdate('ContactCard', 'a', group, { addressBookIds: { [archive]: true } });
ids.forEach((id, i) => h.server.serverUpdate('ContactCard', 'a', id, { 'name/full': `Q${i}` }));
// Checkpoints: 'changes', 'download' (the group's chunk), 'download' (the first contacts chunk) …
h.checkpoints.count = 0;
h.checkpoints.crashAt = 3;
await h.run();
h.checkpoints.crashAt = null;
expect(h.device.rows('groups')).toHaveLength(0);
expect((await h.run()).outcome).toBe('ok');
expect(h.server.get('ContactCard', 'a', group)).toBeDefined();
expect(h.state()?.accounts.a.groups).toEqual([]);
});
it('lists a group written before an interruption, so a hard delete by an app still reaches the server', async () => {
const h = real();
addServerCards(h, Array.from({ length: 60 }, (_, i) => `P${String(i).padStart(2, '0')}`));
const group = h.server.addCard('a', { uid: 'g1', kind: 'group', name: { full: 'Friends' }, members: {} });
// First sync: 'reconcile', 'reconcile:ids', 'reconcile:objects' (the group's chunk), then out of time.
h.checkpoints.onCheckpoint = (n) => {
if (n === 3) h.clock.now += RUN_BUDGET;
};
expect((await h.run()).outcome).toBe('cancelled');
h.checkpoints.onCheckpoint = undefined;
expect(h.device.rows('groups')).toHaveLength(1);
expect(h.state()?.accounts.a.groups).toEqual([`a/${group}`]);
expect((await h.run()).outcome).toBe('ok');
expect(h.contacts()).toHaveLength(60);
// Fossify hard-deletes the group through a sync-adapter URI.
h.device.user.fossifyDeleteGroup(Number(h.device.rows('groups')[0]._id));
expect((await h.run()).outcome).toBe('ok');
expect(h.server.get('ContactCard', 'a', group)).toBeUndefined();
});
it('lists a group again when it comes back as an echo', async () => {
const h = real();
const group = h.server.addCard('a', { uid: 'g1', kind: 'group', name: { full: 'Friends' }, members: {} });
await h.run();
// A list that lost the group (a write committed without its state op), then a download of the unchanged card.
await storeGroups(h, []);
addServerCards(h, ['Ada']);
h.server.truncateChangeLog('a', 'contacts');
expect((await h.run()).outcome).toBe('ok');
expect(h.state()?.accounts.a.groups).toEqual([`a/${group}`]);
h.device.user.fossifyDeleteGroup(Number(h.device.rows('groups')[0]._id));
await h.run();
expect(h.server.get('ContactCard', 'a', group)).toBeUndefined();
});
it("moves a group's membership on the device even when time ran out after the group's chunk", async () => {
const h = real();
const [ada, grace] = addServerCards(h, ['Ada Lovelace', 'Grace Hopper']);
const uid = (id: string) => h.server.get('ContactCard', 'a', id)!.uid as string;
const group = h.server.addCard('a', { uid: 'g1', kind: 'group', name: { full: 'Friends' }, members: { [uid(ada)]: true } });
await h.run();
const members = () =>
h.device.rows('data')
.filter((d) => d.mimetype === MimeType.GROUP_MEMBERSHIP)
.map((d) => h.contacts().find((c) => c.id === Number(d.raw_contact_id))?.name)
.sort();
expect(members()).toEqual(['Ada Lovelace']);
// Another client moves the membership from Ada to Grace: only the group card changes.
h.server.serverUpdate('ContactCard', 'a', group, { members: { [uid(grace)]: true } });
// 'changes', 'download' (the group's chunk), then out of time before the members' chunk.
h.checkpoints.count = 0;
h.checkpoints.onCheckpoint = (n) => {
if (n === 2) h.clock.now += RUN_BUDGET;
};
expect((await h.run()).outcome).toBe('cancelled');
h.checkpoints.onCheckpoint = undefined;
expect((await h.run()).outcome).toBe('ok');
expect(members()).toEqual(['Grace Hopper']);
expect(h.state()?.accounts.a.stale).toEqual([]);
});
it('never destroys a listed group card that is in no synced book or no longer a group, and forgets it', async () => {
const h = real();
const archive = h.server.addAddressBook('a', { name: 'Archive' });
h.prefs.contactsSelection[`a/${archive}`] = false;
const filed = h.server.addCard('a', { uid: 'g1', kind: 'group', name: { full: 'Filed' }, addressBookIds: { [archive]: true }, members: {} });
const person = h.server.addCard('a', { uid: 'p1', name: { full: 'Was a group' }, addressBookIds: { [h.book]: true } });
await h.run();
// The list names both as groups whose rows are gone (the engine removed them, and the batch storing that was lost).
await storeGroups(h, [`a/${filed}`, `a/${person}`]);
const report = await h.run();
expect(report).toMatchObject({ outcome: 'ok', stats: { uploaded: { deleted: 0 } } });
expect(h.server.get('ContactCard', 'a', filed)).toBeDefined();
expect(h.server.get('ContactCard', 'a', person)).toBeDefined();
expect(h.state()?.accounts.a.groups).toEqual([]);
});
});
+204
View File
@@ -0,0 +1,204 @@
/**
* The engine under test, wired to the fake providers, the fake JMAP server
* and the toy planners, with helpers to read both sides and inject faults.
*/
import { vi } from 'vitest';
import { Data, MimeType } from '../../android-columns';
import { parseJsonColumn } from '../../common/json';
import { runDeviceSync } from '../../engine/run';
import { teardownAuthority, type TeardownOptions } from '../../engine/teardown';
import type { EngineDeps, JmapConnection, Tuning } from '../../engine/deps';
import { openWithAuthRebuild } from '../../jmap/connection';
import type { SyncState } from '../../engine/sync-state';
import {
CALENDAR_AUTHORITY,
CONTACTS_AUTHORITY,
type AccountSyncPrefs,
type Authority,
type ProviderOp,
type ProviderPort,
type RunExtras,
type RunReport,
type RunStatus,
type Row,
} from '../../types';
import { FakeDeviceProviders } from '../fakes/fake-provider';
import { FakeJmapServer } from '../fakes/fake-jmap-server';
import { toyCalendarPlanner, toyContactsPlanner } from './toy-planners';
export const ANDROID_ACCOUNT = 'alice@example.com';
export const REGISTRY_ID = 'alice@mail.example.com';
export const ORIGIN = 'https://mail.example.com';
export const START = Date.UTC(2026, 8, 27, 10, 0, 0);
export const RUN_BUDGET = 9 * 60_000;
/** Deterministic randomness: uids and keys differ between items but repeat between test runs. */
export function seededRandom(seed = 42): () => number {
let s = seed >>> 0;
return () => {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 2 ** 32;
};
}
export class CrashError extends Error {
constructor(where: string) {
super(`crash injected ${where}`);
this.name = 'CrashError';
}
}
export interface Harness {
server: FakeJmapServer;
device: FakeDeviceProviders;
deps: EngineDeps;
prefs: AccountSyncPrefs;
book: string;
calendar: string;
clock: { now: number };
/** Checkpoints seen by the last runs (each call of isCancelled). */
checkpoints: { count: number; crashAt: number | null; onCheckpoint?: (n: number) => void };
/** Batches applied through the engine's provider port; crash after `crashAfter` of them. */
batches: { applied: number; crashAfter: number | null; log: ProviderOp[][] };
run(authority?: Authority, extras?: RunExtras): Promise<RunReport>;
teardown(authority: Authority, options?: TeardownOptions): Promise<{ pending: number }>;
state(authority?: Authority): SyncState | null;
contacts(): Array<{ id: number; sourceId: string | null; name: string | null; dirty: boolean; deleted: boolean; row: Row }>;
contactNamed(name: string): { id: number; sourceId: string | null; dirty: boolean; deleted: boolean } | undefined;
nameDataRow(rawContactId: number): Row | undefined;
events(): Row[];
serverNames(accountId?: string): string[];
/** The status the runs recorded last for the settings (what `deps.lastStatus` answers). */
lastStatus(authority?: Authority): RunStatus | undefined;
}
export function createHarness(options: { tuning?: Partial<Tuning>; accounts?: 'personal' | 'withShared'; rebuild?: boolean } = {}): Harness {
const clock = { now: START };
const server = new FakeJmapServer({ now: () => clock.now });
server.addAccount('a', { name: ANDROID_ACCOUNT });
const book = server.addAddressBook('a', { name: 'Personal', isDefault: true });
const calendar = server.addCalendar('a', { name: 'Calendar', isDefault: true });
if (options.accounts === 'withShared') {
server.addAccount('team', { name: 'Team', isPersonal: false });
server.addAddressBook('team', { name: 'Team contacts' });
server.addCalendar('team', { name: 'Team calendar' });
}
const device = new FakeDeviceProviders();
const prefs: AccountSyncPrefs = { contactsSelection: {}, calendarSelection: {}, reminderOwner: 'device' };
const checkpoints: Harness['checkpoints'] = { count: 0, crashAt: null };
const batches: Harness['batches'] = { applied: 0, crashAfter: null, log: [] };
const statuses = new Map<Authority, RunStatus>();
const wrap = (port: ProviderPort): ProviderPort => ({
accountName: port.accountName,
authority: port.authority,
query: (q) => port.query(q),
readSyncState: () => port.readSyncState(),
readPhoto: (id, px) => port.readPhoto(id, px),
applyBatch: async (ops) => {
if (batches.crashAfter !== null && batches.applied >= batches.crashAfter) throw new CrashError(`after ${batches.applied} batches`);
const result = await port.applyBatch(ops);
if (result.ok) {
batches.applied++;
batches.log.push(ops);
}
return result;
},
});
const connection = (): JmapConnection => ({ port: server.port(), origin: ORIGIN });
const deps: EngineDeps = {
provider: (name, authority) => wrap(device.port(name, authority)),
jmap: async () => (options.rebuild === false ? connection() : openWithAuthRebuild(async () => connection())),
prefs: async () => prefs,
planners: { contacts: toyContactsPlanner, calendar: toyCalendarPlanner },
now: () => clock.now,
isCancelled: async () => {
checkpoints.count++;
checkpoints.onCheckpoint?.(checkpoints.count);
if (checkpoints.crashAt !== null && checkpoints.count >= checkpoints.crashAt) throw new CrashError(`at checkpoint ${checkpoints.count}`);
return false;
},
random: seededRandom(),
subscriptionCalendars: async () => [],
isSyncEnabled: async () => true,
deviceZone: () => 'Europe/Berlin',
recordStatus: vi.fn((_registryId: string, authority: Authority, status: RunStatus) => {
statuses.set(authority, status);
}),
lastStatus: (_registryId, authority) => statuses.get(authority),
recordKnownState: vi.fn(),
notifyAuthProblem: vi.fn(),
yieldThread: async () => undefined,
sleep: async () => undefined,
tuning: options.tuning,
};
let runs = 0;
const harness: Harness = {
server,
device,
deps,
prefs,
book,
calendar,
clock,
checkpoints,
batches,
run: (authority = CONTACTS_AUTHORITY, extras = {}) =>
runDeviceSync(
{ runId: `run-${++runs}`, accountName: ANDROID_ACCOUNT, registryId: REGISTRY_ID, authority, extras, deadline: clock.now + RUN_BUDGET },
deps,
),
teardown: (authority, teardownOptions) => teardownAuthority(deps, REGISTRY_ID, ANDROID_ACCOUNT, authority, teardownOptions),
state: (authority = CONTACTS_AUTHORITY) => parseJsonColumn<SyncState>(device.readSyncState(ANDROID_ACCOUNT, authority)),
contacts: () =>
device.rows('raw_contacts').map((row) => {
const id = Number(row._id);
const nameRow = harness.nameDataRow(id);
return {
id,
sourceId: (row.sourceid as string | null) ?? null,
name: (nameRow?.[Data.DATA1] as string | null) ?? null,
dirty: Number(row.dirty) === 1,
deleted: Number(row.deleted) === 1,
row,
};
}),
contactNamed: (name) => harness.contacts().find((c) => c.name === name),
nameDataRow: (rawContactId) =>
device.rows('data').find((d) => Number(d.raw_contact_id) === rawContactId && d.mimetype === MimeType.STRUCTURED_NAME),
events: () => device.rows('events'),
serverNames: (accountId = 'a') =>
server
.all('ContactCard', accountId)
.filter((c) => c.kind !== 'group')
.map((c) => String((c.name as { full?: string } | undefined)?.full ?? ''))
.sort(),
lastStatus: (authority = CONTACTS_AUTHORITY) => statuses.get(authority),
};
return harness;
}
/** A contact added on the device the way a Contacts app does (DIRTY, no SOURCE_ID). */
export function addDeviceContact(h: Harness, name: string): number {
return h.device.user.insertContact(ANDROID_ACCOUNT, [{ [Data.MIMETYPE]: MimeType.STRUCTURED_NAME, [Data.DATA1]: name }]);
}
/** An in-place edit of a contact's name (AOSP Contacts). */
export function renameDeviceContact(h: Harness, rawContactId: number, name: string): void {
const row = h.nameDataRow(rawContactId);
if (!row) throw new Error(`no name row for ${rawContactId}`);
h.device.user.updateData(Number(row._id), { [Data.DATA1]: name, [Data.DATA2]: null, [Data.DATA3]: null });
}
export function addServerCards(h: Harness, names: string[], accountId = 'a', book = h.book): string[] {
return names.map((name, i) => h.server.addCard(accountId, { uid: `uid-${name}-${i}`, name: { full: name }, addressBookIds: { [book]: true } }));
}
/** Batches that wrote rows (anything but the SyncState op). */
export function rowWrites(batches: ProviderOp[][]): ProviderOp[][] {
return batches.filter((ops) => ops.some((op) => op.op !== 'syncState' && op.op !== 'assert'));
}
export { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY };
@@ -0,0 +1,506 @@
// The engine's building blocks on their own: provider batches, the
// SyncState blob, the run lock, SetError handling, query paging, the auth
// rebuild and how failures map to outcomes.
import { describe, expect, it, vi } from 'vitest';
import { BatchWriter, buildBatch, estimateBatchBytes, MAX_OPS_PER_YIELD, prependOps, type Work } from '../../engine/batch';
import { classifyFailure, RunAbort, StateMismatch, StopRun } from '../../engine/errors';
import { acquireLock } from '../../engine/mutex';
import { isBackedOff, nextMarker } from '../../engine/poison';
import { emptyStats, isQuietRun, statusToRecord } from '../../engine/report';
import { collectionDefaultOn, isCollectionSelected } from '../../engine/selection';
import { accountOf, emptySyncState, parseSyncState, serializeSyncState, StateStore, type SyncState } from '../../engine/sync-state';
import { classifySetError, existingIdFromUidError, JmapCaller } from '../../jmap/caller';
import { openWithAuthRebuild } from '../../jmap/connection';
import { JMAPMethodError } from '../../jmap/errors';
import { accountsWithCapability, calendarAddresses } from '../../jmap/session';
import { JMAP_CALENDARS, JMAP_CONTACTS, type BatchResult, type ProviderOp, type ProviderPort, type RunStats, type RunStatus } from '../../types';
import { FakeJmapServer } from '../fakes/fake-jmap-server';
function port(apply: (ops: ProviderOp[]) => BatchResult | Promise<BatchResult>): ProviderPort & { sent: ProviderOp[][] } {
const sent: ProviderOp[][] = [];
return {
accountName: 'x',
authority: 'com.android.contacts',
sent,
query: async () => ({ columns: [], rows: [] }),
readSyncState: async () => null,
readPhoto: async () => null,
applyBatch: async (ops) => {
sent.push(ops);
return apply(ops);
},
};
}
const ok = (ops: ProviderOp[]): BatchResult => ({ ok: true, results: ops.map(() => ({ count: 1 })) });
const insert = (table: 'raw_contacts' | 'data', values: Record<string, string> = {}): ProviderOp => ({ op: 'insert', table, values });
const writer = (p: ProviderPort, options: Partial<{ maxOps: number; maxBytes: number; maxReplans: number }> = {}) =>
new BatchWriter(p, { maxOps: 400, maxBytes: 300_000, maxReplans: 3, ...options });
describe('provider batches', () => {
it('rebases refs, makes each group start at a yield point and puts the state op last', () => {
const group = { ref: 'g', ops: [insert('raw_contacts'), { op: 'insert', table: 'data', values: {}, refs: { raw_contact_id: 0 }, yieldAllowed: true } as ProviderOp] };
const ops = buildBatch([group, group], { op: 'syncState', value: '{}', yieldAllowed: true } as ProviderOp);
expect(ops.map((op) => (op as { yieldAllowed?: boolean }).yieldAllowed ?? false)).toEqual([true, false, true, false, false]);
expect((ops[3] as { refs: Record<string, number> }).refs).toEqual({ raw_contact_id: 2 });
expect(ops[4]).toEqual({ op: 'syncState', value: '{}' });
});
it('prepends ops to a group, shifting its refs', () => {
const group = prependOps({ ref: 'g', ops: [insert('raw_contacts'), { op: 'insert', table: 'data', values: {}, refs: { raw_contact_id: 0 } }] }, [
{ op: 'assert', table: 'raw_contacts', where: 'sourceid = ?', args: ['a/1'], expectCount: 0 },
]);
expect((group.ops[2] as { refs: Record<string, number> }).refs).toEqual({ raw_contact_id: 1 });
});
it('never splits a group, keeps batches within the op budget and sends photo groups alone', async () => {
const p = port(ok);
const works: Work[] = [
...Array.from({ length: 5 }, (_, i) => ({ group: { ref: `g${i}`, ops: [insert('raw_contacts'), insert('data'), insert('data')] } })),
{ group: { ref: 'photo', ops: [{ op: 'insert', table: 'data', values: { data15: { b64: 'AAAA' } } }] } },
{ group: { ref: 'g5', ops: [insert('raw_contacts')] } },
];
await writer(p, { maxOps: 7 }).write(works, { op: () => ({ op: 'syncState', value: 's' }), applied: () => undefined });
expect(p.sent.map((b) => b.length)).toEqual([6, 6, 3, 1, 2]);
expect(p.sent[3]).toEqual([expect.objectContaining({ values: { data15: { b64: 'AAAA' } } })]);
expect(p.sent[4][1]).toEqual({ op: 'syncState', value: 's' });
});
it('retries the groups of a failed batch one by one and re-plans a failed assert at most three times', async () => {
let failing = 5;
const p = port((ops) =>
ops.some((op) => op.op === 'assert') && failing-- > 0 ? { ok: false, reason: 'assert', message: 'stale' } : ok(ops),
);
const applied: string[] = [];
const failed = vi.fn();
let replans = 0;
const stale: Work = {
group: { ref: 'stale', ops: [{ op: 'assert', table: 'raw_contacts', id: 1, values: { version: 1 } }, insert('data')] },
applied: () => {
applied.push('stale');
},
failed,
};
stale.replan = async () => {
replans++;
return stale;
};
const fine: Work = {
group: { ref: 'fine', ops: [insert('raw_contacts')] },
applied: () => {
applied.push('fine');
},
};
const tail = { op: () => ({ op: 'syncState', value: 's' }) as ProviderOp, applied: vi.fn() };
await writer(p).write([fine, stale], tail);
expect(applied).toEqual(['fine']);
expect(replans).toBe(3);
expect(failed).toHaveBeenCalledWith('assert', 'stale');
expect(tail.applied).toHaveBeenCalledOnce();
expect(p.sent.at(-1)).toEqual([{ op: 'syncState', value: 's' }]);
});
it('splits a batch the Binder refused (tooLarge) and gives up a single group that is too large', async () => {
const p = port((ops) => (ops.length > 2 ? { ok: false, reason: 'tooLarge', message: 'TransactionTooLargeException' } : ok(ops)));
const failed = vi.fn();
const works: Work[] = [
...Array.from({ length: 4 }, (_, i) => ({ group: { ref: `g${i}`, ops: [insert('raw_contacts')] } })),
{ group: { ref: 'big', ops: [insert('raw_contacts'), insert('data'), insert('data')] }, failed },
];
await writer(p).write(works);
expect(failed).toHaveBeenCalledWith('tooLarge', 'TransactionTooLargeException');
expect(p.sent.filter((b) => b.length <= 2).flat()).toHaveLength(4);
});
it('ends the run when the permission is gone and refuses to retry an op outside the account', async () => {
const denied = port(() => ({ ok: false, reason: 'permission', message: 'SecurityException' }));
await expect(writer(denied).write([{ group: { ref: 'g', ops: [insert('raw_contacts')] } }])).rejects.toMatchObject({ outcome: 'permission' });
const scope = port(() => ({ ok: false, reason: 'scope', message: 'not our account' }));
await expect(writer(scope).write([{ group: { ref: 'g', ops: [insert('raw_contacts')] } }])).rejects.toThrow(/not our account/);
});
it("stores a group's state change in the batch that applies it, its retry included, and never without it", async () => {
const store = new StateStore(emptySyncState(), { registryId: 'r', origin: 'o' });
let failures = 1;
const p = port((ops) => (ops.some((op) => op.op === 'assert') && failures-- > 0 ? { ok: false, reason: 'assert', message: 'stale' } : ok(ops)));
const listed = (ref: string) => (next: SyncState) => {
accountOf(next, 'a').groups = [...(accountOf(next, 'a').groups ?? []), ref];
};
const groupOps = (id: number): ProviderOp[] => [
{ op: 'assert', table: 'groups', id, values: { version: 1 } },
{ op: 'update', table: 'groups', id, values: { sourceid: `a/g${id}` } },
];
const plain: Work = { group: { ref: 'c', ops: [insert('raw_contacts'), insert('data')] } };
const retried: Work = { group: { ref: 'a/g1', ops: groupOps(1) }, state: listed('a/g1') };
retried.replan = async () => ({ group: retried.group, state: retried.state });
const w = new BatchWriter(p, { maxOps: 2, maxBytes: 300_000, maxReplans: 3 }, store);
await w.write([plain, retried]);
// The plain group alone, without a state op; the group with its state op, refused; its retry, with the state op.
expect(p.sent.map((b) => b.map((op) => op.op))).toEqual([['insert', 'insert'], ['assert', 'update', 'syncState'], ['assert', 'update', 'syncState']]);
expect(store.committed.accounts.a.groups).toEqual(['a/g1']);
failures = Infinity;
const given: Work = { group: { ref: 'a/g2', ops: groupOps(2) }, state: listed('a/g2'), replan: async () => null };
await w.write([given], store.tail(() => undefined));
expect(p.sent.at(-1)!.map((op) => op.op)).toEqual(['syncState']);
expect(store.committed.accounts.a.groups).toEqual(['a/g1']);
});
it('plans a download again after a failed batch, which may have been committed up to a yield point', async () => {
let failures = 1;
const p = port((ops) => (failures-- > 0 ? { ok: false, reason: 'provider', message: 'failed after a yield point' } : ok(ops)));
const planned: Work = { group: { ref: 'a/1', ops: [{ op: 'update', table: 'events', id: 1, values: { title: 'Planned from the new read' } }] } };
const download: Work = { group: { ref: 'a/1', ops: [insert('data', { stale: 'group' })] }, fresh: true, replan: async () => planned };
const other: Work = { group: { ref: 'a/2', ops: [insert('raw_contacts')] } };
await writer(p).write([download, other]);
expect(p.sent.slice(1)).toEqual([planned.group.ops.map((op) => ({ ...op, yieldAllowed: true })), buildBatch([other.group])]);
});
it('weighs a batch as the Parcel carries it: two bytes per character of its JSON and 300 per op', () => {
const ops: ProviderOp[] = [insert('data', { data1: 'x'.repeat(1000) }), insert('raw_contacts')];
expect(estimateBatchBytes(ops)).toBe(2 * JSON.stringify(ops).length + 2 * 300);
expect(estimateBatchBytes(ops)).toBeGreaterThan(2 * 1000);
});
it('packs batches by that weight', async () => {
const p = port(ok);
const group = (i: number): Work => ({ group: { ref: `g${i}`, ops: [insert('data', { data1: 'x'.repeat(1000) })] } });
await writer(p, { maxBytes: 2 * estimateBatchBytes(group(0).group.ops) }).write([group(0), group(1), group(2)]);
expect(p.sent.map((b) => b.length)).toEqual([2, 1]);
});
it('sends the state op alone after a group that fills a yield window, and with a group that leaves room', async () => {
const tail = () => ({ op: () => ({ op: 'syncState', value: 's' }) as ProviderOp, applied: () => undefined });
const group = (n: number): Work => ({ group: { ref: `g${n}`, ops: Array.from({ length: n }, () => insert('data')) } });
const p = port(ok);
await writer(p, { maxOps: 1000 }).write([group(MAX_OPS_PER_YIELD)], tail());
await writer(p, { maxOps: 1000 }).write([group(MAX_OPS_PER_YIELD - 1)], tail());
expect(p.sent.map((b) => b.length)).toEqual([MAX_OPS_PER_YIELD, 1, MAX_OPS_PER_YIELD]);
});
it('skips groups that only assert', async () => {
const p = port(ok);
await writer(p).write([{ group: { ref: 'g', ops: [{ op: 'assert', table: 'raw_contacts', id: 1, values: { dirty: 0 } }] } }]);
expect(p.sent).toEqual([]);
});
describe('an item too big for one group (a chain)', () => {
const named = (n: string) => insert('data', { n });
const namesOf = (batches: ProviderOp[][]) => batches.map((b) => b.map((op) => (op.op === 'insert' ? op.values.n : op.op)));
const chained = (extra: Partial<Work> = {}): Work => ({
group: { ref: 'big', ops: [named('1')], next: [{ ref: 'big', ops: [named('2')] }, { ref: 'big', ops: [named('3')] }] },
...extra,
});
it('applies its groups in order, each in a batch of its own, between the other works, and counts it after the last', async () => {
const p = port(ok);
const applied: string[] = [];
const store = new StateStore(emptySyncState(), { registryId: 'r', origin: 'o' });
const big = chained({
applied: (results) => void applied.push(`big:${results.length}`),
state: (next) => {
accountOf(next, 'a').stale = ['big'];
},
});
const other = (n: string): Work => ({ group: { ref: n, ops: [named(n)] }, applied: () => void applied.push(n) });
await new BatchWriter(p, { maxOps: 400, maxBytes: 300_000, maxReplans: 3 }, store).write(
[other('a'), big, other('b')],
store.tail(() => undefined),
);
expect(namesOf(p.sent)).toEqual([['a'], ['1'], ['2'], ['3', 'syncState'], ['b', 'syncState']]);
expect(applied).toEqual(['a', 'big:3', 'b']);
expect(store.committed.accounts.a.stale).toEqual(['big']);
});
it('plans the item again from what its first groups wrote when a later one fails, never sending that one again', async () => {
let failures = 1;
const p = port((ops) => (namesOf([ops])[0].includes('2') && failures-- > 0 ? { ok: false, reason: 'assert', message: 'changed' } : ok(ops)));
const rest: Work = { group: { ref: 'big', ops: [named('rest')] } };
await writer(p).write([chained({ replan: async () => rest })]);
expect(namesOf(p.sent)).toEqual([['1'], ['2'], ['rest']]);
});
it('gives the item up when one of its groups is refused for good', async () => {
const p = port((ops) => (namesOf([ops])[0].includes('2') ? { ok: false, reason: 'tooLarge', message: 'TransactionTooLargeException' } : ok(ops)));
const failed = vi.fn();
await writer(p).write([chained({ failed, replan: async () => null })]);
expect(namesOf(p.sent)).toEqual([['1'], ['2']]);
expect(failed).toHaveBeenCalledWith('tooLarge', 'TransactionTooLargeException');
});
});
});
describe('SyncState', () => {
it('round-trips and tolerates missing parts', () => {
const state = emptySyncState();
state.owner = { registryId: 'r', origin: 'https://o' };
state.accounts.a = { collectionsState: 's1', itemsState: 's2', selected: ['a/b'], stale: ['c1'], reconcile: null, groups: ['a/g'] };
state.deviceZone = 'Europe/Berlin';
expect(parseSyncState(serializeSyncState(state))).toEqual({ state, readable: true });
expect(parseSyncState(JSON.stringify({ v: 1, accounts: { a: { itemsState: 'x' } } })).state.accounts.a).toEqual({
collectionsState: null,
itemsState: 'x',
selected: [],
stale: [],
reconcile: null,
});
});
it('starts over when the blob is unreadable or of another version', () => {
expect(parseSyncState('{nope').readable).toBe(false);
expect(parseSyncState(JSON.stringify({ v: 2, accounts: {} })).readable).toBe(false);
expect(parseSyncState(null)).toEqual({ state: emptySyncState(), readable: true });
expect(parseSyncState('')).toEqual({ state: emptySyncState(), readable: true });
});
it('adopts a proposed state only after its batch applied', () => {
const store = new StateStore(emptySyncState(), { registryId: 'r', origin: 'o' });
const tail = store.tail((next) => {
next.deviceZone = 'UTC';
});
const op = tail.op();
expect(store.committed.deviceZone).toBeUndefined();
expect(JSON.parse((op as { value: string }).value)).toMatchObject({ deviceZone: 'UTC', owner: { registryId: 'r' } });
tail.applied();
expect(store.committed.deviceZone).toBe('UTC');
});
});
describe('run lock', () => {
it('serialises holders and hands on the turn of a waiter that gave up', async () => {
const order: string[] = [];
const first = await acquireLock('k');
const second = acquireLock('k', 10);
const third = acquireLock('k');
expect(await second).toBeNull();
first!();
const release = await third;
order.push('third');
release!();
expect(order).toEqual(['third']);
const again = await acquireLock('k', 10);
expect(again).not.toBeNull();
again!();
});
});
describe('poison markers', () => {
it('backs off an hour, doubling to a day, while the item stays the same', () => {
const first = nextMarker(null, 'fp', { type: 'invalidPatch' }, 0, { firstMs: 3_600_000, maxMs: 86_400_000 });
const second = nextMarker(first, 'fp', { type: 'invalidPatch' }, 0, { firstMs: 3_600_000, maxMs: 86_400_000 });
let marker = second;
for (let i = 0; i < 10; i++) marker = nextMarker(marker, 'fp', { type: 'invalidPatch' }, 0, { firstMs: 3_600_000, maxMs: 86_400_000 });
expect([first.until, second.until, marker.until]).toEqual([3_600_000, 7_200_000, 86_400_000]);
expect(nextMarker(second, 'changed', { type: 'invalidPatch' }, 0, { firstMs: 1, maxMs: 10 }).n).toBe(1);
expect(isBackedOff(first, 'fp', 1)).toBe(true);
expect(isBackedOff(first, 'other', 1)).toBe(false);
expect(isBackedOff(first, 'fp', 3_600_001)).toBe(false);
});
});
describe('JMAP side', () => {
it('classifies SetErrors', () => {
expect(classifySetError({ type: 'notFound' }, 'update')).toBe('notFound');
expect(classifySetError({ type: 'forbidden' }, 'destroy')).toBe('forbidden');
expect(classifySetError({ type: 'invalidProperties', properties: ['uid'] }, 'create')).toBe('uidExists');
expect(classifySetError({ type: 'invalidProperties', properties: ['uid'] }, 'update')).toBe('poison');
expect(classifySetError({ type: 'overQuota' }, 'create')).toBe('poison');
expect(existingIdFromUidError({ type: 'invalidProperties', description: 'Contact with UID u1 already exists with id c5.' })).toBe('c5');
expect(existingIdFromUidError({ type: 'invalidProperties', description: 'An event with UID u1 already exists.' })).toBeNull();
});
it('pages /query by position until the total, with overlapping pages', async () => {
const server = new FakeJmapServer();
server.addAccount('a', { name: 'alice@example.com' });
const book = server.addAddressBook('a', { name: 'Personal' });
for (let i = 0; i < 30; i++) server.addCard('a', { uid: `u${i}` });
server.setLimits({ maxQueryResults: 8 });
const caller = new JmapCaller(server.port(), () => 0);
const ids = await caller.queryAll('ContactCard', 'a', { inAddressBook: book });
expect(ids).toHaveLength(30);
expect(server.calls('ContactCard/query').length).toBeGreaterThan(3);
});
it('looks uids up in one query and maps them back', async () => {
const server = new FakeJmapServer();
server.addAccount('a', { name: 'alice@example.com' });
const book = server.addAddressBook('a', { name: 'Personal' });
const other = server.addAddressBook('a', { name: 'Other' });
const c1 = server.addCard('a', { uid: 'u1', addressBookIds: { [book]: true } });
server.addCard('a', { uid: 'u2', addressBookIds: { [other]: true } });
const caller = new JmapCaller(server.port(), () => 0);
const found = await caller.lookupUids('ContactCard', 'a', ['u1', 'u2', 'u3'], book);
expect([...found]).toEqual([['u1', [c1]]]);
});
it('names the accounts with a capability, the primary first', () => {
const server = new FakeJmapServer();
server.addAccount('team', { name: 'Team', isPersonal: false, capabilities: ['contacts'] });
server.addAccount('a', { name: 'alice@example.com' });
const session = server.port().session();
expect(accountsWithCapability(session, JMAP_CONTACTS).map((a) => [a.id, a.primary, a.personal])).toEqual([
['a', true, true],
['team', false, false],
]);
expect(accountsWithCapability(session, JMAP_CALENDARS).map((a) => a.id)).toEqual(['a']);
});
it("counts only the capability's primary account as personal, like the app's chooser", () => {
const server = new FakeJmapServer();
server.addAccount('a', { name: 'alice@example.com' });
// Another account the server calls personal (a second mailbox of the same login, say).
server.addAccount('home', { name: 'alice-home@example.com', isPersonal: true });
const session = server.port().session();
expect(accountsWithCapability(session, JMAP_CONTACTS).map((a) => [a.id, a.primary, a.personal])).toEqual([
['a', true, true],
['home', false, false],
]);
});
it('takes the login as the owner address and survives a server without identities', async () => {
const server = new FakeJmapServer();
server.addAccount('a', { name: 'Alice@Example.com' });
const addresses = await calendarAddresses(new JmapCaller(server.port(), () => 0), 'fallback');
expect(addresses).toEqual({ ownerAccount: 'alice@example.com', selfAddresses: ['alice@example.com'] });
});
it('rebuilds the connection once when the credentials are rejected', async () => {
const server = new FakeJmapServer();
server.addAccount('a', { name: 'alice@example.com' });
const load = vi.fn(async () => ({ port: server.port(), origin: 'o' }));
const connection = await openWithAuthRebuild(load);
server.failNextRequest('auth');
await connection.port.request([['Core/echo', {}, '0']], ['urn:ietf:params:jmap:core']);
expect(load).toHaveBeenCalledTimes(2);
server.failNextRequest('auth');
await expect(connection.port.request([['Core/echo', {}, '0']], ['urn:ietf:params:jmap:core'])).rejects.toMatchObject({ name: 'AuthenticationError' });
expect(load).toHaveBeenCalledTimes(2);
});
it('opens with a second client when the first has no session, and gives up after that', async () => {
const connection = { port: new FakeJmapServer().port(), origin: 'o' };
const loads = [null, connection];
await expect(openWithAuthRebuild(async () => loads.shift() ?? null)).resolves.toMatchObject({ origin: 'o' });
expect(loads).toEqual([]);
const rejected = Object.assign(new Error('401'), { name: 'AuthenticationError' });
await expect(openWithAuthRebuild(async () => { throw rejected; })).rejects.toMatchObject({ name: 'AuthenticationError' });
await expect(openWithAuthRebuild(async () => null)).rejects.toMatchObject({ name: 'AuthenticationError' });
const offline = Object.assign(new Error('offline'), { name: 'NetworkError' });
await expect(openWithAuthRebuild(async () => { throw offline; })).rejects.toBe(offline);
});
});
describe('selection defaults', () => {
it("syncs the personal account's collections, not shared ones nor the Trusted Senders book, unless chosen", () => {
expect(collectionDefaultOn(true, 'com.android.contacts', 'Personal')).toBe(true);
expect(collectionDefaultOn(true, 'com.android.contacts', 'Trusted Senders')).toBe(false);
expect(collectionDefaultOn(true, 'com.android.calendar', 'Trusted Senders')).toBe(true);
expect(collectionDefaultOn(false, 'com.android.contacts', 'Team')).toBe(false);
expect(collectionDefaultOn(true, 'com.android.calendar', null)).toBe(true);
const personal = { id: 'a', personal: true };
const trusted = { id: 'ab9', name: 'Trusted Senders' };
expect(isCollectionSelected({}, personal, 'com.android.contacts', trusted)).toBe(false);
expect(isCollectionSelected({ 'a/ab9': true }, personal, 'com.android.contacts', trusted)).toBe(true);
expect(isCollectionSelected({ 'a/ab1': false }, personal, 'com.android.contacts', { id: 'ab1', name: 'Personal' })).toBe(false);
expect(isCollectionSelected({ 't/c1': true }, { id: 't', personal: false }, 'com.android.calendar', { id: 'c1' })).toBe(true);
});
});
describe('outcomes', () => {
it('maps failures to report outcomes', () => {
const named = (name: string, extra: object = {}) => Object.assign(new Error(name), { name, ...extra });
expect(classifyFailure(new StopRun('deadline'), 0)).toMatchObject({ outcome: 'cancelled', moreRecordsToGet: true });
expect(classifyFailure(new RunAbort('safetyAbort', 'x'), 0)).toMatchObject({ outcome: 'safetyAbort' });
expect(classifyFailure(new StateMismatch('a'), 0)).toMatchObject({ outcome: 'io', noProgress: true });
expect(classifyFailure(named('AuthenticationError'), 0)).toMatchObject({ outcome: 'auth', authProblem: true });
expect(classifyFailure(named('NetworkError'), 0)).toMatchObject({ outcome: 'io' });
expect(classifyFailure(named('RequestTimeoutError'), 0)).toMatchObject({ outcome: 'io' });
expect(classifyFailure(named('RateLimitError', { retryAfterMs: 5000 }), 10_000)).toMatchObject({ outcome: 'io', delayUntil: 15 });
expect(classifyFailure(new JMAPMethodError('serverFail'), 0)).toMatchObject({ outcome: 'io' });
expect(classifyFailure(new Error('JMAP request failed: 503 - busy'), 0)).toMatchObject({ outcome: 'io' });
expect(
classifyFailure(new Error('JMAP request failed: 400 - {"type":"urn:ietf:params:jmap:error:limit","limit":"maxCallsInRequest"}'), 0),
).toMatchObject({ outcome: 'internal' });
expect(classifyFailure(Object.assign(new Error('denied'), { code: 'permission' }), 0)).toMatchObject({ outcome: 'permission' });
expect(classifyFailure(new TypeError('x is undefined'), 0)).toMatchObject({ outcome: 'internal' });
});
});
describe('the status a run leaves for the settings', () => {
const status = (over: Partial<RunStatus> = {}): RunStatus => ({ at: 2_000, outcome: 'ok', durationMs: 50, conflicts: 0, itemErrors: 0, stats: emptyStats(), ...over });
const withStats = (edit: (stats: RunStats) => void) => {
const stats = emptyStats();
edit(stats);
return status({ stats });
};
const reported = status({ at: 1_000, durationMs: 900, conflicts: 1, itemErrors: 2, message: 'invitations not sent', stats: { ...emptyStats(), skipped: 2 } });
it('keeps what the run before reported through a run that did nothing, moving the time on', () => {
expect(isQuietRun(status())).toBe(true);
expect(statusToRecord(status(), reported)).toEqual({ ...reported, at: 2_000 });
expect(statusToRecord(status(), undefined)).toEqual(status());
});
it('shows a failure followed by a run that did nothing as recovered, with what the failed run reported', () => {
const failed = { ...reported, outcome: 'io' as const, message: 'Could not reach the server' };
const recorded = statusToRecord(status(), failed);
expect(recorded).toEqual({ at: 2_000, outcome: 'ok', durationMs: 900, conflicts: 1, itemErrors: 2, stats: reported.stats });
});
it('replaces it with a run that wrote, skipped or reported anything, or failed', () => {
const busy = [
withStats((s) => (s.downloaded.created = 1)),
withStats((s) => (s.downloaded.deleted = 1)),
withStats((s) => (s.uploaded.updated = 1)),
withStats((s) => (s.skipped = 1)),
status({ conflicts: 1 }),
status({ itemErrors: 1 }),
status({ message: 'invitations not sent' }),
status({ outcome: 'io', message: 'Could not reach the server' }),
status({ outcome: 'cancelled' }),
status({ outcome: 'disabled', message: 'Sync is off for this account' }),
];
for (const run of busy) {
expect(isQuietRun(run)).toBe(false);
expect(statusToRecord(run, reported)).toBe(run);
}
// Items only looked at are no work.
expect(isQuietRun(withStats((s) => (s.entries = 3)))).toBe(true);
});
});
@@ -0,0 +1,77 @@
// Objects our uploads created. The stored state stays where the download left
// it, and `/changes` omits an object created and destroyed after its
// `sinceState` (Stalwart does, RFC 8620 allows it): such an object must still
// leave the device when another client destroys it before the next download.
import { describe, expect, it } from 'vitest';
import { calendarPlanner } from '../../calendar/planner';
import { contactsPlanner } from '../../contacts/planner';
import { addDeviceContact, addServerCards, CALENDAR_AUTHORITY, createHarness, type Harness } from './harness';
function real(): Harness {
const h = createHarness();
h.deps.planners = { contacts: contactsPlanner, calendar: calendarPlanner };
return h;
}
describe('device sync engine: objects our uploads created', () => {
it('removes a device-created contact that another client destroyed before the next download', async () => {
const h = real();
addServerCards(h, ['Ada Lovelace']);
await h.run();
const id = addDeviceContact(h, 'Hedy Lamarr');
expect((await h.run()).stats.uploaded.created).toBe(1);
const hedy = h.contacts().find((c) => c.id === id)!.sourceId!.split('/')[1];
h.server.serverDestroy('ContactCard', 'a', hedy);
const report = await h.run();
expect(report).toMatchObject({ outcome: 'ok', stats: { downloaded: { deleted: 1 } } });
expect(h.contacts().map((c) => c.name)).toEqual(['Ada Lovelace']);
expect(h.state()?.accounts.a.created ?? []).toEqual([]);
});
it('removes a device-created event that another client destroyed before the next download', async () => {
const h = real();
await h.run(CALENDAR_AUTHORITY);
const rowId = Number(h.device.rows('calendars')[0]._id);
const id = h.device.user.insertEvent(rowId, { title: 'Dentist', dtstart: Date.UTC(2026, 9, 1, 8), dtend: Date.UTC(2026, 9, 1, 9), eventTimezone: 'Europe/Berlin' });
expect((await h.run(CALENDAR_AUTHORITY)).stats.uploaded.created).toBe(1);
const created = String(h.events().find((e) => Number(e._id) === id)!._sync_id).split('/')[1];
h.server.serverDestroy('CalendarEvent', 'a', created);
const report = await h.run(CALENDAR_AUTHORITY);
expect(report).toMatchObject({ outcome: 'ok', stats: { downloaded: { deleted: 1 } } });
expect(h.events()).toEqual([]);
});
it('fetches a created object once, and writes nothing when it is unchanged', async () => {
const h = real();
await h.run();
addDeviceContact(h, 'Hedy Lamarr');
await h.run();
expect(h.state()?.accounts.a.created).toHaveLength(1);
const before = h.batches.log.length;
expect((await h.run()).outcome).toBe('ok');
expect(h.state()?.accounts.a.created).toEqual([]);
expect(h.batches.log.slice(before).flat().filter((op) => op.op !== 'syncState')).toEqual([]);
expect(h.contacts().map((c) => c.name)).toEqual(['Hedy Lamarr']);
});
it('turns an upload-only sync into a full one while a created object waits to be checked', async () => {
const h = real();
await h.run();
const id = addDeviceContact(h, 'Hedy Lamarr');
await h.run();
const hedy = h.contacts().find((c) => c.id === id)!.sourceId!.split('/')[1];
h.server.serverDestroy('ContactCard', 'a', hedy);
// Android's upload sync 30 s after an app wrote anything, with nothing dirty in this account.
expect((await h.run(undefined, { upload: true })).outcome).toBe('ok');
expect(h.contacts()).toEqual([]);
});
});
@@ -0,0 +1,978 @@
// The engine with the real contacts and calendar planners (not the toy
// ones): the contract between them holds end to end. Mapping details are
// the planners' own tests; these check what the engine guarantees.
import { describe, expect, it } from 'vitest';
import { Attendees, Data, Events, MimeType } from '../../android-columns';
import { calendarPlanner } from '../../calendar/planner';
import { contactsPlanner } from '../../contacts/planner';
import type { Row } from '../../types';
import {
addDeviceContact,
addServerCards,
CALENDAR_AUTHORITY,
CONTACTS_AUTHORITY,
createHarness,
renameDeviceContact,
rowWrites,
RUN_BUDGET,
type Harness,
} from './harness';
function real(options: Parameters<typeof createHarness>[0] = {}): Harness {
const h = createHarness(options);
h.deps.planners = { contacts: contactsPlanner, calendar: calendarPlanner };
return h;
}
/** The event columns Etar copies into the row it inserts when it moves an event or ends its recurrence. */
const ETAR_COPY = [
Events.TITLE, Events.DESCRIPTION, Events.EVENT_LOCATION, Events.STATUS, Events.AVAILABILITY, Events.ACCESS_LEVEL,
Events.EVENT_COLOR, Events.DTSTART, Events.DTEND, Events.DURATION, Events.EVENT_TIMEZONE, Events.ALL_DAY, Events.RRULE,
];
/** Etar's move or "series to single event": the row is deleted and a copy (with its attendees) inserted. */
function etarReinsert(h: Harness, old: Row, calendarRowId: number, changes: Row = {}): number {
const values: Row = {};
for (const c of ETAR_COPY) if (old[c] !== undefined) values[c] = old[c];
const attendees = h.device.rows('attendees')
.filter((a) => Number(a.event_id) === Number(old._id))
.map((a) => ({
[Attendees.ATTENDEE_EMAIL]: a[Attendees.ATTENDEE_EMAIL],
[Attendees.ATTENDEE_NAME]: a[Attendees.ATTENDEE_NAME],
[Attendees.ATTENDEE_RELATIONSHIP]: a[Attendees.ATTENDEE_RELATIONSHIP],
[Attendees.ATTENDEE_TYPE]: a[Attendees.ATTENDEE_TYPE],
[Attendees.ATTENDEE_STATUS]: a[Attendees.ATTENDEE_STATUS],
}));
h.device.user.deleteEvent(Number(old._id));
return h.device.user.insertEvent(calendarRowId, { ...values, ...changes }, { attendees });
}
/** Masters (no ORIGINAL_*) that share a `_SYNC_ID`: a split the next run would resolve by destroy + create. */
function sharedIdentities(h: Harness): string[] {
const masters = h.events().filter((e) => e[Events.ORIGINAL_ID] === null && e[Events.ORIGINAL_SYNC_ID] === null && e._sync_id);
const ids = masters.map((e) => String(e._sync_id));
return ids.filter((id, i) => ids.indexOf(id) !== i);
}
/** A device edit of the display name only, as AOSP Contacts makes it (the provider fills the components). */
function retypeName(h: Harness, rawContactId: number, name: string): void {
const row = h.nameDataRow(rawContactId)!;
h.device.user.updateData(Number(row._id), { [Data.DATA1]: name });
}
function serverUids(h: Harness): string[] {
return h.server.all('ContactCard', 'a').map((c) => String(c.uid));
}
describe('device sync engine with the real planners', () => {
it('syncs contacts both ways and writes nothing for its own echo', async () => {
const h = real();
const [, grace] = addServerCards(h, ['Ada Lovelace', 'Grace Hopper']);
expect((await h.run()).outcome).toBe('ok');
expect(h.contacts().map((c) => c.name).sort()).toEqual(['Ada Lovelace', 'Grace Hopper']);
addDeviceContact(h, 'Hedy Lamarr');
h.device.user.deleteContact(h.contactNamed('Grace Hopper')!.id);
const up = await h.run();
expect(up).toMatchObject({ outcome: 'ok', itemErrors: [] });
expect(up.stats.uploaded).toMatchObject({ created: 1, deleted: 1 });
expect(h.server.get('ContactCard', 'a', grace)).toBeUndefined();
expect(h.contacts().every((c) => c.sourceId && !c.dirty)).toBe(true);
expect(new Set(serverUids(h)).size).toBe(2);
const before = h.batches.log.length;
const echo = await h.run();
expect(echo.outcome).toBe('ok');
expect(rowWrites(h.batches.log.slice(before))).toEqual([]);
});
it('converges with the real contacts planner after a crash at any checkpoint', async () => {
const setup = async () => {
const h = real();
addServerCards(h, ['Ada Lovelace', 'Grace Hopper', 'Linus Torvalds']);
await h.run();
retypeName(h, h.contactNamed('Grace Hopper')!.id, 'Grace B. Hopper');
addDeviceContact(h, 'Hedy Lamarr');
h.device.user.deleteContact(h.contactNamed('Linus Torvalds')!.id);
h.server.serverUpdate('ContactCard', 'a', h.contactNamed('Ada Lovelace')!.sourceId!.split('/')[1], { 'name/full': 'Ada King' });
addServerCards(h, ['Margaret Hamilton']);
h.checkpoints.count = 0;
return h;
};
const probe = await setup();
await probe.run();
const final = probe.contacts().map((c) => c.name).sort();
const finalServer = probe.serverNames();
const checkpoints = probe.checkpoints.count;
for (let n = 1; n <= checkpoints; n++) {
const h = await setup();
h.checkpoints.crashAt = n;
await h.run();
h.checkpoints.crashAt = null;
const report = await h.run();
expect(report.outcome, `crash at checkpoint ${n}`).toBe('ok');
expect(h.contacts().map((c) => c.name).sort(), `device after crash ${n}`).toEqual(final);
expect(h.serverNames(), `server after crash ${n}`).toEqual(finalServer);
expect(h.contacts().every((c) => !c.dirty && !c.deleted)).toBe(true);
expect(new Set(serverUids(h)).size).toBe(serverUids(h).length);
}
});
it('adopts a lost create by uid with the real contacts planner', async () => {
const h = real();
addServerCards(h, ['Ada Lovelace']);
await h.run();
h.server.setUidIndexLag(3);
const id = addDeviceContact(h, 'Hedy Lamarr');
h.server.applyThenLoseResponse({ match: 'ContactCard/set' });
expect((await h.run()).outcome).toBe('io');
expect((await h.run()).outcome).toBe('ok');
expect(h.serverNames()).toEqual(['Ada Lovelace', 'Hedy Lamarr']);
expect(h.contacts()).toHaveLength(2);
expect(h.contacts().find((c) => c.id === id)).toMatchObject({ dirty: false, sourceId: expect.stringMatching(/^a\//) });
expect(h.device.rows('data').filter((d) => d.mimetype === MimeType.STRUCTURED_NAME)).toHaveLength(2);
});
it("follows a group's members when only the group card changed on the server", async () => {
const h = real();
const [ada, grace] = addServerCards(h, ['Ada Lovelace', 'Grace Hopper']);
const uid = (id: string) => h.server.get('ContactCard', 'a', id)!.uid as string;
const group = h.server.addCard('a', { uid: 'g1', kind: 'group', name: { full: 'Friends' }, members: { [uid(ada)]: true } });
const members = () =>
h.device
.rows('data')
.filter((d) => d.mimetype === MimeType.GROUP_MEMBERSHIP)
.map((d) => h.contacts().find((c) => c.id === Number(d.raw_contact_id))?.name)
.sort();
expect((await h.run()).outcome).toBe('ok');
expect(members()).toEqual(['Ada Lovelace']);
h.server.serverUpdate('ContactCard', 'a', group, { members: { [uid(grace)]: true } });
expect((await h.run()).outcome).toBe('ok');
expect(members()).toEqual(['Grace Hopper']);
expect(h.contacts().every((c) => !c.dirty)).toBe(true);
});
describe('a membership edit on the device', () => {
async function adaJoinsFriends() {
const h = real();
const [ada] = addServerCards(h, ['Ada Lovelace']);
const group = h.server.addCard('a', { uid: 'g1', kind: 'group', name: { full: 'Friends' }, members: {} });
await h.run();
const contact = h.contactNamed('Ada Lovelace')!;
h.device.user.addToGroup(contact.id, Number(h.device.rows('groups')[0]._id));
return { h, ada, group, contact };
}
it('leaves the contact clean once the group card shows it', async () => {
const { h, ada, group, contact } = await adaJoinsFriends();
expect((await h.run()).outcome).toBe('ok');
const uid = h.server.get('ContactCard', 'a', ada)!.uid as string;
expect(h.server.get('ContactCard', 'a', group)!.members).toEqual({ [uid]: true });
expect(h.contacts().find((c) => c.id === contact.id)).toMatchObject({ dirty: false });
});
it('leaves nothing waiting for the teardown once it is uploaded', async () => {
const { h, ada, group } = await adaJoinsFriends();
expect(await h.teardown(CONTACTS_AUTHORITY)).toEqual({ pending: 0 });
const uid = h.server.get('ContactCard', 'a', ada)!.uid as string;
expect(h.server.get('ContactCard', 'a', group)!.members).toEqual({ [uid]: true });
});
it('waits with a membership edit while the group card is stale, instead of resending its members', async () => {
const { h, ada, group } = await adaJoinsFriends();
const [bob] = addServerCards(h, ['Bob']);
const bobUid = h.server.get('ContactCard', 'a', bob)!.uid as string;
// Another client adds Bob; this device cannot store the new version of the group this run.
h.server.serverUpdate('ContactCard', 'a', group, { members: { [bobUid]: true } });
h.deps.planners = {
...h.deps.planners,
contacts: {
...contactsPlanner,
planGroupDownload: (card, local, ctx) => {
if (card.id === group) throw new Error('cannot store this group now');
return contactsPlanner.planGroupDownload(card, local, ctx);
},
},
};
await h.run();
const adaUid = h.server.get('ContactCard', 'a', ada)!.uid as string;
expect(h.server.get('ContactCard', 'a', group)!.members).toEqual({ [bobUid]: true });
expect(h.contacts().find((c) => c.name === 'Ada Lovelace')).toMatchObject({ dirty: true });
h.deps.planners = { contacts: contactsPlanner, calendar: calendarPlanner };
expect((await h.run()).outcome).toBe('ok');
expect(h.server.get('ContactCard', 'a', group)!.members).toEqual({ [bobUid]: true, [adaUid]: true });
expect(h.contacts().find((c) => c.name === 'Ada Lovelace')).toMatchObject({ dirty: false });
});
describe('an upload that downloads nothing first', () => {
/** Ada in Friends (and Grace); Ada's name edited in place (AOSP); nothing new on the server. */
async function adaRenamedInFriends() {
const h = real();
const [ada, grace] = addServerCards(h, ['Ada Lovelace', 'Grace Hopper']);
const uid = h.server.get('ContactCard', 'a', ada)!.uid as string;
h.server.addCard('a', { uid: 'g1', kind: 'group', name: { full: 'Friends' }, members: { [uid]: true } });
expect((await h.run()).outcome).toBe('ok');
const row = h.contactNamed('Ada Lovelace')!.id;
h.device.user.updateData(Number(h.nameDataRow(row)!._id), { [Data.DATA1]: 'Ada King' });
const memberships = () => h.device.rows('data').filter((d) => Number(d.raw_contact_id) === row && d.mimetype === MimeType.GROUP_MEMBERSHIP);
return { h, ada, grace, memberships };
}
it("keeps the uploaded contact's memberships in a sync run", async () => {
const { h, ada, memberships } = await adaRenamedInFriends();
expect((await h.run()).outcome).toBe('ok');
expect(h.server.get('ContactCard', 'a', ada)).toMatchObject({ name: { full: 'Ada King' } });
expect(memberships()).toHaveLength(1);
});
it("keeps the uploaded contact's memberships in a teardown's upload", async () => {
const { h, ada, grace, memberships } = await adaRenamedInFriends();
// Grace's edit cannot go up, so the teardown keeps every row and asks.
renameDeviceContact(h, h.contactNamed('Grace Hopper')!.id, 'Grace B. Hopper');
h.server.setErrorFor('ContactCard', 'a', grace, { type: 'tooLarge' });
expect(await h.teardown(CONTACTS_AUTHORITY)).toEqual({ pending: 1 });
expect(h.server.get('ContactCard', 'a', ada)).toMatchObject({ name: { full: 'Ada King' } });
expect(memberships()).toHaveLength(1);
});
});
it('uploads a removal from a group in a run that downloads no contact', async () => {
const h = real();
// A keyed phone row shows the editor works in place, so a missing membership row is a removal.
h.server.addCard('a', { uid: 'ada-uid', name: { full: 'Ada Lovelace' }, phones: { p1: { '@type': 'Phone', number: '+1 111' } } });
const group = h.server.addCard('a', { uid: 'g1', kind: 'group', name: { full: 'Friends' }, members: { 'ada-uid': true } });
await h.run();
const contact = h.contactNamed('Ada Lovelace')!;
const membership = h.device.rows('data').find((d) => Number(d.raw_contact_id) === contact.id && d.mimetype === MimeType.GROUP_MEMBERSHIP)!;
h.device.user.deleteData(Number(membership._id));
// The upload sync Android requests after the edit: the server has nothing new.
expect((await h.run(CONTACTS_AUTHORITY, { upload: true })).outcome).toBe('ok');
expect(h.server.get('ContactCard', 'a', group)!.members ?? {}).toEqual({});
expect(h.contacts().find((c) => c.id === contact.id)).toMatchObject({ dirty: false });
expect(h.device.rows('data').filter((d) => d.mimetype === MimeType.GROUP_MEMBERSHIP)).toEqual([]);
});
});
describe('a batch the provider committed up to a yield point before it failed', () => {
const alert = { al1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' }, action: 'display' } };
it("never inserts a clean event's new reminder twice", async () => {
const h = real();
const event = (uid: string, title: string) =>
h.server.addEvent('a', { uid, title, start: '2026-10-06T12:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', calendarIds: { [h.calendar]: true } });
const lunch = event('lunch-uid', 'Lunch');
const retro = event('retro-uid', 'Retro');
await h.run(CALENDAR_AUTHORITY);
// Only an alert for Lunch (its row's columns stay), a new title for Retro: one download batch, two groups.
h.server.serverUpdate('CalendarEvent', 'a', lunch, { alerts: alert });
h.server.serverUpdate('CalendarEvent', 'a', retro, { title: 'Retrospective' });
h.device.partialCommitNextBatch(1, 'assert', (ops) => ops.some((op) => op.op === 'insert' && op.table === 'reminders'));
expect((await h.run(CALENDAR_AUTHORITY)).outcome).toBe('ok');
const row = h.events().find((e) => e.title === 'Lunch')!;
expect(h.device.rows('reminders').filter((r) => Number(r.event_id) === Number(row._id)).map((r) => r.minutes)).toEqual([15]);
expect(h.events().map((e) => e.title).sort()).toEqual(['Lunch', 'Retrospective']);
});
});
describe('a deletion decided on the server version', () => {
/** The contacts planner as fixed for data finding 4: a deleted contact's shadow follows the server. */
function refreshingDeletedShadows(h: Harness): void {
h.deps.planners = {
...h.deps.planners,
contacts: {
...contactsPlanner,
planDownload: (card, local, ctx) => {
if (!local?.deleted || !local.sourceId) return contactsPlanner.planDownload(card, local, ctx);
const shadow = JSON.stringify({ ...card, '~memberOf': local.shadow?.['~memberOf'] ?? [] });
const ops = [{ op: 'update' as const, table: 'raw_contacts' as const, id: local.rawContactId, values: { sync2: shadow } }];
return { ops: { ref: local.sourceId, ops }, conflicts: 0, stillDirty: true, effect: 'update' as const, writes: 1 };
},
},
};
}
it('in a teardown, when the server changed since the last sync', async () => {
const h = real();
const archive = h.server.addAddressBook('a', { name: 'Archive' });
h.prefs.contactsSelection[`a/${archive}`] = false;
const [ada] = addServerCards(h, ['Ada Lovelace']);
await h.run();
refreshingDeletedShadows(h);
h.device.user.deleteContact(h.contactNamed('Ada Lovelace')!.id);
// Meanwhile another client files Ada in Archive too.
h.server.serverUpdate('ContactCard', 'a', ada, { [`addressBookIds/${archive}`]: true });
expect(await h.teardown(CONTACTS_AUTHORITY)).toEqual({ pending: 0 });
expect(h.server.get('ContactCard', 'a', ada)).toMatchObject({ addressBookIds: { [archive]: true } });
expect((h.server.get('ContactCard', 'a', ada)!.addressBookIds as object)).not.toHaveProperty(h.book);
});
it('lets the planner see the server version of a contact deleted on the device and filed away meanwhile', async () => {
const h = real();
const archive = h.server.addAddressBook('a', { name: 'Archive' });
h.prefs.contactsSelection[`a/${archive}`] = false;
const [ada] = addServerCards(h, ['Ada Lovelace']);
await h.run();
refreshingDeletedShadows(h);
const planned: string[][] = [];
const contacts = h.deps.planners.contacts;
h.deps.planners = {
...h.deps.planners,
contacts: {
...contacts,
planUpload: (local, ctx) => {
if (local.deleted) planned.push(Object.keys(local.shadow?.addressBookIds ?? {}));
return contacts.planUpload(local, ctx);
},
},
};
h.device.user.deleteContact(h.contactNamed('Ada Lovelace')!.id);
// Another client files Ada in Archive only: she left every synced book.
h.server.serverUpdate('ContactCard', 'a', ada, { addressBookIds: { [archive]: true } });
expect((await h.run()).outcome).toBe('ok');
expect(planned).toEqual([[archive]]);
});
it('leaves a contact deleted on the device that another client moved out of every synced book', async () => {
const h = real();
const archive = h.server.addAddressBook('a', { name: 'Archive' });
h.prefs.contactsSelection[`a/${archive}`] = false;
const [ada] = addServerCards(h, ['Ada Lovelace', 'Grace Hopper']);
await h.run();
h.device.user.deleteContact(h.contactNamed('Ada Lovelace')!.id);
// Another client files Ada in Archive only before the deletion reached the server.
h.server.serverUpdate('ContactCard', 'a', ada, { addressBookIds: { [archive]: true } });
const report = await h.run();
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 0, updated: 0, deleted: 0 } } });
expect(h.server.get('ContactCard', 'a', ada)).toMatchObject({ addressBookIds: { [archive]: true } });
expect(h.contacts().map((c) => c.name)).toEqual(['Grace Hopper']);
});
/** Lunch synced, deleted on the device, then filed by another client in Archive, which this device does not sync. */
async function lunchDeletedAndFiledAway() {
const h = real();
const archive = h.server.addCalendar('a', { name: 'Archive' });
h.prefs.calendarSelection[`a/${archive}`] = false;
const lunch = h.server.addEvent('a', { uid: 'lunch-uid', title: 'Lunch', start: '2026-10-06T12:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', calendarIds: { [h.calendar]: true } });
await h.run(CALENDAR_AUTHORITY);
h.device.user.deleteEvent(Number(h.events()[0]._id));
h.server.serverUpdate('CalendarEvent', 'a', lunch, { calendarIds: { [archive]: true } });
return { h, archive, lunch };
}
it('leaves an event deleted on the device that another client moved out of every synced calendar', async () => {
const { h, archive, lunch } = await lunchDeletedAndFiledAway();
const report = await h.run(CALENDAR_AUTHORITY);
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 0, updated: 0, deleted: 0 } } });
expect(h.server.get('CalendarEvent', 'a', lunch)).toMatchObject({ calendarIds: { [archive]: true } });
expect(h.events()).toEqual([]);
});
it('still uploads the deletion as it was planned when the planner cannot take the server version', async () => {
const { h, lunch } = await lunchDeletedAndFiledAway();
h.deps.planners = {
...h.deps.planners,
calendar: {
...calendarPlanner,
planDownload: (event, local, ctx) => {
if (local?.deleted) throw new Error('cannot take it');
return calendarPlanner.planDownload(event, local, ctx);
},
},
};
const report = await h.run(CALENDAR_AUTHORITY);
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { deleted: 1 } } });
expect(h.server.get('CalendarEvent', 'a', lunch)).toBeUndefined();
expect(h.events()).toEqual([]);
});
it('waits in a teardown, with the edits, while no state guards the upload (the first sync never ended)', async () => {
const h = real();
const [ada] = addServerCards(h, ['Ada Lovelace', 'Grace Hopper', ...Array.from({ length: 60 }, (_, i) => `P${i}`)]);
// The first sync (a full reconcile) runs out of time after its first chunk (Ada and Grace): no state yet.
h.checkpoints.onCheckpoint = (n) => {
if (n === 3) h.clock.now += RUN_BUDGET;
};
expect((await h.run()).outcome).toBe('cancelled');
h.checkpoints.onCheckpoint = undefined;
expect(h.state()?.accounts.a.itemsState).toBeNull();
renameDeviceContact(h, h.contactNamed('Ada Lovelace')!.id, 'Ada King');
h.device.user.deleteContact(h.contactNamed('Grace Hopper')!.id);
addDeviceContact(h, 'Hedy Lamarr');
// Meanwhile another client changes Ada.
h.server.serverUpdate('ContactCard', 'a', ada, { 'name/full': 'Ada, Countess of Lovelace' });
expect(await h.teardown(CONTACTS_AUTHORITY)).toEqual({ pending: 2 });
expect(h.serverNames().filter((n) => !/^P\d+$/.test(n))).toEqual(['Ada, Countess of Lovelace', 'Grace Hopper', 'Hedy Lamarr']);
});
});
describe('a device edit the server refuses (forbidden)', () => {
it('puts a contact back in place, keeping what only the device knows', async () => {
const h = real();
const [ada] = addServerCards(h, ['Ada Lovelace']);
await h.run();
const before = h.contactNamed('Ada Lovelace')!;
h.device.user.star(before.id);
renameDeviceContact(h, before.id, 'Ada King');
h.server.setErrorFor('ContactCard', 'a', ada, { type: 'forbidden' });
const report = await h.run();
expect(report.itemErrors).toEqual([expect.objectContaining({ ref: `a/${ada}`, type: 'forbidden' })]);
expect(h.server.get('ContactCard', 'a', ada)).toMatchObject({ name: { full: 'Ada Lovelace' } });
expect(h.contacts()).toEqual([expect.objectContaining({ id: before.id, name: 'Ada Lovelace', dirty: false })]);
expect(h.contacts()[0].row.starred).toBe(1);
});
it('brings a contact back whose deletion the server refuses', async () => {
const h = real();
const [ada] = addServerCards(h, ['Ada Lovelace']);
await h.run();
h.device.user.deleteContact(h.contactNamed('Ada Lovelace')!.id);
h.server.setErrorFor('ContactCard', 'a', ada, { type: 'forbidden' });
const report = await h.run();
expect(report.itemErrors).toEqual([expect.objectContaining({ ref: `a/${ada}`, type: 'forbidden' })]);
expect(h.server.get('ContactCard', 'a', ada)).toBeDefined();
expect(h.contacts()).toEqual([expect.objectContaining({ sourceId: `a/${ada}`, name: 'Ada Lovelace', dirty: false, deleted: false })]);
});
it('puts an event back in place', async () => {
const h = real();
const standup = h.server.addEvent('a', {
uid: 'standup-uid',
title: 'Standup',
start: '2026-09-28T09:00:00',
duration: 'PT15M',
timeZone: 'Europe/Berlin',
calendarIds: { [h.calendar]: true },
});
await h.run(CALENDAR_AUTHORITY);
const row = h.events()[0];
h.device.user.updateEvent(Number(row._id), { [Events.TITLE]: 'Mine', [Events.CUSTOM_APP_PACKAGE]: 'org.example.app' });
h.server.setErrorFor('CalendarEvent', 'a', standup, { type: 'forbidden' });
const report = await h.run(CALENDAR_AUTHORITY);
expect(report.itemErrors).toEqual([expect.objectContaining({ ref: `a/${standup}`, type: 'forbidden' })]);
expect(h.events()).toEqual([
expect.objectContaining({ _id: row._id, title: 'Standup', dirty: 0, [Events.CUSTOM_APP_PACKAGE]: 'org.example.app' }),
]);
});
});
describe('the last run status the settings show', () => {
/** A run that puts back an edit the server refuses: reported once, nothing waits afterwards. */
async function refusedEdit() {
const h = real();
const [ada] = addServerCards(h, ['Ada Lovelace', 'Grace Hopper']);
await h.run();
renameDeviceContact(h, h.contactNamed('Ada Lovelace')!.id, 'Ada King');
h.server.setErrorFor('ContactCard', 'a', ada, { type: 'forbidden' });
h.clock.now += 60_000;
expect((await h.run()).itemErrors).toEqual([expect.objectContaining({ type: 'forbidden' })]);
const status = h.lastStatus()!;
expect(status).toMatchObject({ outcome: 'ok', itemErrors: 1 });
return { h, status };
}
it('keeps what a run reported through an upload sync after it that finds nothing to do', async () => {
const { h, status } = await refusedEdit();
h.clock.now += 2_000;
await h.run(CONTACTS_AUTHORITY, { upload: true });
expect(h.lastStatus()).toEqual({ ...status, at: expect.any(Number) });
expect(h.lastStatus()!.at).toBeGreaterThan(status.at);
});
it('keeps what a run reported through a follow-up sync that finds nothing new on the server', async () => {
const { h, status } = await refusedEdit();
h.clock.now += 2_000;
expect((await h.run()).itemErrors).toEqual([]);
expect(h.lastStatus()).toEqual({ ...status, at: expect.any(Number) });
expect(h.lastStatus()!.at).toBeGreaterThan(status.at);
});
it('replaces it with a run that did something', async () => {
const { h } = await refusedEdit();
addServerCards(h, ['Linus Torvalds']);
await h.run();
expect(h.lastStatus()).toMatchObject({ outcome: 'ok', itemErrors: 0, stats: { downloaded: { created: 1 } } });
});
it('shows a sync that finds nothing to do after a failed one as recovered', async () => {
const h = real();
addServerCards(h, ['Ada Lovelace']);
await h.run();
renameDeviceContact(h, h.contactNamed('Ada Lovelace')!.id, 'Ada King');
h.server.failNextRequest('network', { match: 'ContactCard/set' });
expect((await h.run()).outcome).toBe('io');
expect(h.lastStatus()).toMatchObject({ outcome: 'io' });
// Uploaded by the next sync; the one after it finds nothing to do.
expect((await h.run()).outcome).toBe('ok');
h.clock.now += 2_000;
h.server.failNextRequest('network', { match: 'ContactCard/changes' });
expect((await h.run()).outcome).toBe('io');
expect((await h.run()).outcome).toBe('ok');
expect(h.lastStatus()).toMatchObject({ outcome: 'ok', itemErrors: 0, conflicts: 0 });
expect(h.lastStatus()).not.toHaveProperty('message');
});
});
describe('a create whose target the server deleted after the claim', () => {
it('claims a new contact again for a book that exists', async () => {
const h = real();
const work = h.server.addAddressBook('a', { name: 'Work' });
h.prefs.newContactsAddressBook = `a/${work}`;
await h.run();
const id = addDeviceContact(h, 'Hedy Lamarr');
// The claim names Work, the create is lost; then another client deletes Work.
h.server.failNextRequest('network', { match: 'ContactCard/set' });
expect((await h.run()).outcome).toBe('io');
h.server.serverDestroy('AddressBook', 'a', work);
const report = await h.run();
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 1 } } });
const hedy = h.server.all('ContactCard', 'a').find((c) => (c.name as { full?: string }).full === 'Hedy Lamarr')!;
expect(hedy.addressBookIds).toEqual({ [h.book]: true });
expect(h.contacts().find((c) => c.id === id)).toMatchObject({ sourceId: `a/${hedy.id}`, dirty: false });
expect(h.contacts().find((c) => c.id === id)!.row.sync4).toBeNull();
});
it('keeps a new event whose calendar is gone, without poisoning it, until the user moves it', async () => {
const h = real();
const work = h.server.addCalendar('a', { name: 'Work' });
await h.run(CALENDAR_AUTHORITY);
const rowOf = (calendarId: string) => Number(h.device.rows('calendars').find((c) => c._sync_id === `a/${calendarId}`)!._id);
const workRow = rowOf(work);
const dentist = { title: 'Dentist', dtstart: Date.UTC(2026, 9, 1, 8), dtend: Date.UTC(2026, 9, 1, 9), eventTimezone: 'Europe/Berlin' };
const id = h.device.user.insertEvent(workRow, dentist);
h.server.failNextRequest('network', { match: 'CalendarEvent/set' });
expect((await h.run(CALENDAR_AUTHORITY)).outcome).toBe('io');
h.server.serverDestroy('Calendar', 'a', work);
const report = await h.run(CALENDAR_AUTHORITY);
expect(report.itemErrors).toEqual([expect.objectContaining({ ref: `row:${id}`, type: 'collectionGone' })]);
expect(h.server.all('CalendarEvent', 'a')).toEqual([]);
expect(h.events().find((e) => Number(e._id) === id)).toMatchObject({ title: 'Dentist', sync_data5: null });
// The user moves it to the personal calendar (Etar: delete + insert).
h.device.user.deleteEvent(id);
h.device.user.insertEvent(rowOf(h.calendar), dentist);
expect(await h.run(CALENDAR_AUTHORITY)).toMatchObject({ outcome: 'ok', itemErrors: [] });
expect(h.server.all('CalendarEvent', 'a').map((e) => [e.title, e.calendarIds])).toEqual([['Dentist', { [h.calendar]: true }]]);
expect(h.device.rows('calendars').map((c) => c._sync_id)).toEqual([`a/${h.calendar}`]);
});
});
describe('a device edit of an object the server moved out of every synced collection', () => {
/** Ada with two numbers in the synced book; the device deletes the second in place (AOSP). */
async function adaWithDeletedNumber() {
const h = real();
const archive = h.server.addAddressBook('a', { name: 'Archive' });
h.prefs.contactsSelection[`a/${archive}`] = false;
const ada = h.server.addCard('a', {
uid: 'ada-uid',
name: { full: 'Ada Lovelace' },
phones: { p1: { '@type': 'Phone', number: '+1 111' }, p2: { '@type': 'Phone', number: '+1 222' } },
addressBookIds: { [h.book]: true },
});
await h.run();
const contact = h.contactNamed('Ada Lovelace')!;
const p2 = h.device.rows('data').find((d) => Number(d.raw_contact_id) === contact.id && d[Data.DATA1] === '+1 222')!;
h.device.user.deleteData(Number(p2._id));
// Meanwhile another client files Ada in Archive only and adds a number.
h.server.serverUpdate('ContactCard', 'a', ada, { addressBookIds: { [archive]: true }, 'phones/p3': { '@type': 'Phone', number: '+1 333' } });
return { h, ada, archive };
}
const numbers = (card: Record<string, unknown>) =>
Object.values((card.phones ?? {}) as Record<string, { number: string }>).map((p) => p.number).sort();
it('uploads it against the server version, then drops the rows', async () => {
const { h, ada, archive } = await adaWithDeletedNumber();
const report = await h.run();
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { updated: 1 } } });
const card = h.server.get('ContactCard', 'a', ada)!;
expect(numbers(card)).toEqual(['+1 111', '+1 333']);
expect(card.addressBookIds).toEqual({ [archive]: true });
expect(h.contacts()).toEqual([]);
});
it('does the same when a full reconcile finds the object outside', async () => {
const { h, ada, archive } = await adaWithDeletedNumber();
h.server.truncateChangeLog('a', 'contacts');
const report = await h.run();
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [] });
const card = h.server.get('ContactCard', 'a', ada)!;
expect(numbers(card)).toEqual(['+1 111', '+1 333']);
expect(card.addressBookIds).toEqual({ [archive]: true });
expect(h.contacts()).toEqual([]);
});
it('never uploads an event edit against an outdated shadow', async () => {
const h = real();
const archive = h.server.addCalendar('a', { name: 'Archive' });
h.prefs.calendarSelection[`a/${archive}`] = false;
const series = h.server.addEvent('a', {
uid: 'series-uid',
title: 'Standup',
start: '2026-09-28T09:00:00',
duration: 'PT15M',
timeZone: 'Europe/Berlin',
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'daily' },
recurrenceOverrides: { '2026-09-29T09:00:00': { title: 'Planning' } },
calendarIds: { [h.calendar]: true },
});
await h.run(CALENDAR_AUTHORITY);
const master = h.events().find((e) => e._sync_id === `a/${series}`)!;
const later = Number(master[Events.DTSTART]) + 3_600_000;
// The device moves the series by an hour; another client files it in Archive and changes another instance.
h.device.user.updateEvent(Number(master._id), { [Events.DTSTART]: later });
h.server.serverUpdate('CalendarEvent', 'a', series, {
calendarIds: { [archive]: true },
'recurrenceOverrides/2026-10-01T09:00:00': { title: 'Retro' },
});
expect(await h.run(CALENDAR_AUTHORITY)).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { updated: 1 } } });
const event = h.server.get('CalendarEvent', 'a', series)!;
const titles = Object.values((event.recurrenceOverrides ?? {}) as Record<string, { title?: string }>).map((o) => o.title).sort();
expect(titles).toEqual(['Planning', 'Retro']);
// The move went up against the server's version, and the rows went with the event.
expect(event).toMatchObject({ start: '2026-09-28T10:00:00', calendarIds: { [archive]: true } });
expect(h.events()).toEqual([]);
});
});
it('creates a device event that carries the uid of a synced event (an app copied it) under a fresh uid', async () => {
const h = real();
const standup = h.server.addEvent('a', {
uid: 'standup-uid',
title: 'Standup',
start: '2026-09-28T09:00:00',
duration: 'PT15M',
timeZone: 'Europe/Berlin',
calendarIds: { [h.calendar]: true },
});
await h.run(CALENDAR_AUTHORITY);
const rowId = Number(h.device.rows('calendars')[0]._id);
const copy = h.device.user.insertEvent(rowId, {
title: 'Standup (copy)',
dtstart: Date.UTC(2026, 8, 29, 7),
dtend: Date.UTC(2026, 8, 29, 7, 15),
eventTimezone: 'Europe/Berlin',
uid2445: 'standup-uid',
});
const report = await h.run(CALENDAR_AUTHORITY);
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 1 } } });
expect(h.server.get('CalendarEvent', 'a', standup)).toMatchObject({ title: 'Standup', uid: 'standup-uid' });
const created = h.server.all('CalendarEvent', 'a').find((e) => e.title === 'Standup (copy)')!;
expect(created.uid).not.toBe('standup-uid');
expect(h.events().find((e) => Number(e._id) === copy)).toMatchObject({ _sync_id: `a/${created.id}`, uid2445: created.uid });
expect(h.events().every((e) => Number(e.dirty ?? 0) === 0)).toBe(true);
});
it('uploads "this and following" in one run: the capped rule and the new series', async () => {
const h = real();
const series = h.server.addEvent('a', {
uid: 'series-uid',
title: 'Standup',
start: '2026-09-28T09:00:00',
duration: 'PT15M',
timeZone: 'Europe/Berlin',
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'daily' },
calendarIds: { [h.calendar]: true },
});
await h.run(CALENDAR_AUTHORITY);
const master = h.events()[0];
expect(master).toMatchObject({ rrule: 'FREQ=DAILY' });
const rowId = Number(master.calendar_id);
// Etar: the master's rule ends before the split, a new series starts there (it copies the uid).
h.device.user.updateEvent(Number(master._id), { rrule: 'FREQ=DAILY;UNTIL=20260930T235959Z' });
const later = h.device.user.insertEvent(rowId, {
title: 'Standup',
dtstart: Date.UTC(2026, 9, 1, 7),
duration: 'P900S',
rrule: 'FREQ=DAILY',
eventTimezone: 'Europe/Berlin',
uid2445: 'series-uid',
});
const report = await h.run(CALENDAR_AUTHORITY);
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 1, updated: 1 } } });
const capped = h.server.get('CalendarEvent', 'a', series)!;
expect(capped.recurrenceRule).toMatchObject({ frequency: 'daily', until: expect.any(String) });
const next = h.server.all('CalendarEvent', 'a').find((e) => e.id !== series)!;
expect(next).toMatchObject({ start: '2026-10-01T09:00:00', timeZone: 'Europe/Berlin', recurrenceRule: { frequency: 'daily' } });
expect(next.uid).not.toBe('series-uid');
expect(h.events().find((e) => Number(e._id) === later)).toMatchObject({ _sync_id: `a/${next.id}` });
expect(h.events().every((e) => Number(e.dirty ?? 0) === 0)).toBe(true);
});
it("uploads Etar's move to another calendar as a patch of the same event, keeping what the device can't show", async () => {
const h = real();
const work = h.server.addCalendar('a', { name: 'Work' });
const lunch = h.server.addEvent('a', {
uid: 'lunch-uid',
title: 'Lunch',
start: '2026-10-06T12:00:00',
duration: 'PT1H',
timeZone: 'Europe/Berlin',
keywords: { important: true },
virtualLocations: { v1: { '@type': 'VirtualLocation', uri: 'https://meet.example.com/lunch' } },
organizerCalendarAddress: 'mailto:alice@example.com',
participants: {
me: { '@type': 'Participant', calendarAddress: 'mailto:alice@example.com', roles: { owner: true, attendee: true }, participationStatus: 'accepted' },
bob: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.com', name: 'Bob', roles: { attendee: true }, participationStatus: 'accepted' },
},
calendarIds: { [h.calendar]: true },
});
await h.run(CALENDAR_AUTHORITY);
const old = h.events().find((e) => e._sync_id === `a/${lunch}`)!;
const workRow = Number(h.device.rows('calendars').find((c) => c._sync_id === `a/${work}`)!._id);
const moved = etarReinsert(h, old, workRow);
const report = await h.run(CALENDAR_AUTHORITY);
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 0, updated: 1, deleted: 0 } } });
expect(h.server.all('CalendarEvent', 'a')).toHaveLength(1);
const event = h.server.get('CalendarEvent', 'a', lunch)!;
expect(event.calendarIds).toEqual({ [work]: true });
expect(event).toMatchObject({
uid: 'lunch-uid',
keywords: { important: true },
virtualLocations: { v1: { uri: 'https://meet.example.com/lunch' } },
participants: { bob: { participationStatus: 'accepted' } },
});
// A move tells nobody.
expect(h.server.scheduling).toEqual([]);
expect(h.events()).toEqual([expect.objectContaining({ _id: moved, _sync_id: `a/${lunch}`, uid2445: 'lunch-uid', calendar_id: workRow, dirty: 0 })]);
const before = h.batches.log.length;
expect((await h.run(CALENDAR_AUTHORITY)).outcome).toBe('ok');
expect(rowWrites(h.batches.log.slice(before))).toEqual([]);
});
it('keeps a meeting Etar moved the same event, without cancelling and inviting anyone again', async () => {
const h = real();
const work = h.server.addCalendar('a', { name: 'Work' });
const lunch = h.server.addEvent('a', {
uid: 'lunch-uid',
title: 'Lunch',
start: '2026-10-06T12:00:00',
duration: 'PT1H',
timeZone: 'Europe/Berlin',
organizerCalendarAddress: 'mailto:alice@example.com',
participants: {
me: { '@type': 'Participant', calendarAddress: 'mailto:alice@example.com', roles: { owner: true, attendee: true }, participationStatus: 'accepted' },
bob: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.com', name: 'Bob', roles: { attendee: true, optional: true }, participationStatus: 'accepted' },
},
calendarIds: { [h.calendar]: true },
});
await h.run(CALENDAR_AUTHORITY);
const old = h.events().find((e) => e._sync_id === `a/${lunch}`)!;
const workRow = Number(h.device.rows('calendars').find((c) => c._sync_id === `a/${work}`)!._id);
// What Etar really inserts: Bob as a required attendee without an answer, and no row for the user.
const moved = h.device.user.etarMove(Number(old._id), workRow);
const report = await h.run(CALENDAR_AUTHORITY);
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 0, updated: 1, deleted: 0 } } });
expect(h.server.all('CalendarEvent', 'a')).toHaveLength(1);
expect(h.server.get('CalendarEvent', 'a', lunch)).toMatchObject({
uid: 'lunch-uid',
calendarIds: { [work]: true },
participants: { bob: { roles: { attendee: true, optional: true }, participationStatus: 'accepted' } },
});
expect(h.server.scheduling).toEqual([]);
// The rows show the meeting as the server holds it again: the user's own row and Bob's answer.
const attendees = h.device.rows('attendees').filter((a) => Number(a.event_id) === moved);
expect(attendees.map((a) => [a[Attendees.ATTENDEE_EMAIL], a[Attendees.ATTENDEE_STATUS], a[Attendees.ATTENDEE_TYPE]]).sort()).toEqual([
['alice@example.com', 1, 1],
['bob@example.com', 1, 2],
]);
expect(h.events()).toEqual([expect.objectContaining({ _id: moved, _sync_id: `a/${lunch}`, dirty: 0 })]);
});
it('uploads a series Etar turned into a single event as the removal of its rule', async () => {
const h = real();
const series = h.server.addEvent('a', {
uid: 'series-uid',
title: 'Standup',
start: '2026-09-28T09:00:00',
duration: 'PT15M',
timeZone: 'Europe/Berlin',
keywords: { team: true },
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'daily' },
calendarIds: { [h.calendar]: true },
});
await h.run(CALENDAR_AUTHORITY);
const old = h.events().find((e) => e._sync_id === `a/${series}`)!;
const single = etarReinsert(h, old, Number(old.calendar_id), {
[Events.RRULE]: null,
[Events.DURATION]: null,
[Events.DTEND]: Number(old[Events.DTSTART]) + 15 * 60_000,
});
const report = await h.run(CALENDAR_AUTHORITY);
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 0, updated: 1, deleted: 0 } } });
expect(h.server.all('CalendarEvent', 'a')).toHaveLength(1);
const event = h.server.get('CalendarEvent', 'a', series)!;
expect(event).toMatchObject({ uid: 'series-uid', title: 'Standup', keywords: { team: true } });
expect(event.recurrenceRule).toBeUndefined();
expect(h.events()).toEqual([expect.objectContaining({ _id: single, _sync_id: `a/${series}`, rrule: null, dirty: 0 })]);
});
it('never leaves two rows with one identity when the moved event is edited while its move uploads', async () => {
const h = real();
const work = h.server.addCalendar('a', { name: 'Work' });
const lunch = h.server.addEvent('a', {
uid: 'lunch-uid',
title: 'Lunch',
start: '2026-10-06T12:00:00',
duration: 'PT1H',
timeZone: 'Europe/Berlin',
calendarIds: { [h.calendar]: true },
});
await h.run(CALENDAR_AUTHORITY);
const old = h.events().find((e) => e._sync_id === `a/${lunch}`)!;
const workRow = Number(h.device.rows('calendars').find((c) => c._sync_id === `a/${work}`)!._id);
const moved = etarReinsert(h, old, workRow);
let edited = false;
const stop = h.server.onAfterRequest((request) => {
if (edited || !request.methods.includes('CalendarEvent/set')) return;
edited = true;
h.device.user.updateEvent(moved, { [Events.TITLE]: 'Team lunch' });
});
await h.run(CALENDAR_AUTHORITY);
stop();
expect(edited).toBe(true);
expect(sharedIdentities(h)).toEqual([]);
expect(h.events().find((e) => Number(e._id) === moved)).toMatchObject({ title: 'Team lunch' });
expect((await h.run(CALENDAR_AUTHORITY)).outcome).toBe('ok');
expect(sharedIdentities(h)).toEqual([]);
const server = h.server.all('CalendarEvent', 'a');
expect(server.map((e) => [e.title, e.calendarIds])).toEqual([['Team lunch', { [work]: true }]]);
expect(h.events()).toEqual([expect.objectContaining({ _id: moved, _sync_id: `a/${server[0].id}`, dirty: 0 })]);
});
it('pairs the rows again when an app rewrote the moved event unchanged while its move uploaded', async () => {
const h = real();
const work = h.server.addCalendar('a', { name: 'Work' });
const lunch = h.server.addEvent('a', {
uid: 'lunch-uid',
title: 'Lunch',
start: '2026-10-06T12:00:00',
duration: 'PT1H',
timeZone: 'Europe/Berlin',
keywords: { important: true },
organizerCalendarAddress: 'mailto:alice@example.com',
participants: {
me: { '@type': 'Participant', calendarAddress: 'mailto:alice@example.com', roles: { owner: true, attendee: true }, participationStatus: 'accepted' },
bob: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.com', name: 'Bob', roles: { attendee: true }, participationStatus: 'accepted' },
},
calendarIds: { [h.calendar]: true },
});
await h.run(CALENDAR_AUTHORITY);
const old = h.events().find((e) => e._sync_id === `a/${lunch}`)!;
const workRow = Number(h.device.rows('calendars').find((c) => c._sync_id === `a/${work}`)!._id);
const moved = etarReinsert(h, old, workRow);
const stop = h.server.onAfterRequest((request) => {
if (!request.methods.includes('CalendarEvent/set')) return;
stop();
// Fossify's save: the same attendees, deleted and inserted again.
const attendees = h.device.rows('attendees').filter((a) => Number(a.event_id) === moved).map(({ _id, event_id, ...a }) => a);
h.device.user.fossifySaveEvent(moved, {}, attendees, []);
});
const report = await h.run(CALENDAR_AUTHORITY);
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 0, updated: 1, deleted: 0 } } });
expect(h.server.all('CalendarEvent', 'a')).toHaveLength(1);
expect(h.server.get('CalendarEvent', 'a', lunch)).toMatchObject({ uid: 'lunch-uid', calendarIds: { [work]: true }, keywords: { important: true } });
expect(h.events()).toEqual([expect.objectContaining({ _id: moved, _sync_id: `a/${lunch}`, dirty: 0 })]);
});
it('syncs events both ways and writes nothing for its own echo', async () => {
const h = real();
const standup = h.server.addEvent('a', {
uid: 'e1',
title: 'Standup',
start: '2026-09-28T09:00:00',
duration: 'PT15M',
timeZone: 'Europe/Berlin',
calendarIds: { [h.calendar]: true },
});
expect((await h.run(CALENDAR_AUTHORITY)).outcome).toBe('ok');
const rowId = Number(h.device.rows('calendars')[0]._id);
h.device.user.insertEvent(rowId, { title: 'Lunch', dtstart: Date.UTC(2026, 8, 28, 10), dtend: Date.UTC(2026, 8, 28, 11), eventTimezone: 'Europe/Berlin' });
h.device.user.updateEvent(Number(h.events().find((e) => e.title === 'Standup')!._id), { title: 'Daily standup' });
const up = await h.run(CALENDAR_AUTHORITY);
expect(up).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 1, updated: 1 } } });
expect(h.server.get('CalendarEvent', 'a', standup)).toMatchObject({ title: 'Daily standup' });
expect(h.server.all('CalendarEvent', 'a').find((e) => e.title === 'Lunch')).toMatchObject({ start: '2026-09-28T12:00:00', timeZone: 'Europe/Berlin' });
expect(h.events().every((e) => Number(e.dirty ?? 0) === 0 && String(e._sync_id).startsWith('a/'))).toBe(true);
const before = h.batches.log.length;
expect((await h.run(CALENDAR_AUTHORITY)).outcome).toBe('ok');
expect(rowWrites(h.batches.log.slice(before))).toEqual([]);
});
});
@@ -0,0 +1,167 @@
// SyncState `selected` names the collections whose rows are all on the
// device: a newly selected collection is loaded, a deselected one dropped.
// A load or a drop that stopped half-way must neither hide the collection
// from the next load nor keep its rows from the next drop.
import { describe, expect, it } from 'vitest';
import { Events } from '../../android-columns';
import { calendarPlanner } from '../../calendar/planner';
import { contactsPlanner } from '../../contacts/planner';
import { addServerCards, CALENDAR_AUTHORITY, createHarness, RUN_BUDGET, type Harness } from './harness';
function real(): Harness {
const h = createHarness();
h.deps.planners = { contacts: contactsPlanner, calendar: calendarPlanner };
return h;
}
/** Runs out of time right after checkpoint `n` of the next run. */
function outOfTimeAt(h: Harness, n: number): void {
h.checkpoints.count = 0;
h.checkpoints.onCheckpoint = (count) => {
if (count === n) h.clock.now += RUN_BUDGET;
};
}
describe('device sync engine: collections loaded or dropped in part', () => {
it('brings every card back when a book whose drop stopped half-way is selected again', async () => {
const h = real();
const work = h.server.addAddressBook('a', { name: 'Work' });
addServerCards(h, ['Ada']);
addServerCards(h, Array.from({ length: 120 }, (_, i) => `W${String(i).padStart(3, '0')}`), 'a', work);
await h.run();
expect(h.contacts()).toHaveLength(121);
h.prefs.contactsSelection[`a/${work}`] = false;
// 'changes', 'deselect' (sync), 'deselect' (chunk 1): out of time after the first chunk went.
outOfTimeAt(h, 3);
expect((await h.run()).outcome).toBe('cancelled');
h.checkpoints.onCheckpoint = undefined;
const left = h.contacts().length;
expect(left).toBeGreaterThan(1);
expect(left).toBeLessThan(121);
h.prefs.contactsSelection[`a/${work}`] = true;
expect((await h.run()).outcome).toBe('ok');
expect(h.contacts()).toHaveLength(121);
expect(h.state()?.accounts.a.selected.sort()).toEqual([`a/${h.book}`, `a/${work}`].sort());
});
it('finishes the drop of a book in the next run when it stopped half-way', async () => {
const h = real();
const work = h.server.addAddressBook('a', { name: 'Work' });
addServerCards(h, ['Ada']);
addServerCards(h, Array.from({ length: 120 }, (_, i) => `W${String(i).padStart(3, '0')}`), 'a', work);
await h.run();
h.prefs.contactsSelection[`a/${work}`] = false;
outOfTimeAt(h, 3);
expect((await h.run()).outcome).toBe('cancelled');
h.checkpoints.onCheckpoint = undefined;
expect((await h.run()).outcome).toBe('ok');
expect(h.contacts().map((c) => c.name)).toEqual(['Ada']);
expect(h.state()?.accounts.a.selected).toEqual([`a/${h.book}`]);
});
it('keeps a deselected book partial while a dirty card of it waits, and drops it once that is uploaded', async () => {
const h = real();
const work = h.server.addAddressBook('a', { name: 'Work' });
addServerCards(h, ['Ada']);
const [boss] = addServerCards(h, ['Boss', 'Colleague'], 'a', work);
await h.run();
const bossRow = h.contactNamed('Boss')!.id;
const name = h.nameDataRow(bossRow)!;
h.device.user.updateData(Number(name._id), { data1: 'The Boss' });
h.server.setErrorFor('ContactCard', 'a', boss, { type: 'tooLarge' });
h.prefs.contactsSelection[`a/${work}`] = false;
await h.run();
expect(h.contacts().map((c) => c.name).sort()).toEqual(['Ada', 'The Boss']);
expect(h.state()?.accounts.a).toMatchObject({ selected: [`a/${h.book}`], partial: [`a/${work}`] });
h.clock.now += 2 * 3_600_000;
await h.run();
expect(h.server.get('ContactCard', 'a', boss)).toMatchObject({ name: { full: 'The Boss' } });
expect(h.contacts().map((c) => c.name)).toEqual(['Ada']);
expect(h.state()?.accounts.a).toMatchObject({ selected: [`a/${h.book}`], partial: [] });
});
it('drops the cards of a book deselected again while its load had stopped half-way', async () => {
const h = real();
const work = h.server.addAddressBook('a', { name: 'Work' });
h.prefs.contactsSelection[`a/${work}`] = false;
addServerCards(h, ['Ada']);
addServerCards(h, Array.from({ length: 120 }, (_, i) => `W${String(i).padStart(3, '0')}`), 'a', work);
await h.run();
expect(h.contacts()).toHaveLength(1);
h.prefs.contactsSelection[`a/${work}`] = true;
// 'changes', 'select', 'download' (chunk 1): out of time after the first chunk came.
outOfTimeAt(h, 3);
expect((await h.run()).outcome).toBe('cancelled');
h.checkpoints.onCheckpoint = undefined;
expect(h.contacts().length).toBeGreaterThan(1);
h.prefs.contactsSelection[`a/${work}`] = false;
expect((await h.run()).outcome).toBe('ok');
expect(h.contacts().map((c) => c.name)).toEqual(['Ada']);
});
it('downloads every card of a book selected while a first sync was cut short', async () => {
const h = createHarness();
const extra = h.server.addAddressBook('a', { name: 'Friends' });
const friends = addServerCards(h, Array.from({ length: 10 }, (_, i) => `F${i}`), 'a', extra);
addServerCards(h, Array.from({ length: 120 }, (_, i) => `P${String(i).padStart(3, '0')}`));
h.prefs.contactsSelection[`a/${extra}`] = false;
// The first sync (a full reconcile): 'reconcile', 'reconcile:ids', 'reconcile:objects': out of time after chunk 1.
outOfTimeAt(h, 3);
expect((await h.run()).outcome).toBe('cancelled');
h.checkpoints.onCheckpoint = undefined;
const marker = h.state()!.accounts.a.reconcile!;
expect(friends.filter((id) => id <= (marker.after as string)).length).toBeGreaterThan(0);
// The user turns Friends on before the next sync.
h.prefs.contactsSelection[`a/${extra}`] = true;
expect((await h.run()).outcome).toBe('ok');
const onDevice = new Set(h.contacts().map((c) => c.sourceId));
expect(friends.filter((id) => !onDevice.has(`a/${id}`))).toEqual([]);
expect(h.contacts()).toHaveLength(130);
expect(h.state()!.accounts.a).toMatchObject({ reconcile: null });
expect(h.state()!.accounts.a.selected.sort()).toEqual([`a/${h.book}`, `a/${extra}`].sort());
});
it('loads the events of a calendar dropped by a stopped run when it is selected again', async () => {
const h = real();
const holidays = h.server.addCalendar('a', { name: 'Holidays' });
const sports = h.server.addCalendar('a', { name: 'Sports' });
for (const [calendar, title] of [[holidays, 'Easter'], [holidays, 'Christmas'], [sports, 'Match']] as const) {
h.server.addEvent('a', { uid: `u-${title}`, title, start: '2026-10-01T09:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', calendarIds: { [calendar]: true } });
}
await h.run(CALENDAR_AUTHORITY);
expect(h.events()).toHaveLength(3);
// Both deselected; the run is cancelled once the first calendar's rows are gone.
h.prefs.calendarSelection[`a/${holidays}`] = false;
h.prefs.calendarSelection[`a/${sports}`] = false;
const original = h.deps.isCancelled;
let cancelled = false;
h.deps.isCancelled = async (runId) => {
const left = h.events().length;
if (!cancelled && left > 0 && left < 3) cancelled = true;
return cancelled || original(runId);
};
expect((await h.run(CALENDAR_AUTHORITY)).outcome).toBe('cancelled');
h.deps.isCancelled = original;
const titles = () => h.events().map((e) => String(e[Events.TITLE])).sort();
const dropped = titles().includes('Match') ? holidays : sports;
const expected = dropped === holidays ? ['Christmas', 'Easter'] : ['Match'];
h.prefs.calendarSelection[`a/${dropped}`] = true;
expect((await h.run(CALENDAR_AUTHORITY)).outcome).toBe('ok');
expect(titles()).toEqual(expected);
});
});
@@ -0,0 +1,75 @@
// The headless task entry: every run ends with finishRun and nothing
// escapes it (docs/device-sync.md, "The Android shell").
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../native', () => ({
createProviderPort: vi.fn(),
finishRun: vi.fn(async () => true),
getSyncSettings: vi.fn(async () => ({
masterAutomatic: true,
authorities: { 'com.android.contacts': { automatic: false, syncable: 1, periodicSeconds: 0, active: false, pending: false } },
})),
isRunCancelled: vi.fn(async () => false),
showSyncProblem: vi.fn(async () => undefined),
}));
import * as native from '../../native';
import { emptyStats } from '../../engine/report';
import { createTaskDeps, runDeviceSyncTask, teardownAuthority } from '../../task';
import { useDeviceSyncStore } from '../../../stores/device-sync-store';
import type { RunPayload } from '../../types';
const payload: RunPayload = {
runId: 'r1',
accountName: 'alice@example.com',
registryId: 'alice@mail.example.com',
authority: 'com.android.contacts',
extras: {},
deadline: Date.now() + 60_000,
};
describe('device sync task', () => {
beforeEach(() => {
vi.mocked(native.finishRun).mockClear();
});
it('hands the report to the adapter before settling, and records the status', async () => {
await runDeviceSyncTask(payload);
expect(native.finishRun).toHaveBeenCalledWith('r1', expect.objectContaining({ v: 1, runId: 'r1', outcome: 'disabled' }));
expect(useDeviceSyncStore.getState().accounts['alice@mail.example.com']?.lastRun?.['com.android.contacts']).toMatchObject({
outcome: 'disabled',
});
});
it('reads back the status it records, for a run that did nothing to keep', async () => {
const deps = createTaskDeps();
const status = { at: 5, outcome: 'ok' as const, durationMs: 3, conflicts: 1, itemErrors: 2, stats: emptyStats() };
await deps.recordStatus('bob@mail.example.com', 'com.android.calendar', status);
await expect(deps.lastStatus('bob@mail.example.com', 'com.android.calendar')).resolves.toEqual(status);
await expect(deps.lastStatus('bob@mail.example.com', 'com.android.contacts')).resolves.toBeUndefined();
await expect(deps.lastStatus('nobody@mail.example.com', 'com.android.calendar')).resolves.toBeUndefined();
});
it('reports a failure instead of throwing, and survives a refused finishRun', async () => {
vi.mocked(native.getSyncSettings).mockRejectedValueOnce(new Error('module gone'));
vi.mocked(native.finishRun).mockRejectedValueOnce(new Error('bridge down'));
await expect(runDeviceSyncTask({ ...payload, runId: 'r2' })).resolves.toBeUndefined();
expect(native.finishRun).toHaveBeenCalledWith('r2', expect.objectContaining({ outcome: 'internal', message: 'module gone' }));
});
it('ignores a payload without a run', async () => {
await runDeviceSyncTask({} as RunPayload);
expect(native.finishRun).not.toHaveBeenCalled();
});
it('exports the teardown the app calls', () => {
expect(teardownAuthority).toBeTypeOf('function');
expect(teardownAuthority.length).toBe(4);
});
});
@@ -0,0 +1,656 @@
/**
* Small planners for the engine tests, faithful to the planner contract
* (src/device-sync/planner.ts) for a toy mapping:
*
* - contacts: `name.full` ↔ one StructuredName row (DISPLAY_NAME), group
* cards ↔ Groups rows (TITLE), memberships ↔ GroupMembership rows;
* - calendar: `title` ↔ one event row (plus the columns the provider
* requires), calendars ↔ Calendars rows.
*
* Like the real planners they assert what they read first (VERSION and
* DIRTY, or the event projection), keep baselines in DATA_SYNC3 /
* SYNC_DATA4, merge dirty items per unit (server wins a real conflict),
* adopt pending creates by uid, claim new items before creating them, and
* return `effect: 'none'` for echoes.
*/
import { Data, Events, GroupMembership, MimeType } from '../../android-columns';
import { collectionKey, parseCollectionKey, parseObjectRef, pendingRef, pendingUidOf } from '../../common/ids';
import { deepEqual, parseJsonColumn } from '../../common/json';
import { ptr } from '../../common/patch';
import type {
AcceptedPlan,
CalendarContext,
CalendarEventWire,
CalendarPlanner,
ContactCardWire,
ContactsContext,
ContactsPlanner,
DownloadPlan,
LocalCalendar,
LocalContact,
LocalEvent,
LocalGroup,
OpGroup,
UploadAction,
UploadPlan,
} from '../../planner';
import type { ProviderOp, Row } from '../../types';
const num = (v: unknown) => Number(v ?? 0);
const flag = (v: unknown) => Number(v ?? 0) === 1;
const str = (v: unknown): string | null => (typeof v === 'string' && v ? v : null);
const onIds = (map: Record<string, boolean> | undefined | null) =>
Object.entries(map ?? {}).filter(([, on]) => on === true).map(([id]) => id);
interface ToyContactsContext extends ContactsContext {
groupsOf?(uid: string): string[];
}
// ─── Contacts ───────────────────────────────────────────
function nameOf(card: ContactCardWire | null | undefined): string {
return card?.name?.full ?? '';
}
function nameRow(local: LocalContact) {
return local.rows.find((r) => r.mimetype === MimeType.STRUCTURED_NAME) ?? null;
}
function membershipRows(local: LocalContact) {
return local.rows.filter((r) => r.mimetype === MimeType.GROUP_MEMBERSHIP);
}
function cardCollections(acct: string, card: ContactCardWire): string {
return onIds(card.addressBookIds).map((id) => collectionKey(acct, id)).join(',');
}
function shadowOf(card: ContactCardWire): string {
return JSON.stringify(card);
}
/** Group SOURCE_IDs a card should be a member of, with their row ids. */
function wantedGroups(card: ContactCardWire, ctx: ToyContactsContext): Array<{ sourceId: string; rowId: number }> {
if (typeof card.uid !== 'string' || !ctx.groupsOf) return [];
return ctx
.groupsOf(card.uid)
.map((sourceId) => ({ sourceId, rowId: ctx.groupRowIdBySourceId(sourceId) }))
.filter((g): g is { sourceId: string; rowId: number } => g.rowId !== null);
}
function membershipOps(local: LocalContact | null, card: ContactCardWire, ctx: ToyContactsContext, parent: number | null): ProviderOp[] {
const wanted = wantedGroups(card, ctx);
const have = local ? membershipRows(local) : [];
const ops: ProviderOp[] = [];
for (const row of have) {
if (!wanted.some((g) => g.sourceId === row.cells.group_sourceid)) ops.push({ op: 'delete', table: 'data', id: row.id, expectCount: 1 });
}
for (const group of wanted) {
if (have.some((r) => r.cells.group_sourceid === group.sourceId)) continue;
const values = { [Data.MIMETYPE]: MimeType.GROUP_MEMBERSHIP, [GroupMembership.GROUP_ROW_ID]: group.rowId };
ops.push(
parent === null
? { op: 'insert', table: 'data', values, refs: { [Data.RAW_CONTACT_ID]: 0 } }
: { op: 'insert', table: 'data', values: { ...values, [Data.RAW_CONTACT_ID]: parent } },
);
}
return ops;
}
export const toyContactsPlanner: ContactsPlanner = {
rawContactColumns: ['_id', 'sourceid', 'version', 'dirty', 'deleted', 'sync1', 'sync2', 'sync3', 'sync4'],
dataColumns: ['_id', 'raw_contact_id', 'mimetype', 'data1', 'data_sync1', 'data_sync3', 'group_sourceid'],
groupColumns: ['_id', 'sourceid', 'version', 'dirty', 'deleted', 'title', 'sync2', 'sync3', 'sync4'],
decodeContact(rc: Row, data: Row[]): LocalContact {
return {
rawContactId: num(rc._id),
sourceId: str(rc.sourceid),
version: num(rc.version),
dirty: flag(rc.dirty),
deleted: flag(rc.deleted),
collections: (str(rc.sync1) ?? '').split(',').filter(Boolean),
shadow: parseJsonColumn<ContactCardWire>(rc.sync2),
pending: parseJsonColumn(rc.sync3),
poison: parseJsonColumn(rc.sync4),
rows: data.map((d) => ({
id: num(d._id),
mimetype: String(d.mimetype),
cells: { data1: d.data1 ?? null, group_sourceid: d.group_sourceid ?? null },
key: str(d.data_sync1),
photoHash: null,
baseline: parseJsonColumn<Row>(d.data_sync3),
})),
};
},
decodeGroup(row: Row): LocalGroup {
return {
groupId: num(row._id),
sourceId: str(row.sourceid),
version: num(row.version),
dirty: flag(row.dirty),
deleted: flag(row.deleted),
title: str(row.title),
shadow: parseJsonColumn<ContactCardWire>(row.sync2),
pending: parseJsonColumn(row.sync3),
poison: parseJsonColumn(row.sync4),
};
},
planDownload(card, local, context): DownloadPlan {
const ctx = context as ToyContactsContext;
const acct = ctx.jmapAccountId;
const ref = `${acct}/${card.id}`;
const name = nameOf(card);
const raw = { sourceid: ref, sync1: cardCollections(acct, card), sync2: shadowOf(card), raw_contact_is_read_only: ctx.isReadOnly(card) ? 1 : 0 };
if (!local) {
const ops: ProviderOp[] = [
{ op: 'insert', table: 'raw_contacts', values: raw },
{
op: 'insert',
table: 'data',
values: { [Data.MIMETYPE]: MimeType.STRUCTURED_NAME, data1: name, data_sync1: 'name', data_sync3: JSON.stringify({ data1: name }) },
refs: { [Data.RAW_CONTACT_ID]: 0 },
},
...membershipOps(null, card, ctx, null),
];
return { ops: { ref, ops }, conflicts: 0, stillDirty: false, effect: 'insert', writes: ops.length };
}
const row = nameRow(local);
const guard: ProviderOp = {
op: 'assert',
table: 'raw_contacts',
id: local.rawContactId,
values: { version: local.version, dirty: local.dirty ? 1 : 0 },
expectCount: 1,
};
const ops: ProviderOp[] = [guard];
let conflicts = 0;
let stillDirty = false;
const writeName = (value: string) => {
if (row) {
ops.push({ op: 'update', table: 'data', id: row.id, values: { data1: value, data_sync3: JSON.stringify({ data1: value }) }, expectCount: 1 });
} else {
ops.push({
op: 'insert',
table: 'data',
values: { [Data.RAW_CONTACT_ID]: local.rawContactId, [Data.MIMETYPE]: MimeType.STRUCTURED_NAME, data1: value, data_sync1: 'name', data_sync3: JSON.stringify({ data1: value }) },
});
}
};
const adopting = !local.sourceId && local.pending?.uid === card.uid;
if (local.deleted) {
// The device delete wins over a server edit; the destroy is on its way.
return { ops: { ref, ops: [guard] }, conflicts: 0, stillDirty: true, effect: 'none', writes: 0 };
}
const localValue = String(row?.cells.data1 ?? '');
if (adopting) {
// Our own create: identity and shadow; the rows stay the user's, measured against the server's value.
ops.push({ op: 'update', table: 'raw_contacts', id: local.rawContactId, values: { ...raw, sync3: null } });
if (row) ops.push({ op: 'update', table: 'data', id: row.id, values: { data_sync3: JSON.stringify({ data1: name }) }, expectCount: 1 });
return { ops: { ref, ops }, conflicts: 0, stillDirty: localValue !== name, effect: 'update', writes: ops.length - 1 };
}
const shadowName = nameOf(local.shadow);
const baseValue = String(row?.baseline?.data1 ?? shadowName);
const localChanged = local.dirty && localValue !== baseValue;
const remoteChanged = name !== shadowName;
if (!localChanged) {
if (localValue !== name) writeName(name);
} else if (remoteChanged) {
if (localValue === name) writeName(name);
else {
writeName(name);
conflicts++;
}
} else {
stillDirty = true;
}
ops.push(...membershipOps(local, card, ctx, local.rawContactId));
const shadowSame = deepEqual(local.shadow, card) && local.collections.join(',') === raw.sync1;
if (!shadowSame) ops.push({ op: 'update', table: 'raw_contacts', id: local.rawContactId, values: raw });
const writes = ops.length - 1;
return { ops: { ref, ops }, conflicts, stillDirty, effect: writes > 0 ? 'update' : 'none', writes };
},
planLocalDelete(local): OpGroup {
return { ref: local.sourceId ?? `row:${local.rawContactId}`, ops: [{ op: 'delete', table: 'raw_contacts', id: local.rawContactId }] };
},
planBaselineHeal(local): OpGroup | null {
const row = nameRow(local);
if (local.dirty || !row || deepEqual(row.baseline, { data1: row.cells.data1 })) return null;
return {
ref: local.sourceId ?? `row:${local.rawContactId}`,
ops: [
{ op: 'assert', table: 'raw_contacts', id: local.rawContactId, values: { version: local.version, dirty: 0 }, expectCount: 1 },
{ op: 'update', table: 'data', id: row.id, values: { data_sync3: JSON.stringify({ data1: row.cells.data1 }) }, expectCount: 1 },
],
};
},
planUpload(local, ctx): UploadPlan<ContactCardWire> {
const ref = local.sourceId ?? `row:${local.rawContactId}`;
const guard: ProviderOp = { op: 'assert', table: 'raw_contacts', id: local.rawContactId, values: { version: local.version }, expectCount: 1 };
const parsed = parseObjectRef(local.sourceId);
if (local.deleted) {
if (parsed) {
const books = onIds(local.shadow?.addressBookIds);
const synced = books.filter((b) => ctx.isSelected(collectionKey(parsed.accountId, b)));
if (synced.length && synced.length < books.length) {
return { kind: 'upload', actions: [{ kind: 'update', id: parsed.id, patch: Object.fromEntries(synced.map((b) => [`addressBookIds/${b}`, null])) }] };
}
return { kind: 'upload', actions: [{ kind: 'destroy', id: parsed.id, uid: null }] };
}
if (local.pending) return { kind: 'upload', actions: [{ kind: 'destroy', id: null, uid: local.pending.uid }] };
return { kind: 'purge', ops: { ref, ops: [{ op: 'delete', table: 'raw_contacts', id: local.rawContactId }] } };
}
const row = nameRow(local);
const value = String(row?.cells.data1 ?? '');
if (!parsed) {
if (!local.pending) {
const target = ctx.createTarget();
if (!target) return { kind: 'skip', reason: 'noWritableAddressBook' };
return {
kind: 'claim',
ops: { ref, ops: [guard, { op: 'update', table: 'raw_contacts', id: local.rawContactId, values: { sync3: JSON.stringify({ uid: ctx.mintUid(), target }) } }] },
};
}
const target = parseCollectionKey(local.pending.target);
if (!target) return { kind: 'skip', reason: 'badTarget' };
return {
kind: 'upload',
actions: [{ kind: 'create', uid: local.pending.uid, collectionId: target.id, object: { uid: local.pending.uid, name: { full: value } } }],
};
}
if (local.shadow && ctx.isReadOnly(local.shadow)) {
const shadowName = nameOf(local.shadow);
return {
kind: 'revert',
ops: {
ref,
ops: [
guard,
...(row ? [{ op: 'update' as const, table: 'data' as const, id: row.id, values: { data1: shadowName, data_sync3: JSON.stringify({ data1: shadowName }) } }] : []),
{ op: 'update', table: 'raw_contacts', id: local.rawContactId, values: { dirty: 0 } },
],
},
};
}
const base = String(row?.baseline?.data1 ?? nameOf(local.shadow));
if (value === base) {
return { kind: 'clean', ops: { ref, ops: [{ ...guard, values: { version: local.version, dirty: 1 } }, { op: 'update', table: 'raw_contacts', id: local.rawContactId, values: { dirty: 0 } }] } };
}
const patch = local.shadow?.name ? { 'name/full': value } : { name: { full: value } };
return { kind: 'upload', actions: [{ kind: 'update', id: parsed.id, patch }] };
},
planAccepted(local, server, ctx): AcceptedPlan {
const acct = ctx.jmapAccountId;
const ref = `${acct}/${server.id}`;
const row = nameRow(local);
const identity = { sourceid: ref, sync1: cardCollections(acct, server), sync2: shadowOf(server), sync3: null };
const baseline: ProviderOp[] = row
? [{ op: 'update', table: 'data', id: row.id, values: { data_sync3: JSON.stringify({ data1: row.cells.data1 }) }, expectCount: 1 }]
: [];
return {
ops: {
ref,
ops: [
{ op: 'assert', table: 'raw_contacts', id: local.rawContactId, values: { version: local.version }, expectCount: 1 },
{ op: 'update', table: 'raw_contacts', id: local.rawContactId, values: { ...identity, dirty: 0 } },
...baseline,
],
},
keepDirtyOps: { ref, ops: [{ op: 'update', table: 'raw_contacts', id: local.rawContactId, values: identity }, ...baseline] },
};
},
planGroupDownload(card, local, ctx): DownloadPlan {
const acct = ctx.jmapAccountId;
const ref = `${acct}/${card.id}`;
const values = { sourceid: ref, title: nameOf(card), group_visible: 1, sync2: shadowOf(card) };
if (!local) {
return { ops: { ref, ops: [{ op: 'insert', table: 'groups', values }] }, conflicts: 0, stillDirty: false, effect: 'insert', writes: 1 };
}
if (local.deleted) return { ops: { ref, ops: [] }, conflicts: 0, stillDirty: true, effect: 'none', writes: 0 };
const adopting = !local.sourceId && local.pending?.uid === card.uid;
if (!adopting && local.title === values.title && deepEqual(local.shadow, card)) {
return { ops: { ref, ops: [] }, conflicts: 0, stillDirty: local.dirty, effect: 'none', writes: 0 };
}
const localChanged = local.dirty && local.title !== nameOf(local.shadow);
const keepTitle = adopting || (localChanged && values.title === nameOf(local.shadow));
return {
ops: {
ref,
ops: [
{ op: 'assert', table: 'groups', id: local.groupId, values: { version: local.version }, expectCount: 1 },
{ op: 'update', table: 'groups', id: local.groupId, values: { ...values, ...(keepTitle ? { title: local.title } : {}), sync3: null } },
],
},
conflicts: localChanged && !keepTitle && values.title !== local.title ? 1 : 0,
stillDirty: keepTitle && local.dirty,
effect: 'update',
writes: 1,
};
},
planGroupLocalDelete(local): OpGroup {
return { ref: local.sourceId ?? `group:${local.groupId}`, ops: [{ op: 'delete', table: 'groups', id: local.groupId }] };
},
planGroupUpload(local, ctx): UploadPlan<ContactCardWire> {
const ref = local.sourceId ?? `group:${local.groupId}`;
const parsed = parseObjectRef(local.sourceId);
if (local.deleted) {
if (parsed) return { kind: 'upload', actions: [{ kind: 'destroy', id: parsed.id, uid: null }] };
return { kind: 'purge', ops: { ref, ops: [{ op: 'delete', table: 'groups', id: local.groupId }] } };
}
if (!parsed) {
if (!local.pending) {
const target = ctx.createTarget();
if (!target) return { kind: 'skip', reason: 'noWritableAddressBook' };
return {
kind: 'claim',
ops: {
ref,
ops: [
{ op: 'assert', table: 'groups', id: local.groupId, values: { version: local.version }, expectCount: 1 },
{ op: 'update', table: 'groups', id: local.groupId, values: { sync3: JSON.stringify({ uid: ctx.mintUid(), target }) } },
],
},
};
}
const target = parseCollectionKey(local.pending.target);
if (!target) return { kind: 'skip', reason: 'badTarget' };
return {
kind: 'upload',
actions: [{ kind: 'create', uid: local.pending.uid, collectionId: target.id, object: { uid: local.pending.uid, kind: 'group', name: { full: local.title ?? '' } } }],
};
}
if ((local.title ?? '') === nameOf(local.shadow)) {
return {
kind: 'clean',
ops: { ref, ops: [{ op: 'assert', table: 'groups', id: local.groupId, values: { version: local.version }, expectCount: 1 }, { op: 'update', table: 'groups', id: local.groupId, values: { dirty: 0 } }] },
};
}
return { kind: 'upload', actions: [{ kind: 'update', id: parsed.id, patch: { 'name/full': local.title ?? '' } }] };
},
planGroupAccepted(local, server, ctx): AcceptedPlan {
const ref = `${ctx.jmapAccountId}/${server.id}`;
const values = { sourceid: ref, sync2: shadowOf(server), sync3: null };
return {
ops: {
ref,
ops: [
{ op: 'assert', table: 'groups', id: local.groupId, values: { version: local.version }, expectCount: 1 },
{ op: 'update', table: 'groups', id: local.groupId, values: { ...values, dirty: 0 } },
],
},
keepDirtyOps: { ref, ops: [{ op: 'update', table: 'groups', id: local.groupId, values }] },
};
},
planMembershipUploads(contacts, groups, ctx): UploadAction<ContactCardWire>[] {
const acct = ctx.jmapAccountId;
const patches = new Map<string, Record<string, unknown>>();
for (const contact of contacts) {
const uid = contact.shadow?.uid ?? contact.pending?.uid;
if (!contact.sourceId || typeof uid !== 'string') continue;
const wanted = new Set(membershipRows(contact).map((r) => String(r.cells.group_sourceid)));
for (const group of groups) {
const parsed = parseObjectRef(group.sourceId);
if (!parsed || parsed.accountId !== acct) continue;
const member = group.shadow?.members?.[uid] === true;
const want = wanted.has(group.sourceId as string);
if (member === want) continue;
const patch = patches.get(parsed.id) ?? {};
patch[ptr('members', uid)] = want ? true : null;
patches.set(parsed.id, patch);
}
}
return [...patches].map(([id, patch]) => ({ kind: 'update', id, patch }));
},
};
// ─── Calendar ───────────────────────────────────────────
const EVENT_HOUR = 3_600_000;
function startMs(event: CalendarEventWire): number {
return Date.parse(`${String(event.start ?? '2026-01-01T00:00:00').slice(0, 19)}Z`);
}
function localStart(ms: number): string {
return new Date(ms).toISOString().slice(0, 19);
}
function eventRef(ctx: CalendarContext, event: CalendarEventWire): string {
return `${ctx.jmapAccountId}/${event.id}`;
}
export const toyCalendarPlanner: CalendarPlanner = {
calendarColumns: ['_id', '_sync_id', 'name', 'calendar_displayName', 'calendar_access_level', 'maxReminders', 'cal_sync2', 'cal_sync3'],
eventColumns: [
'_id', 'calendar_id', '_sync_id', 'dirty', 'deleted', 'title', 'dtstart', 'dtend', 'eventTimezone', 'original_id',
'original_sync_id', 'originalInstanceTime', 'sync_data1', 'sync_data2', 'sync_data3', 'sync_data4', 'sync_data5', 'uid2445', 'mutators',
],
attendeeColumns: ['_id', 'event_id', 'attendeeEmail'],
reminderColumns: ['_id', 'event_id', 'minutes'],
decodeCalendar(row: Row): LocalCalendar {
return {
calendarRowId: num(row._id),
syncId: str(row._sync_id),
cells: row,
shadow: parseJsonColumn(row.cal_sync2),
flags: parseJsonColumn(row.cal_sync3),
};
},
decodeEvents(events: Row[], attendees: Row[], reminders: Row[]): LocalEvent[] {
const toRow = (row: Row) => ({
eventId: num(row._id),
calendarRowId: num(row.calendar_id),
syncId: str(row._sync_id),
dirty: flag(row.dirty),
deleted: flag(row.deleted),
cells: { title: row.title ?? null, dtstart: row.dtstart ?? null, dtend: row.dtend ?? null },
attendees: attendees.filter((a) => num(a.event_id) === num(row._id)).map((a) => ({ id: num(a._id), cells: { attendeeEmail: a.attendeeEmail ?? null } })),
reminders: reminders.filter((r) => num(r.event_id) === num(row._id)).map((r) => ({ id: num(r._id), cells: { minutes: r.minutes ?? null } })),
baseline: parseJsonColumn<{ cells: Row; attendees: Row[]; reminders: Row[] }>(row.sync_data4),
pending: parseJsonColumn(row.sync_data3),
poison: parseJsonColumn(row.sync_data5),
mutators: str(row.mutators),
});
const isException = (row: Row) => row.original_id !== null && row.original_id !== undefined || !!str(row.original_sync_id);
const masters = events.filter((r) => !isException(r)).map((row) => ({
...toRow(row),
shadow: parseJsonColumn<CalendarEventWire>(row.sync_data1),
exceptions: events
.filter((x) => isException(x) && (num(x.original_id) === num(row._id) || (str(x.original_sync_id) !== null && str(x.original_sync_id) === str(row._sync_id))))
.map((x) => ({ ...toRow(x), recurrenceId: str(x.sync_data2), originalInstanceTime: x.originalInstanceTime === null ? null : num(x.originalInstanceTime) })),
})) as LocalEvent[];
const bySyncId = new Map<string, LocalEvent[]>();
for (const m of masters) if (m.syncId) bySyncId.set(m.syncId, [...(bySyncId.get(m.syncId) ?? []), m]);
for (const pair of bySyncId.values()) {
if (pair.length < 2) continue;
pair.sort((a, b) => a.eventId - b.eventId);
pair[0].split = 'source';
for (const clone of pair.slice(1)) clone.split = 'clone';
}
return masters;
},
planCalendars(selected, local, ctx) {
const groups: OpGroup[] = [];
const keys = new Set<string>();
for (const { jmapAccountId, calendar, readOnly, accountName } of selected) {
const key = collectionKey(jmapAccountId, calendar.id);
keys.add(key);
const values = {
name: calendar.name,
calendar_displayName: accountName ? `${calendar.name} (${accountName})` : calendar.name,
calendar_access_level: readOnly ? 200 : 700,
maxReminders: ctx.reminderOwner === 'device' ? 5 : 0,
cal_sync2: JSON.stringify(calendar),
cal_sync3: JSON.stringify(readOnly ? { readOnly: 'rights' } : {}),
};
const existing = local.find((l) => l.syncId === key);
if (!existing) {
groups.push({
ref: key,
ops: [{ op: 'insert', table: 'calendars', values: { ...values, _sync_id: key, calendar_color: -16776961, ownerAccount: ctx.ownerAccount, sync_events: 1, visible: 1 } }],
});
} else if (Object.entries(values).some(([k, v]) => String(existing.cells[k] ?? '') !== String(v))) {
groups.push({ ref: key, ops: [{ op: 'update', table: 'calendars', id: existing.calendarRowId, values }] });
}
}
return { groups, deleteCalendarRows: local.filter((l) => !l.syncId || !keys.has(l.syncId)).map((l) => l.calendarRowId) };
},
planDownload(event, local, ctx): DownloadPlan {
const ref = eventRef(ctx, event);
const calendarRowId = onIds(event.calendarIds).map((id) => ctx.calendarRowId(id)).find((id) => id !== null) ?? null;
const start = startMs(event);
const cells = { title: event.title ?? '', dtstart: start, dtend: start + EVENT_HOUR };
const baseline = JSON.stringify({ cells: { title: cells.title }, attendees: [], reminders: [] });
if (!local) {
if (calendarRowId === null) return { ops: { ref, ops: [] }, conflicts: 0, stillDirty: false, effect: 'none', writes: 0 };
const ops: ProviderOp[] = [{
op: 'insert',
table: 'events',
// CalendarProvider leaves DIRTY NULL on a sync-adapter insert unless it is written.
values: { calendar_id: calendarRowId, _sync_id: ref, ...cells, eventTimezone: 'UTC', eventStatus: 1, dirty: 0, uid2445: event.uid, sync_data1: JSON.stringify(event), sync_data4: baseline },
}];
return { ops: { ref, ops }, conflicts: 0, stillDirty: false, effect: 'insert', writes: 1 };
}
const guard: ProviderOp = { op: 'assert', table: 'events', id: local.eventId, values: { dirty: local.dirty ? 1 : 0, title: local.cells.title }, expectCount: 1 };
if (local.deleted) return { ops: { ref, ops: [guard] }, conflicts: 0, stillDirty: true, effect: 'none', writes: 0 };
const adopting = !parseObjectRef(local.syncId) && (local.pending?.uid ?? pendingUidOf(local.syncId)) === event.uid;
if (adopting) {
return {
ops: { ref, ops: [guard, { op: 'update', table: 'events', id: local.eventId, values: { _sync_id: ref, sync_data1: JSON.stringify(event), sync_data3: null, sync_data4: baseline } }] },
conflicts: 0,
stillDirty: local.cells.title !== event.title,
effect: 'update',
writes: 1,
};
}
const shadowTitle = local.shadow?.title ?? '';
const baseTitle = String(local.baseline?.cells.title ?? shadowTitle);
const localChanged = local.dirty && local.cells.title !== baseTitle;
const remoteChanged = (event.title ?? '') !== shadowTitle;
const values: Row = {};
let conflicts = 0;
if (!localChanged || remoteChanged) {
if (local.cells.title !== cells.title || local.cells.dtstart !== cells.dtstart) Object.assign(values, cells, { sync_data4: baseline });
if (localChanged && local.cells.title !== cells.title) conflicts = 1;
}
if (!deepEqual(local.shadow, event)) values.sync_data1 = JSON.stringify(event);
if (!Object.keys(values).length) return { ops: { ref, ops: [guard] }, conflicts: 0, stillDirty: localChanged && !remoteChanged, effect: 'none', writes: 0 };
return {
ops: { ref, ops: [guard, { op: 'update', table: 'events', id: local.eventId, values, expectCount: 1 }] },
conflicts,
stillDirty: localChanged && !remoteChanged,
effect: 'update',
writes: 1,
};
},
planLocalDelete(local): OpGroup {
return {
ref: local.syncId ?? `row:${local.eventId}`,
ops: [
...local.exceptions.map((x) => ({ op: 'delete' as const, table: 'events' as const, id: x.eventId })),
{ op: 'delete', table: 'events', id: local.eventId },
],
};
},
planBaselineHeal() {
return null;
},
planUpload(local, ctx): UploadPlan<CalendarEventWire> {
const ref = local.syncId ?? `row:${local.eventId}`;
const identity = local.split === 'clone' ? null : parseObjectRef(local.syncId);
const pendingUid = local.pending?.uid ?? pendingUidOf(local.syncId);
if (local.deleted) {
if (identity) return { kind: 'upload', actions: [{ kind: 'destroy', id: identity.id, uid: null }] };
if (pendingUid) return { kind: 'upload', actions: [{ kind: 'destroy', id: null, uid: pendingUid }] };
return { kind: 'purge', ops: { ref, ops: [{ op: 'delete', table: 'events', id: local.eventId }] } };
}
const guard: ProviderOp = { op: 'assert', table: 'events', id: local.eventId, values: { title: local.cells.title }, expectCount: 1 };
if (!identity) {
const calendar = ctx.calendarIdOfRow(local.calendarRowId);
if (!calendar) return { kind: 'skip', reason: 'notOurCalendar' };
if (!pendingUid || local.split === 'clone') {
const uid = ctx.mintUid();
return {
kind: 'claim',
ops: {
ref,
ops: [guard, {
op: 'update',
table: 'events',
id: local.eventId,
values: { _sync_id: pendingRef(uid), uid2445: uid, sync_data3: JSON.stringify({ uid, target: collectionKey(calendar.jmapAccountId, calendar.calendarId) }) },
}],
},
};
}
const start = Number(local.cells.dtstart ?? 0);
return {
kind: 'upload',
actions: [{
kind: 'create',
uid: pendingUid,
collectionId: calendar.calendarId,
object: { uid: pendingUid, title: String(local.cells.title ?? ''), start: localStart(start), duration: 'PT1H', timeZone: 'Etc/UTC' },
}],
};
}
if (ctx.isReadOnly(onIds(local.shadow?.calendarIds)[0] ?? '')) {
return { kind: 'revert', ops: { ref, ops: [guard, { op: 'update', table: 'events', id: local.eventId, values: { title: local.shadow?.title ?? '', dirty: 0 } }] } };
}
const base = String(local.baseline?.cells.title ?? local.shadow?.title ?? '');
if (local.cells.title === base) {
return { kind: 'clean', ops: { ref, ops: [guard, { op: 'update', table: 'events', id: local.eventId, values: { dirty: 0 } }] } };
}
return { kind: 'upload', actions: [{ kind: 'update', id: identity.id, patch: { title: String(local.cells.title ?? '') } }] };
},
planAccepted(local, server, ctx): AcceptedPlan {
const ref = eventRef(ctx, server);
const values = {
_sync_id: ref,
sync_data1: JSON.stringify(server),
sync_data3: null,
sync_data4: JSON.stringify({ cells: { title: local.cells.title }, attendees: [], reminders: [] }),
};
return {
ops: {
ref,
ops: [
{ op: 'assert', table: 'events', id: local.eventId, values: { title: local.cells.title }, expectCount: 1 },
{ op: 'update', table: 'events', id: local.eventId, values: { ...values, dirty: 0 } },
],
},
keepDirtyOps: { ref, ops: [{ op: 'update', table: 'events', id: local.eventId, values }] },
};
},
planPairs() {
return [];
},
planZoneChange() {
return null;
},
planReminderOwnerChange() {
return null;
},
};
/** An Events column list the fake accepts in `where`, for tests that read rows. */
export const EVENT_TITLE = Events.TITLE;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,518 @@
import { describe, expect, it } from 'vitest';
import {
Attendees,
Calendars,
Data,
Events,
MimeType,
RawContacts,
StructuredName,
StructuredPostal,
} from '../android-columns';
import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY, type ProviderOp } from '../types';
import { JPEG } from './contacts/fixtures';
import { FakeDeviceProviders, aospDurationValid, aospRruleValid } from './fakes/fake-provider';
import { compileWhere } from './fakes/fake-sql';
const ME = 'usera@example.org';
const OTHER = 'someone@example.org';
async function contactWithEmail(fake: FakeDeviceProviders, account = ME) {
const port = fake.port(account, CONTACTS_AUTHORITY);
const res = await port.applyBatch([
{ op: 'insert', table: 'raw_contacts', values: { [RawContacts.SOURCE_ID]: 'c/e1' } },
{
op: 'insert',
table: 'data',
values: { [Data.MIMETYPE]: MimeType.EMAIL, [Data.DATA1]: 'a@example.org', [Data.DATA_SYNC1]: 'emails:e1' },
refs: { [Data.RAW_CONTACT_ID]: 0 },
},
]);
if (!res.ok) throw new Error(res.message);
return { port, rawId: res.results[0].id!, dataId: res.results[1].id! };
}
describe('fake-sql', () => {
const row = (r: Record<string, string | number | null>) => (c: string) => r[c] ?? null;
it('evaluates the documented subset with SQL precedence and NULL rules', () => {
const where = compileWhere('dirty = 1 OR deleted = 1 AND sourceid IS NOT NULL', []);
expect(where(row({ dirty: 0, deleted: 1, sourceid: null }))).toBe(false);
expect(where(row({ dirty: 1, deleted: 0, sourceid: null }))).toBe(true);
expect(compileWhere('x IN (?, ?, 3)', ['1', 2])(row({ x: 3 }))).toBe(true);
expect(compileWhere('x NOT IN (?)', ['1'])(row({ x: null }))).toBe(false);
expect(compileWhere("mimetype LIKE '%/email%'")(row({ mimetype: 'vnd.android.cursor.item/email_v2' }))).toBe(true);
expect(compileWhere('version = ?', ['2'])(row({ version: 2 }))).toBe(true);
expect(compileWhere('NOT (a = 1)')(row({ a: null }))).toBe(true);
expect(() => compileWhere('a = ?', [])).toThrow(/placeholders/);
});
});
describe('FakeDeviceProviders: contacts', () => {
it('scopes every read and write to the port account', async () => {
const fake = new FakeDeviceProviders();
const mine = await contactWithEmail(fake, ME);
const theirs = await contactWithEmail(fake, OTHER);
const rows = await mine.port.query({ table: 'raw_contacts', columns: [RawContacts._ID] });
expect(rows.rows).toEqual([[mine.rawId]]);
const data = await mine.port.query({ table: 'data', columns: [Data._ID] });
expect(data.rows).toEqual([[mine.dataId]]);
const denied = await mine.port.applyBatch([{ op: 'update', table: 'raw_contacts', id: theirs.rawId, values: { [RawContacts.SYNC1]: 'x' } }]);
expect(denied).toMatchObject({ ok: false, reason: 'scope' });
const orphan = await mine.port.applyBatch([
{ op: 'insert', table: 'data', values: { [Data.RAW_CONTACT_ID]: theirs.rawId, [Data.MIMETYPE]: MimeType.NOTE } },
]);
expect(orphan).toMatchObject({ ok: false, reason: 'scope' });
await expect(mine.port.query({ table: 'events', columns: ['_id'] })).rejects.toMatchObject({ code: 'scope' });
});
it('never sets DIRTY for the sync adapter but does for app edits, and only apps soft-delete', async () => {
const fake = new FakeDeviceProviders();
const { rawId, dataId } = await contactWithEmail(fake);
expect(fake.row('raw_contacts', rawId)![RawContacts.DIRTY]).toBe(0);
fake.user.updateData(dataId, { [Data.DATA1]: 'b@example.org' });
expect(fake.row('raw_contacts', rawId)![RawContacts.DIRTY]).toBe(1);
expect(fake.row('data', dataId)![Data.DATA_SYNC1]).toBe('emails:e1');
fake.user.deleteContact(rawId);
expect(fake.row('raw_contacts', rawId)).toMatchObject({ [RawContacts.DELETED]: 1, [RawContacts.DIRTY]: 1 });
const port = fake.port(ME, CONTACTS_AUTHORITY);
await port.applyBatch([{ op: 'delete', table: 'raw_contacts', id: rawId }]);
expect(fake.row('raw_contacts', rawId)).toBeUndefined();
expect(fake.row('data', dataId)).toBeUndefined();
});
it('bumps VERSION for data writes by anyone but not for DIRTY, SOURCE_ID or SYNC columns', async () => {
const fake = new FakeDeviceProviders();
const { port, rawId, dataId } = await contactWithEmail(fake);
const version = () => fake.row('raw_contacts', rawId)![RawContacts.VERSION];
expect(version()).toBe(2); // one bump for the batch's data inserts
await port.applyBatch([
{ op: 'update', table: 'raw_contacts', id: rawId, values: { [RawContacts.DIRTY]: 0, [RawContacts.SYNC2]: '{}', [RawContacts.SOURCE_ID]: 'c/e2' } },
]);
expect(version()).toBe(2);
await port.applyBatch([{ op: 'update', table: 'data', id: dataId, values: { [Data.DATA_SYNC3]: '{}' } }]);
expect(version()).toBe(3);
fake.user.star(rawId);
expect(version()).toBe(3);
expect(fake.row('raw_contacts', rawId)![RawContacts.DIRTY]).toBe(1);
});
it('applies a batch atomically and fails it on a stale VERSION assert', async () => {
const fake = new FakeDeviceProviders();
const { port, rawId, dataId } = await contactWithEmail(fake);
fake.user.updateData(dataId, { [Data.DATA1]: 'edited@example.org' });
const clear: ProviderOp[] = [
{ op: 'assert', table: 'raw_contacts', id: rawId, values: { [RawContacts.VERSION]: 2 } },
{ op: 'update', table: 'raw_contacts', id: rawId, values: { [RawContacts.DIRTY]: 0 } },
];
expect(await port.applyBatch(clear)).toMatchObject({ ok: false, reason: 'assert' });
expect(fake.row('raw_contacts', rawId)![RawContacts.DIRTY]).toBe(1);
const current = fake.row('raw_contacts', rawId)![RawContacts.VERSION] as number;
const ok = await port.applyBatch([
{ op: 'assert', table: 'raw_contacts', id: rawId, values: { [RawContacts.VERSION]: current } },
{ op: 'update', table: 'raw_contacts', id: rawId, values: { [RawContacts.DIRTY]: 0 } },
{ op: 'syncState', value: '{"v":1}' },
]);
expect(ok.ok).toBe(true);
expect(fake.readSyncState(ME, CONTACTS_AUTHORITY)).toBe('{"v":1}');
});
it('runs a hook right before the next batch, for concurrent-edit tests', async () => {
const fake = new FakeDeviceProviders();
const { port, rawId, dataId } = await contactWithEmail(fake);
const v = fake.row('raw_contacts', rawId)![RawContacts.VERSION] as number;
fake.beforeNextBatch(() => fake.user.updateData(dataId, { [Data.DATA1]: 'late@example.org' }));
const res = await port.applyBatch([
{ op: 'assert', table: 'raw_contacts', id: rawId, values: { [RawContacts.VERSION]: v } },
{ op: 'update', table: 'raw_contacts', id: rawId, values: { [RawContacts.DIRTY]: 0 } },
]);
expect(res.ok).toBe(false);
});
it('refuses 500 contacts ops without a yield point and oversized batches', async () => {
const fake = new FakeDeviceProviders({ maxBatchBytes: 500_000 });
const port = fake.port(ME, CONTACTS_AUTHORITY);
const ops: ProviderOp[] = Array.from({ length: 500 }, () => ({ op: 'insert', table: 'raw_contacts', values: {} }) as ProviderOp);
expect(await port.applyBatch(ops)).toMatchObject({ ok: false, reason: 'provider' });
ops[250] = { ...ops[250], yieldAllowed: true };
expect((await port.applyBatch(ops)).ok).toBe(true);
const big = await port.applyBatch([{ op: 'insert', table: 'raw_contacts', values: { [RawContacts.SYNC2]: 'x'.repeat(300_000) } }]);
expect(big).toMatchObject({ ok: false, reason: 'tooLarge' });
});
it('normalises names and addresses like ContactsProvider', async () => {
const fake = new FakeDeviceProviders();
const { port, rawId } = await contactWithEmail(fake);
const res = await port.applyBatch([
{ op: 'insert', table: 'data', values: { [Data.RAW_CONTACT_ID]: rawId, [Data.MIMETYPE]: MimeType.STRUCTURED_NAME, [StructuredName.DISPLAY_NAME]: 'Ada Lovelace' } },
{ op: 'insert', table: 'data', values: { [Data.RAW_CONTACT_ID]: rawId, [Data.MIMETYPE]: MimeType.STRUCTURED_POSTAL, [StructuredPostal.CITY]: 'London', [StructuredPostal.COUNTRY]: 'UK' } },
]);
if (!res.ok) throw new Error(res.message);
expect(fake.row('data', res.results[0].id!)).toMatchObject({ [StructuredName.GIVEN_NAME]: 'Ada', [StructuredName.FAMILY_NAME]: 'Lovelace' });
expect(fake.row('data', res.results[1].id!)![StructuredPostal.FORMATTED_ADDRESS]).toBe('London, UK');
});
it('stores a photo written as a blob and serves it to readPhoto', async () => {
const fake = new FakeDeviceProviders();
const { port, rawId } = await contactWithEmail(fake);
const res = await port.applyBatch([
{ op: 'insert', table: 'data', values: { [Data.RAW_CONTACT_ID]: rawId, [Data.MIMETYPE]: MimeType.PHOTO, [Data.DATA15]: { b64: 'AAAA' } } },
]);
expect(res.ok).toBe(true);
const photo = await port.readPhoto(rawId, 512);
expect(photo?.jpegBase64).toBe('AAAA');
expect(photo?.fileId).toEqual(expect.any(Number));
const rows = await port.query({ table: 'data', columns: [Data.DATA14, Data.DATA15], where: `${Data.MIMETYPE} = ?`, args: [MimeType.PHOTO] });
// data14 is a TEXT column: the file id reads back as text.
expect(rows.rows[0]).toEqual([String(photo!.fileId), null]);
});
it('keeps a photo within the 96 px thumbnail as the thumbnail alone, and serves that to readPhoto', async () => {
const fake = new FakeDeviceProviders();
const { port, rawId } = await contactWithEmail(fake);
const res = await port.applyBatch([
{ op: 'insert', table: 'data', values: { [Data.RAW_CONTACT_ID]: rawId, [Data.MIMETYPE]: MimeType.PHOTO, [Data.DATA15]: { b64: JPEG } } },
]);
expect(res.ok).toBe(true);
const rows = await port.query({ table: 'data', columns: [Data.DATA14, Data.DATA15], where: `${Data.MIMETYPE} = ?`, args: [MimeType.PHOTO] });
// No PHOTO_FILE_ID for a 1×1 JPEG, and the thumbnail does not read back.
expect(rows.rows[0]).toEqual([null, null]);
expect(await port.readPhoto(rawId, 512)).toEqual({ jpegBase64: JPEG, fileId: null });
});
it('weighs a batch as its Binder transaction does: two bytes per character of its JSON, a photo by its bytes', async () => {
const fake = new FakeDeviceProviders();
const { port, rawId } = await contactWithEmail(fake);
// 600,000 characters of text are about 1.2 MB in the Parcel; the same number of base64 characters are 450 KB of photo.
const text = await port.applyBatch([{ op: 'update', table: 'raw_contacts', id: rawId, values: { [RawContacts.SYNC2]: 'x'.repeat(600_000) } }]);
expect(text).toMatchObject({ ok: false, reason: 'tooLarge' });
const photo = await port.applyBatch([
{ op: 'insert', table: 'data', values: { [Data.RAW_CONTACT_ID]: rawId, [Data.MIMETYPE]: MimeType.PHOTO, [Data.DATA15]: { b64: 'A'.repeat(600_000) } } },
]);
expect(photo.ok).toBe(true);
});
it('models Fossify saves: rows re-inserted without DATA_SYNC', async () => {
const fake = new FakeDeviceProviders();
const { rawId, dataId } = await contactWithEmail(fake);
fake.user.fossifySave(rawId, null, [{ [Data.MIMETYPE]: MimeType.EMAIL, [Data.DATA1]: 'a@example.org' }]);
expect(fake.row('data', dataId)).toBeUndefined();
const emails = fake.rows('data', `${Data.RAW_CONTACT_ID} = ?`, [rawId]);
expect(emails).toHaveLength(1);
expect(emails[0][Data.DATA_SYNC1]).toBeNull();
expect(fake.row('raw_contacts', rawId)![RawContacts.DIRTY]).toBe(1);
});
});
describe('FakeDeviceProviders: calendar', () => {
async function calendar(fake: FakeDeviceProviders) {
const port = fake.port(ME, CALENDAR_AUTHORITY);
const res = await port.applyBatch([
{
op: 'insert',
table: 'calendars',
values: {
[Calendars._SYNC_ID]: 'c/b',
[Calendars.NAME]: 'Personal',
[Calendars.CALENDAR_DISPLAY_NAME]: 'Personal',
[Calendars.CALENDAR_COLOR]: -16776961,
[Calendars.CALENDAR_ACCESS_LEVEL]: 700,
[Calendars.OWNER_ACCOUNT]: ME,
[Calendars.SYNC_EVENTS]: 1,
},
},
]);
if (!res.ok) throw new Error(res.message);
return { port, calendarId: res.results[0].id! };
}
it('requires the calendar columns the provider requires', async () => {
const fake = new FakeDeviceProviders();
const port = fake.port(ME, CALENDAR_AUTHORITY);
const res = await port.applyBatch([{ op: 'insert', table: 'calendars', values: { [Calendars.NAME]: 'x' } }]);
expect(res).toMatchObject({ ok: false, reason: 'provider' });
});
it('enforces recurring/exception column rules, all-day fixes and the NULL-status crash', async () => {
const fake = new FakeDeviceProviders();
const { port, calendarId } = await calendar(fake);
const base = { [Events.CALENDAR_ID]: calendarId, [Events.EVENT_TIMEZONE]: 'UTC', [Events.STATUS]: 1 };
const master = await port.applyBatch([
{ op: 'insert', table: 'events', values: { ...base, [Events._SYNC_ID]: 'c/e1', [Events.DTSTART]: 1_000, [Events.DTEND]: 5_000, [Events.RRULE]: 'FREQ=DAILY', [Events.DURATION]: 'P3600S' } },
]);
if (!master.ok) throw new Error(master.message);
const m = fake.row('events', master.results[0].id!)!;
expect(m[Events.DTEND]).toBeNull();
expect(m[Events.ORGANIZER]).toBe(ME);
expect(await port.applyBatch([{ op: 'insert', table: 'events', values: { ...base, [Events.DTSTART]: 0, [Events.RRULE]: 'FREQ=DAILY' } }])).toMatchObject({ ok: false });
expect(await port.applyBatch([{ op: 'insert', table: 'events', values: { ...base, [Events.DTSTART]: 0, [Events.RRULE]: 'FREQ=YEARLY;RSCALE=CHINESE', [Events.DURATION]: 'P1D' } }])).toMatchObject({ ok: false });
expect(await port.applyBatch([{ op: 'insert', table: 'events', values: { ...base, [Events.ALL_DAY]: 1, [Events.DTSTART]: 0, [Events.RRULE]: 'FREQ=DAILY', [Events.DURATION]: 'PT0S' } }])).toMatchObject({ ok: false });
const allDay = await port.applyBatch([
{ op: 'insert', table: 'events', values: { ...base, [Events.ALL_DAY]: 1, [Events.DTSTART]: 86_400_000 + 3_600_000, [Events.RRULE]: 'FREQ=DAILY', [Events.DURATION]: 'P86400S' } },
]);
if (!allDay.ok) throw new Error(allDay.message);
expect(fake.row('events', allDay.results[0].id!)).toMatchObject({ [Events.DTSTART]: 86_400_000, [Events.DURATION]: 'P1D' });
expect(await port.applyBatch([{ op: 'update', table: 'events', id: master.results[0].id!, values: { [Events.STATUS]: null } }])).toMatchObject({ ok: false });
expect(await port.applyBatch([{ op: 'update', table: 'events', id: master.results[0].id!, values: { [Events.SELF_ATTENDEE_STATUS]: 1 } }])).toMatchObject({ ok: false });
});
it('links exceptions to their master and keeps them when a sync adapter deletes the master', async () => {
const fake = new FakeDeviceProviders();
const { port, calendarId } = await calendar(fake);
const res = await port.applyBatch([
{ op: 'insert', table: 'events', values: { [Events.CALENDAR_ID]: calendarId, [Events._SYNC_ID]: 'c/e1', [Events.DTSTART]: 0, [Events.RRULE]: 'FREQ=DAILY', [Events.DURATION]: 'P3600S', [Events.EVENT_TIMEZONE]: 'UTC', [Events.STATUS]: 1 } },
{ op: 'insert', table: 'events', values: { [Events.CALENDAR_ID]: calendarId, [Events.ORIGINAL_SYNC_ID]: 'c/e1', [Events.ORIGINAL_INSTANCE_TIME]: 86_400_000, [Events.DTSTART]: 86_400_000, [Events.DTEND]: 90_000_000, [Events.EVENT_TIMEZONE]: 'UTC', [Events.STATUS]: 1 } },
]);
if (!res.ok) throw new Error(res.message);
const [masterId, exceptionId] = res.results.map((r) => r.id!);
expect(fake.row('events', exceptionId)![Events.ORIGINAL_ID]).toBe(masterId);
await port.applyBatch([{ op: 'update', table: 'events', id: masterId, values: { [Events._SYNC_ID]: 'c/e9' } }]);
expect(fake.row('events', exceptionId)![Events.ORIGINAL_SYNC_ID]).toBe('c/e9');
await port.applyBatch([{ op: 'delete', table: 'events', id: masterId }]);
expect(fake.row('events', exceptionId)).toBeDefined();
});
it('leaves STATUS NULL on an event an app inserts without one (the column has no default)', async () => {
const fake = new FakeDeviceProviders();
const { calendarId } = await calendar(fake);
const id = fake.user.insertEvent(calendarId, { [Events.DTSTART]: 0, [Events.DTEND]: 1000, [Events.EVENT_TIMEZONE]: 'UTC', [Events.TITLE]: 'x' });
expect(fake.row('events', id)![Events.STATUS]).toBeNull();
// Unlike AVAILABILITY and ACCESS_LEVEL (NOT NULL DEFAULT 0).
expect(fake.row('events', id)).toMatchObject({ [Events.AVAILABILITY]: 0, [Events.ACCESS_LEVEL]: 0 });
});
it('hard-deletes app-deleted events without _SYNC_ID and soft-deletes synced ones', async () => {
const fake = new FakeDeviceProviders();
const { calendarId } = await calendar(fake);
const local = fake.user.insertEvent(calendarId, { [Events.DTSTART]: 0, [Events.DTEND]: 1000, [Events.EVENT_TIMEZONE]: 'UTC', [Events.TITLE]: 'x', [Events.STATUS]: 1 });
expect(fake.row('events', local)).toMatchObject({ [Events.DIRTY]: 1, [Events.MUTATORS]: expect.any(String) });
fake.user.deleteEvent(local);
expect(fake.row('events', local)).toBeUndefined();
const synced = fake.user.insertEvent(calendarId, { [Events.DTSTART]: 0, [Events.DTEND]: 1000, [Events.EVENT_TIMEZONE]: 'UTC', [Events._SYNC_ID]: '~pending/u1', [Events.STATUS]: 1 }, { reminders: [{ minutes: 10, method: 1 }] });
fake.user.deleteEvent(synced);
expect(fake.row('events', synced)).toMatchObject({ [Events.DELETED]: 1, [Events.DIRTY]: 1 });
expect(fake.rows('reminders', 'event_id = ?', [synced])).toHaveLength(0);
});
it('models CONTENT_EXCEPTION_URI: only the exception is dirty; a split clones _SYNC_ID', async () => {
const fake = new FakeDeviceProviders();
const { port, calendarId } = await calendar(fake);
const res = await port.applyBatch([
{ op: 'insert', table: 'events', values: { [Events.CALENDAR_ID]: calendarId, [Events._SYNC_ID]: 'c/e1', [Events.UID_2445]: 'u1', [Events.DTSTART]: 0, [Events.RRULE]: 'FREQ=DAILY', [Events.DURATION]: 'P3600S', [Events.EVENT_TIMEZONE]: 'UTC', [Events.STATUS]: 1 } },
{ op: 'insert', table: 'attendees', values: { [Attendees.ATTENDEE_EMAIL]: ME, [Attendees.ATTENDEE_STATUS]: 3 }, refs: { event_id: 0 } },
]);
if (!res.ok) throw new Error(res.message);
const masterId = res.results[0].id!;
const ex = fake.user.exceptionViaUri(masterId, 86_400_000, { [Events.SELF_ATTENDEE_STATUS]: 1 });
expect(fake.row('events', masterId)![Events.DIRTY]).toBeNull();
expect(fake.row('events', ex)).toMatchObject({ [Events.DIRTY]: 1, [Events._SYNC_ID]: null, [Events.UID_2445]: 'u1', [Events.ORIGINAL_SYNC_ID]: 'c/e1' });
expect(fake.rows('attendees', 'event_id = ?', [ex])[0][Attendees.ATTENDEE_STATUS]).toBe(1);
const split = fake.user.splitViaUri(masterId, 5 * 86_400_000, '20260101T000000Z', {});
expect(fake.row('events', split)![Events._SYNC_ID]).toBe('c/e1');
expect(fake.row('events', masterId)![Events.RRULE]).toContain('UNTIL=20260101T000000Z');
expect(fake.row('events', masterId)![Events.DIRTY]).toBeNull();
});
});
describe('AOSP format checks', () => {
it('accepts the Duration forms calendarcommon2 parses', () => {
for (const ok of ['P1D', 'PT1H30M', 'P1DT2H', 'P2W', 'P1W2D', '-PT15M', 'P3600S', 'P0DT1H30M0S']) expect(aospDurationValid(ok)).toBe(true);
for (const bad of ['1D', 'P1Y', 'PT1.5S', 'P', '']) expect(aospDurationValid(bad)).toBe(false);
});
it('rejects RRULE parts EventRecurrence does not know', () => {
expect(aospRruleValid('FREQ=MONTHLY;BYDAY=MO;BYSETPOS=-1;WKST=SU;X-FOO=1')).toBe(true);
expect(aospRruleValid('RRULE:FREQ=DAILY')).toBe(false);
expect(aospRruleValid('FREQ=YEARLY;RSCALE=GREGORIAN;SKIP=OMIT')).toBe(false);
expect(aospRruleValid('COUNT=2')).toBe(false);
});
});
describe('FakeDeviceProviders: the native bridge rules', () => {
async function calendarWithEvent(fake: FakeDeviceProviders, account = ME) {
const port = fake.port(account, CALENDAR_AUTHORITY);
const res = await port.applyBatch([
{
op: 'insert',
table: 'calendars',
values: {
[Calendars.NAME]: 'Personal',
[Calendars.CALENDAR_DISPLAY_NAME]: 'Personal',
[Calendars.CALENDAR_COLOR]: -16776961,
[Calendars.CALENDAR_ACCESS_LEVEL]: 700,
[Calendars.OWNER_ACCOUNT]: account,
},
},
{
op: 'insert',
table: 'events',
values: { [Events.DTSTART]: 0, [Events.DTEND]: 3_600_000, [Events.EVENT_TIMEZONE]: 'UTC', [Events.STATUS]: 1 },
refs: { [Events.CALENDAR_ID]: 0 },
},
{ op: 'insert', table: 'attendees', values: { [Attendees.ATTENDEE_EMAIL]: OTHER }, refs: { event_id: 1 } },
{ op: 'insert', table: 'extended_properties', values: { name: 'x', value: '1' }, refs: { event_id: 1 } },
]);
if (!res.ok) throw new Error(res.message);
return {
port,
calendarId: res.results[0].id!,
eventId: res.results[1].id!,
attendeeId: res.results[2].id!,
propertyId: res.results[3].id!,
};
}
it('stores numbers written to data1..data14 as text', async () => {
const fake = new FakeDeviceProviders();
const { port, rawId } = await contactWithEmail(fake);
const res = await port.applyBatch([
{
op: 'insert',
table: 'data',
values: { [Data.RAW_CONTACT_ID]: rawId, [Data.MIMETYPE]: MimeType.PHONE, [Data.DATA1]: '+1 555', [Data.DATA2]: 2 },
},
]);
if (!res.ok) throw new Error(res.message);
const rows = await port.query({
table: 'data',
columns: [Data.DATA2, Data.RAW_CONTACT_ID],
where: `${Data.MIMETYPE} = ?`,
args: [MimeType.PHONE],
});
expect(rows.rows).toEqual([['2', rawId]]);
// Asserts compare as text, so a number still matches.
const ok = await port.applyBatch([{ op: 'assert', table: 'data', id: res.results[0].id!, values: { [Data.DATA2]: 2 } }]);
expect(ok.ok).toBe(true);
});
it('leaves DIRTY NULL on events and calendars the sync adapter inserts', async () => {
const fake = new FakeDeviceProviders();
const { port, calendarId, eventId } = await calendarWithEvent(fake);
expect(fake.row('calendars', calendarId)![Calendars.DIRTY]).toBeNull();
expect(fake.row('events', eventId)![Events.DIRTY]).toBeNull();
expect((await port.query({ table: 'events', columns: ['_id'], where: 'dirty = 0' })).rows).toEqual([]);
expect((await port.query({ table: 'events', columns: ['_id'], where: 'dirty = 1' })).rows).toEqual([]);
});
it('expects exactly the id row unless told otherwise, and applies where to it', async () => {
const fake = new FakeDeviceProviders();
const { port, rawId } = await contactWithEmail(fake);
const gone = await port.applyBatch([{ op: 'delete', table: 'raw_contacts', id: rawId + 1000 }]);
expect(gone).toMatchObject({ ok: false, reason: 'assert' });
const tolerated = await port.applyBatch([{ op: 'delete', table: 'raw_contacts', id: rawId + 1000, expectCount: 0 }]);
expect(tolerated).toEqual({ ok: true, results: [{ count: 0 }] });
const mismatch = await port.applyBatch([
{ op: 'update', table: 'raw_contacts', id: rawId, where: `${RawContacts.DIRTY} = 1`, values: { [RawContacts.SYNC1]: 'x' } },
]);
expect(mismatch).toMatchObject({ ok: false, reason: 'assert' });
const matched = await port.applyBatch([
{ op: 'update', table: 'raw_contacts', id: rawId, where: `${RawContacts.DIRTY} = 0`, values: { [RawContacts.SYNC1]: 'x' } },
]);
expect(matched).toEqual({ ok: true, results: [{ count: 1 }] });
});
it('refuses _id in where on calendar child tables, and updates extended properties by id only', async () => {
const fake = new FakeDeviceProviders();
const { port, eventId, attendeeId, propertyId } = await calendarWithEvent(fake);
await expect(
port.query({ table: 'attendees', columns: ['event_id'], where: '_id = ?', args: [attendeeId] }),
).rejects.toMatchObject({ code: 'provider' });
const byWhere = await port.applyBatch([{ op: 'delete', table: 'attendees', where: '_id = ?', args: [attendeeId] }]);
expect(byWhere).toMatchObject({ ok: false, reason: 'provider' });
const byId = await port.applyBatch([{ op: 'delete', table: 'attendees', id: attendeeId }]);
expect(byId).toEqual({ ok: true, results: [{ count: 1 }] });
const propertiesByWhere = await port.applyBatch([
{ op: 'update', table: 'extended_properties', where: 'event_id = ?', args: [eventId], values: { value: '2' } },
]);
expect(propertiesByWhere).toMatchObject({ ok: false, reason: 'provider' });
const propertiesById = await port.applyBatch([
{ op: 'update', table: 'extended_properties', id: propertyId, values: { value: '2' } },
]);
expect(propertiesById.ok).toBe(true);
});
it('fails with assert for a parent that is gone and with scope for one of another account', async () => {
const fake = new FakeDeviceProviders();
const mine = await calendarWithEvent(fake);
const theirs = await calendarWithEvent(fake, OTHER);
const gone = await mine.port.applyBatch([
{ op: 'insert', table: 'reminders', values: { event_id: theirs.eventId + 1000, minutes: 10 } },
]);
expect(gone).toMatchObject({ ok: false, reason: 'assert' });
const foreign = await mine.port.applyBatch([
{ op: 'insert', table: 'reminders', values: { event_id: theirs.eventId, minutes: 10 } },
]);
expect(foreign).toMatchObject({ ok: false, reason: 'scope' });
const unnamed = await mine.port.applyBatch([{ op: 'insert', table: 'reminders', values: { minutes: 10 } }]);
expect(unnamed).toMatchObject({ ok: false, reason: 'scope' });
const moveAway = await mine.port.applyBatch([
{ op: 'update', table: 'events', id: mine.eventId, values: { [Events.CALENDAR_ID]: theirs.calendarId } },
]);
expect(moveAway).toMatchObject({ ok: false, reason: 'scope' });
const wrongRef = await mine.port.applyBatch([
{ op: 'insert', table: 'colors', values: { color_type: 0, color_index: '1', color: 1 } },
{ op: 'insert', table: 'reminders', values: { minutes: 10 }, refs: { event_id: 0 } },
]);
expect(wrongRef).toMatchObject({ ok: false, reason: 'scope' });
const leaves = await mine.port.applyBatch([
{ op: 'update', table: 'calendars', id: mine.calendarId, values: { [Calendars.ACCOUNT_NAME]: OTHER } },
]);
expect(leaves).toMatchObject({ ok: false, reason: 'scope' });
});
it('wants syncState last and never at a yield point', async () => {
const fake = new FakeDeviceProviders();
const { port, rawId } = await contactWithEmail(fake);
const early = await port.applyBatch([
{ op: 'syncState', value: '{}' },
{ op: 'update', table: 'raw_contacts', id: rawId, values: { [RawContacts.SYNC1]: 'x' } },
]);
expect(early).toMatchObject({ ok: false, reason: 'scope' });
const yielding = await port.applyBatch([
{ op: 'update', table: 'raw_contacts', id: rawId, values: { [RawContacts.SYNC1]: 'x' } },
{ op: 'syncState', value: '{}', yieldAllowed: true },
]);
expect(yielding).toMatchObject({ ok: false, reason: 'scope' });
expect(fake.readSyncState(ME, CONTACTS_AUTHORITY)).toBeNull();
});
it('takes raw_contact_is_read_only on writes but refuses it in projections and selections', async () => {
const fake = new FakeDeviceProviders();
const port = fake.port(ME, CONTACTS_AUTHORITY);
const res = await port.applyBatch([{ op: 'insert', table: 'raw_contacts', values: { [RawContacts.RAW_CONTACT_IS_READ_ONLY]: 1 } }]);
expect(res.ok).toBe(true);
await expect(port.query({ table: 'raw_contacts', columns: [RawContacts.RAW_CONTACT_IS_READ_ONLY] })).rejects.toMatchObject({
code: 'provider',
});
await expect(
port.query({ table: 'raw_contacts', columns: [RawContacts._ID], where: `${RawContacts.RAW_CONTACT_IS_READ_ONLY} = 1` }),
).rejects.toMatchObject({ code: 'provider' });
});
it('returns no id for a settings insert, which cannot be addressed by id either', async () => {
const fake = new FakeDeviceProviders();
const port = fake.port(ME, CONTACTS_AUTHORITY);
const res = await port.applyBatch([{ op: 'insert', table: 'settings', values: { ungrouped_visible: 1 } }]);
expect(res).toEqual({ ok: true, results: [{}] });
const byId = await port.applyBatch([{ op: 'delete', table: 'settings', id: 1 }]);
expect(byId).toMatchObject({ ok: false, reason: 'provider' });
});
it('counts the op at a yield point before resetting, like ContactsProvider', async () => {
const fake = new FakeDeviceProviders();
const { port, rawId } = await contactWithEmail(fake);
const update = (yieldAllowed = false): ProviderOp => ({
op: 'update',
table: 'raw_contacts',
id: rawId,
values: { [RawContacts.SYNC2]: 'y' },
yieldAllowed,
});
// 499 ops, then a yield point: fine.
const fits = [...Array.from({ length: 498 }, () => update()), update(true), ...Array.from({ length: 10 }, () => update())];
expect((await port.applyBatch(fits)).ok).toBe(true);
// The yield point is the 500th op since the start: refused.
const late = [...Array.from({ length: 499 }, () => update()), update(true)];
expect(await port.applyBatch(late)).toMatchObject({ ok: false, reason: 'provider' });
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+269
View File
@@ -0,0 +1,269 @@
/**
* The small SQL subset the device sync engine may use in `ProviderQuery.where`
* and op selections, evaluated over in-memory rows for the fake providers.
* Keeping the engine to this subset keeps it portable to the real providers
* (SQLite) and testable here:
*
* expr := or
* or := and ('OR' and)*
* and := not ('AND' not)*
* not := 'NOT' not | primary
* primary := '(' expr ')'
* | column ('=' | '!=' | '<>' | '<' | '<=' | '>' | '>=') value
* | column 'IS' ['NOT'] 'NULL'
* | column ['NOT'] 'IN' '(' value (',' value)* ')'
* | column ['NOT'] 'LIKE' value
* value := '?' | 'string' | number
*
* Comparisons follow SQLite closely enough: NULL compares false, numbers and
* numeric strings compare as numbers (column affinity), LIKE is
* case-insensitive with `%` and `_`.
*/
import type { Cell } from '../../types';
type Value = { kind: 'arg'; index: number } | { kind: 'lit'; value: string | number };
type Node =
| { t: 'or'; parts: Node[] }
| { t: 'and'; parts: Node[] }
| { t: 'not'; inner: Node }
| { t: 'cmp'; column: string; op: string; value: Value }
| { t: 'isnull'; column: string; negate: boolean }
| { t: 'in'; column: string; values: Value[]; negate: boolean }
| { t: 'like'; column: string; value: Value; negate: boolean };
type Token = { k: 'id' | 'str' | 'num' | 'op' | 'arg' | 'lp' | 'rp' | 'comma'; v: string };
function tokenize(src: string): Token[] {
const out: Token[] = [];
let i = 0;
while (i < src.length) {
const c = src[i];
if (/\s/.test(c)) { i++; continue; }
if (c === '(') { out.push({ k: 'lp', v: c }); i++; continue; }
if (c === ')') { out.push({ k: 'rp', v: c }); i++; continue; }
if (c === ',') { out.push({ k: 'comma', v: c }); i++; continue; }
if (c === '?') { out.push({ k: 'arg', v: c }); i++; continue; }
if (c === "'") {
let j = i + 1;
let s = '';
while (j < src.length) {
if (src[j] === "'" && src[j + 1] === "'") { s += "'"; j += 2; continue; }
if (src[j] === "'") break;
s += src[j++];
}
if (j >= src.length) throw new Error(`fake-sql: unterminated string in ${src}`);
out.push({ k: 'str', v: s });
i = j + 1;
continue;
}
const two = src.slice(i, i + 2);
if (['!=', '<>', '<=', '>='].includes(two)) { out.push({ k: 'op', v: two }); i += 2; continue; }
if (['=', '<', '>'].includes(c)) { out.push({ k: 'op', v: c }); i++; continue; }
const num = /^-?\d+(\.\d+)?/.exec(src.slice(i));
if (num) { out.push({ k: 'num', v: num[0] }); i += num[0].length; continue; }
const id = /^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i));
if (id) { out.push({ k: 'id', v: id[0] }); i += id[0].length; continue; }
throw new Error(`fake-sql: unexpected '${c}' in ${src}`);
}
return out;
}
class Parser {
private pos = 0;
private argIndex = 0;
constructor(private readonly tokens: Token[], private readonly src: string) {}
parse(): Node {
const node = this.or();
if (this.pos !== this.tokens.length) this.fail('trailing input');
return node;
}
private peek(): Token | undefined { return this.tokens[this.pos]; }
private kw(word: string): boolean {
const t = this.peek();
if (t?.k === 'id' && t.v.toUpperCase() === word) { this.pos++; return true; }
return false;
}
private fail(msg: string): never { throw new Error(`fake-sql: ${msg} in "${this.src}"`); }
private or(): Node {
const parts = [this.and()];
while (this.kw('OR')) parts.push(this.and());
return parts.length === 1 ? parts[0] : { t: 'or', parts };
}
private and(): Node {
const parts = [this.not()];
while (this.kw('AND')) parts.push(this.not());
return parts.length === 1 ? parts[0] : { t: 'and', parts };
}
private not(): Node {
if (this.kw('NOT')) return { t: 'not', inner: this.not() };
return this.primary();
}
private value(): Value {
const t = this.tokens[this.pos++];
if (!t) this.fail('missing value');
if (t.k === 'arg') return { kind: 'arg', index: this.argIndex++ };
if (t.k === 'str') return { kind: 'lit', value: t.v };
if (t.k === 'num') return { kind: 'lit', value: Number(t.v) };
return this.fail(`expected a value, got ${t.v}`);
}
private primary(): Node {
const t = this.peek();
if (!t) this.fail('unexpected end');
if (t.k === 'lp') {
this.pos++;
const inner = this.or();
if (this.peek()?.k !== 'rp') this.fail('missing )');
this.pos++;
return inner;
}
if (t.k !== 'id') this.fail(`expected a column, got ${t.v}`);
this.pos++;
const column = t.v;
if (this.kw('IS')) {
const negate = this.kw('NOT');
if (!this.kw('NULL')) this.fail('expected NULL');
return { t: 'isnull', column, negate };
}
const negate = this.kw('NOT');
if (this.kw('IN')) {
if (this.peek()?.k !== 'lp') this.fail('expected (');
this.pos++;
const values = [this.value()];
while (this.peek()?.k === 'comma') { this.pos++; values.push(this.value()); }
if (this.peek()?.k !== 'rp') this.fail('missing )');
this.pos++;
return { t: 'in', column, values, negate };
}
if (this.kw('LIKE')) return { t: 'like', column, value: this.value(), negate };
if (negate) this.fail('NOT must precede IN or LIKE here');
const op = this.tokens[this.pos++];
if (op?.k !== 'op') this.fail(`expected an operator after ${column}`);
return { t: 'cmp', column, op: op.v, value: this.value() };
}
}
const cache = new Map<string, { node: Node; argCount: number }>();
function compile(where: string): { node: Node; argCount: number } {
let hit = cache.get(where);
if (!hit) {
const tokens = tokenize(where);
const node = new Parser(tokens, where).parse();
hit = { node, argCount: tokens.filter((t) => t.k === 'arg').length };
cache.set(where, hit);
}
return hit;
}
function asNumber(v: unknown): number | null {
if (typeof v === 'number') return v;
if (typeof v === 'string' && /^-?\d+(\.\d+)?$/.test(v.trim())) return Number(v);
return null;
}
/** SQLite-like comparison: -1/0/1, or null when either side is NULL. */
export function compareCells(a: Cell, b: Cell): number | null {
if (a === null || a === undefined || b === null || b === undefined) return null;
const na = asNumber(a);
const nb = asNumber(b);
if (na !== null && nb !== null) return na === nb ? 0 : na < nb ? -1 : 1;
const sa = String(a);
const sb = String(b);
return sa === sb ? 0 : sa < sb ? -1 : 1;
}
function likeToRegex(pattern: string): RegExp {
const esc = pattern.replace(/[.*+^${}()|[\]\\]/g, '\\$&').replace(/%/g, '.*').replace(/_/g, '.');
return new RegExp(`^${esc}$`, 'is');
}
export type ColumnReader = (column: string) => Cell;
/** A predicate for rows, from a WHERE clause and its arguments. */
export function compileWhere(where: string | undefined, args: Array<string | number> = []): (read: ColumnReader) => boolean {
if (!where || !where.trim()) return () => true;
const { node, argCount } = compile(where);
if (argCount !== args.length) {
throw new Error(`fake-sql: "${where}" has ${argCount} placeholders but ${args.length} arguments`);
}
const val = (v: Value): Cell => (v.kind === 'arg' ? args[v.index] : v.value);
const evalNode = (n: Node, read: ColumnReader): boolean => {
switch (n.t) {
case 'or': return n.parts.some((p) => evalNode(p, read));
case 'and': return n.parts.every((p) => evalNode(p, read));
case 'not': return !evalNode(n.inner, read);
case 'isnull': {
const isNull = read(n.column) === null || read(n.column) === undefined;
return n.negate ? !isNull : isNull;
}
case 'in': {
const cell = read(n.column);
if (cell === null || cell === undefined) return false;
const hit = n.values.some((v) => compareCells(cell, val(v)) === 0);
return n.negate ? !hit : hit;
}
case 'like': {
const cell = read(n.column);
if (cell === null || cell === undefined) return false;
const hit = likeToRegex(String(val(n.value))).test(String(cell));
return n.negate ? !hit : hit;
}
case 'cmp': {
const c = compareCells(read(n.column), val(n.value));
if (c === null) return false;
switch (n.op) {
case '=': return c === 0;
case '!=':
case '<>': return c !== 0;
case '<': return c < 0;
case '<=': return c <= 0;
case '>': return c > 0;
case '>=': return c >= 0;
}
return false;
}
}
};
return (read) => evalNode(node, read);
}
/** Columns a WHERE clause mentions, so the fake can reject unknown ones like SQLite does. */
export function whereColumns(where: string | undefined): string[] {
if (!where || !where.trim()) return [];
const { node } = compile(where);
const out: string[] = [];
const walk = (n: Node): void => {
switch (n.t) {
case 'or':
case 'and': n.parts.forEach(walk); break;
case 'not': walk(n.inner); break;
default: out.push(n.column);
}
};
walk(node);
return out;
}
/** A comparator for `orderBy` ("col [ASC|DESC], …"); `_id` ascending when absent. */
export function compileOrderBy(orderBy: string | undefined): (a: ColumnReader, b: ColumnReader) => number {
const terms = (orderBy?.trim() ? orderBy.split(',') : ['_id'])
.map((term) => term.trim().split(/\s+/))
.map(([column, dir]) => ({ column, desc: dir?.toUpperCase() === 'DESC' }));
return (a, b) => {
for (const { column, desc } of terms) {
const va = a(column);
const vb = b(column);
let c: number;
if (va === null && vb === null) c = 0;
else if (va === null) c = -1; // SQLite sorts NULL first
else if (vb === null) c = 1;
else c = compareCells(va, vb) ?? 0;
if (c !== 0) return desc ? -c : c;
}
return 0;
};
}
+342
View File
@@ -0,0 +1,342 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
import {
clearSyncProblem,
createProviderPort,
ensureAndroidAccount,
finishRun,
getDeviceSyncModule,
getSyncSettings,
isDeviceSyncAvailable,
isRunCancelled,
listAndroidAccounts,
onAccountsChanged,
openAccountSettings,
removeAndroidAccount,
requestSync,
setPeriodicSync,
setPushRoutes,
setSyncEnabled,
showSyncProblem,
} from '../native';
import {
ACCOUNTS_CHANGED_EVENT,
CALENDAR_AUTHORITY,
CONTACTS_AUTHORITY,
type AccountSyncSettings,
type Authority,
type ProviderOp,
type RunReport,
} from '../types';
const modules = NativeModules as Record<string, unknown>;
const ACCOUNT = 'alice@example.org';
const SETTINGS: AccountSyncSettings = {
masterAutomatic: true,
authorities: {
[CONTACTS_AUTHORITY]: { syncable: 1, automatic: true, periodicSeconds: 3600, active: false, pending: false },
[CALENDAR_AUTHORITY]: { syncable: 1, automatic: false, periodicSeconds: 0, active: false, pending: true },
},
};
/** Installs a BulwarkDeviceSync module whose methods resolve like the Kotlin one. */
function installModule(overrides: Record<string, unknown> = {}) {
const module = {
getInfo: vi.fn(async () => ({ accountType: 'com.anonymous.bulwarkmobile.account', sdkInt: 34 })),
listAccounts: vi.fn(async () => [{ name: ACCOUNT, registryId: 'alice@example.org@mail.example.org' }]),
ensureAccount: vi.fn(async () => true),
removeAccount: vi.fn(async () => true),
getSyncSettings: vi.fn(async () => SETTINGS),
setSyncEnabled: vi.fn(async () => undefined),
setPeriodicSync: vi.fn(async () => undefined),
requestSync: vi.fn(async () => undefined),
openAccountSettings: vi.fn(async () => undefined),
finishRun: vi.fn(async () => true),
isRunCancelled: vi.fn(async () => false),
setPushRoutes: vi.fn(async () => undefined),
query: vi.fn(async () => JSON.stringify({ columns: ['_id', 'sourceid'], rows: [[1, 'c/a'], [2, null]] })),
applyBatch: vi.fn(async () => JSON.stringify({ ok: true, results: [{ id: 5 }, { count: 1 }] })),
readSyncState: vi.fn(async (): Promise<string | null> => null),
readPhoto: vi.fn(async (): Promise<string | null> => null),
showSyncProblem: vi.fn(async () => undefined),
clearSyncProblem: vi.fn(async () => undefined),
addListener: vi.fn(),
removeListeners: vi.fn(),
...overrides,
};
modules.BulwarkDeviceSync = module;
return module;
}
const TWO_OPS: ProviderOp[] = [
{ op: 'insert', table: 'raw_contacts', values: { sourceid: null } },
{ op: 'update', table: 'data', id: 7, values: { data1: 'x' } },
];
afterEach(() => {
delete modules.BulwarkDeviceSync;
(Platform as { OS: string }).OS = 'android';
vi.restoreAllMocks();
});
describe('module lookup', () => {
it('reads NativeModules on every call instead of caching the module', () => {
expect(getDeviceSyncModule()).toBeNull();
const first = installModule();
expect(getDeviceSyncModule()).toBe(first);
const second = installModule();
expect(getDeviceSyncModule()).toBe(second);
delete modules.BulwarkDeviceSync;
expect(getDeviceSyncModule()).toBeNull();
});
it('is off on iOS even if a module of that name exists', () => {
installModule();
(Platform as { OS: string }).OS = 'ios';
expect(getDeviceSyncModule()).toBeNull();
expect(isDeviceSyncAvailable()).toBe(false);
});
it('is available on Android with the module', () => {
expect(isDeviceSyncAvailable()).toBe(false);
installModule();
expect(isDeviceSyncAvailable()).toBe(true);
});
it('rejects everything, and subscribes nothing, without the module', async () => {
const calls: Array<() => Promise<unknown>> = [
() => listAndroidAccounts(),
() => ensureAndroidAccount(ACCOUNT, 'r'),
() => removeAndroidAccount(ACCOUNT),
() => getSyncSettings(ACCOUNT),
() => setSyncEnabled(ACCOUNT, CONTACTS_AUTHORITY, true),
() => setPeriodicSync(ACCOUNT, CONTACTS_AUTHORITY, 3600),
() => requestSync(ACCOUNT, CONTACTS_AUTHORITY),
() => openAccountSettings(ACCOUNT),
() => finishRun('run', {} as RunReport),
() => isRunCancelled('run'),
() => setPushRoutes({}),
() => showSyncProblem(ACCOUNT, 't', 'x', 'bulwarkmobile://login'),
() => clearSyncProblem(ACCOUNT),
() => createProviderPort(ACCOUNT, CONTACTS_AUTHORITY).query({ table: 'raw_contacts', columns: ['_id'] }),
() => createProviderPort(ACCOUNT, CONTACTS_AUTHORITY).applyBatch([]),
() => createProviderPort(ACCOUNT, CONTACTS_AUTHORITY).readSyncState(),
() => createProviderPort(ACCOUNT, CONTACTS_AUTHORITY).readPhoto(1, 512),
];
for (const call of calls) {
await expect(call()).rejects.toMatchObject({ code: 'unavailable' });
}
const unsubscribe = onAccountsChanged(() => undefined);
expect(() => unsubscribe()).not.toThrow();
});
});
describe('createProviderPort', () => {
it('is bound to its account and authority', () => {
const port = createProviderPort(ACCOUNT, CALENDAR_AUTHORITY);
expect(port.accountName).toBe(ACCOUNT);
expect(port.authority).toBe(CALENDAR_AUTHORITY);
});
it('sends a query as JSON and parses the rows', async () => {
const module = installModule();
const port = createProviderPort(ACCOUNT, CONTACTS_AUTHORITY);
const rows = await port.query({ table: 'raw_contacts', columns: ['_id', 'sourceid'], where: 'dirty = ?', args: [1] });
expect(rows).toEqual({ columns: ['_id', 'sourceid'], rows: [[1, 'c/a'], [2, null]] });
expect(module.query).toHaveBeenCalledWith(
ACCOUNT,
CONTACTS_AUTHORITY,
JSON.stringify({ table: 'raw_contacts', columns: ['_id', 'sourceid'], where: 'dirty = ?', args: [1] }),
);
});
it('throws on query replies that are not ProviderRows', async () => {
const port = createProviderPort(ACCOUNT, CONTACTS_AUTHORITY);
const q = { table: 'raw_contacts' as const, columns: ['_id'] };
for (const reply of [
'not json',
JSON.stringify({ columns: ['_id'] }),
JSON.stringify({ columns: ['_id'], rows: [[1, 2]] }),
JSON.stringify({ columns: ['_id'], rows: [[{ b64: 'AA' }]] }),
JSON.stringify({ columns: [7], rows: [] }),
42,
]) {
installModule({ query: vi.fn(async () => reply) });
await expect(port.query(q)).rejects.toThrow(/BulwarkDeviceSync\.query/);
}
});
it('passes native rejections through with their code', async () => {
const scope = Object.assign(new Error('Table instances is not part of com.android.calendar'), { code: 'scope' });
installModule({ query: vi.fn(async () => Promise.reject(scope)) });
await expect(
createProviderPort(ACCOUNT, CALENDAR_AUTHORITY).query({ table: 'events', columns: ['_id'] }),
).rejects.toBe(scope);
});
it('sends ops as JSON and returns the batch result', async () => {
const module = installModule();
const result = await createProviderPort(ACCOUNT, CONTACTS_AUTHORITY).applyBatch(TWO_OPS);
expect(result).toEqual({ ok: true, results: [{ id: 5 }, { count: 1 }] });
expect(module.applyBatch).toHaveBeenCalledWith(ACCOUNT, CONTACTS_AUTHORITY, JSON.stringify(TWO_OPS));
});
it('returns failed batches as results', async () => {
installModule({
applyBatch: vi.fn(async () => JSON.stringify({ ok: false, reason: 'assert', message: 'Expected 1 rows but actual 0' })),
});
await expect(createProviderPort(ACCOUNT, CONTACTS_AUTHORITY).applyBatch(TWO_OPS)).resolves.toEqual({
ok: false,
reason: 'assert',
message: 'Expected 1 rows but actual 0',
});
});
it('throws on batch replies that are not BatchResults', async () => {
const port = createProviderPort(ACCOUNT, CONTACTS_AUTHORITY);
for (const reply of [
JSON.stringify({ ok: false, reason: 'weird', message: 'x' }),
JSON.stringify({ ok: true, results: [{ id: 'five' }, {}] }),
JSON.stringify({ ok: true, results: [{ id: 5 }] }),
JSON.stringify({ ok: true }),
'[]',
]) {
installModule({ applyBatch: vi.fn(async () => reply) });
await expect(port.applyBatch(TWO_OPS)).rejects.toThrow(/BulwarkDeviceSync\.applyBatch/);
}
});
it('reads the SyncState as text', async () => {
const module = installModule();
const port = createProviderPort(ACCOUNT, CALENDAR_AUTHORITY);
await expect(port.readSyncState()).resolves.toBeNull();
module.readSyncState.mockResolvedValueOnce('{"v":1}');
await expect(port.readSyncState()).resolves.toBe('{"v":1}');
expect(module.readSyncState).toHaveBeenCalledWith(ACCOUNT, CALENDAR_AUTHORITY);
});
it('reads photos of contacts only', async () => {
const module = installModule();
const port = createProviderPort(ACCOUNT, CONTACTS_AUTHORITY);
await expect(port.readPhoto(3, 512)).resolves.toBeNull();
module.readPhoto.mockResolvedValueOnce(JSON.stringify({ jpegBase64: '/9j/', fileId: 12 }));
await expect(port.readPhoto(3, 512)).resolves.toEqual({ jpegBase64: '/9j/', fileId: 12 });
expect(module.readPhoto).toHaveBeenLastCalledWith(ACCOUNT, 3, 512);
module.readPhoto.mockResolvedValueOnce(JSON.stringify({ jpegBase64: '/9j/' }));
await expect(port.readPhoto(3, 512)).rejects.toThrow(/readPhoto/);
await expect(createProviderPort(ACCOUNT, CALENDAR_AUTHORITY).readPhoto(3, 512)).rejects.toThrow(/contacts/);
});
});
describe('accounts and sync settings', () => {
it('passes account calls through', async () => {
const module = installModule();
await expect(listAndroidAccounts()).resolves.toEqual([{ name: ACCOUNT, registryId: 'alice@example.org@mail.example.org' }]);
await expect(ensureAndroidAccount(ACCOUNT, 'reg')).resolves.toBe(true);
expect(module.ensureAccount).toHaveBeenCalledWith(ACCOUNT, 'reg');
await expect(removeAndroidAccount(ACCOUNT)).resolves.toBe(true);
expect(module.removeAccount).toHaveBeenCalledWith(ACCOUNT);
await expect(getSyncSettings(ACCOUNT)).resolves.toEqual(SETTINGS);
await setSyncEnabled(ACCOUNT, CALENDAR_AUTHORITY, false);
expect(module.setSyncEnabled).toHaveBeenCalledWith(ACCOUNT, CALENDAR_AUTHORITY, false);
await openAccountSettings(ACCOUNT);
expect(module.openAccountSettings).toHaveBeenCalledWith(ACCOUNT);
});
it('lets a registry id conflict reject with its code', async () => {
const conflict = Object.assign(new Error('belongs to another app account'), { code: 'conflict' });
installModule({ ensureAccount: vi.fn(async () => Promise.reject(conflict)) });
await expect(ensureAndroidAccount(ACCOUNT, 'other')).rejects.toMatchObject({ code: 'conflict' });
});
it('sends whole seconds for periodic syncs and refuses nonsense', async () => {
const module = installModule();
await setPeriodicSync(ACCOUNT, CONTACTS_AUTHORITY, 899.6);
expect(module.setPeriodicSync).toHaveBeenCalledWith(ACCOUNT, CONTACTS_AUTHORITY, 900);
await setPeriodicSync(ACCOUNT, CONTACTS_AUTHORITY, 0);
expect(module.setPeriodicSync).toHaveBeenLastCalledWith(ACCOUNT, CONTACTS_AUTHORITY, 0);
await expect(setPeriodicSync(ACCOUNT, CONTACTS_AUTHORITY, -1)).rejects.toBeInstanceOf(RangeError);
await expect(setPeriodicSync(ACCOUNT, CONTACTS_AUTHORITY, Number.NaN)).rejects.toBeInstanceOf(RangeError);
});
it('sends sync request options as JSON', async () => {
const module = installModule();
await requestSync(ACCOUNT, CONTACTS_AUTHORITY);
expect(module.requestSync).toHaveBeenLastCalledWith(ACCOUNT, CONTACTS_AUTHORITY, '{}');
await requestSync(ACCOUNT, CALENDAR_AUTHORITY, { manual: true, expedited: true });
expect(module.requestSync).toHaveBeenLastCalledWith(
ACCOUNT,
CALENDAR_AUTHORITY,
JSON.stringify({ manual: true, expedited: true }),
);
});
});
describe('run handshake', () => {
it('sends the report as JSON and returns whether it was accepted', async () => {
const module = installModule({ finishRun: vi.fn(async () => false) });
const report = { v: 1, runId: 'r1', authority: CONTACTS_AUTHORITY, outcome: 'ok' } as RunReport;
await expect(finishRun('r1', report)).resolves.toBe(false);
expect(module.finishRun).toHaveBeenCalledWith('r1', JSON.stringify(report));
});
it('asks whether a run was cancelled', async () => {
const module = installModule({ isRunCancelled: vi.fn(async () => true) });
await expect(isRunCancelled('r1')).resolves.toBe(true);
expect(module.isRunCancelled).toHaveBeenCalledWith('r1');
});
});
describe('push routes and notifications', () => {
it('stores push routes as JSON', async () => {
const module = installModule();
const routes: Record<string, Array<{ accountName: string; authorities: Authority[] }>> = {
c: [{ accountName: ACCOUNT, authorities: [CONTACTS_AUTHORITY, CALENDAR_AUTHORITY] }],
};
await setPushRoutes(routes);
expect(module.setPushRoutes).toHaveBeenCalledWith(JSON.stringify(routes));
});
it('posts and clears the sync problem notification', async () => {
const module = installModule();
await showSyncProblem(ACCOUNT, 'Sign in again', 'Contacts stopped syncing', 'bulwarkmobile://login');
expect(module.showSyncProblem).toHaveBeenLastCalledWith(
ACCOUNT,
'Sign in again',
'Contacts stopped syncing',
'bulwarkmobile://login',
null,
);
await showSyncProblem(ACCOUNT, 'Erneut anmelden', 'Kontakte', 'bulwarkmobile://login', 'Synchronisierungsprobleme');
expect(module.showSyncProblem).toHaveBeenLastCalledWith(
ACCOUNT,
'Erneut anmelden',
'Kontakte',
'bulwarkmobile://login',
'Synchronisierungsprobleme',
);
await clearSyncProblem(ACCOUNT);
expect(module.clearSyncProblem).toHaveBeenCalledWith(ACCOUNT);
});
it('listens for account changes until unsubscribed', () => {
installModule();
const remove = vi.fn();
let handler: (() => void) | undefined;
const addListener = vi
.spyOn(NativeEventEmitter.prototype, 'addListener')
.mockImplementation(((event: string, h: () => void) => {
expect(event).toBe(ACCOUNTS_CHANGED_EVENT);
handler = h;
return { remove };
}) as never);
const listener = vi.fn();
const unsubscribe = onAccountsChanged(listener);
expect(addListener).toHaveBeenCalledTimes(1);
handler?.();
expect(listener).toHaveBeenCalledTimes(1);
unsubscribe();
expect(remove).toHaveBeenCalledTimes(1);
});
});
+113
View File
@@ -0,0 +1,113 @@
/**
* The device sync mappers, merge rules and engine must stay pure TypeScript:
* no react-native, expo, zustand stores or the jmapClient singleton anywhere
* in their import graph (docs/device-sync.md, "Architecture"). src/test-setup.ts
* mocks those modules globally, so an impure import would not fail any other
* test; this one walks the graph instead.
*/
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
const ROOT = path.resolve(__dirname, '..');
/**
* Modules allowed to touch React Native: the bridge wrapper, the task entry,
* the detached client, and the app-side glue under `app/` (lifecycle,
* triggers), which the engine never imports.
*/
const RN_BOUNDARY = new Set(['native.ts', 'task.ts', path.join('jmap', 'client-port.ts')].map((p) => path.join(ROOT, p)));
const RN_BOUNDARY_DIRS = [path.join(ROOT, 'app') + path.sep];
const isBoundary = (file: string) => RN_BOUNDARY.has(file) || RN_BOUNDARY_DIRS.some((dir) => file.startsWith(dir));
const FORBIDDEN = [
/^react-native($|\/)/,
/^react($|\/)/,
/^expo/,
/^@expo\//,
/^zustand/,
/^@react-native/,
/^@react-navigation/,
];
const FORBIDDEN_FILES = [/[\\/]src[\\/]stores[\\/]/, /[\\/]api[\\/]jmap-client\.ts$/, /[\\/]lib[\\/](random|uuid)\.ts$/];
function sourceFiles(dir: string): string[] {
if (!existsSync(dir)) return [];
return readdirSync(dir).flatMap((name) => {
const full = path.join(dir, name);
if (statSync(full).isDirectory()) return name === '__tests__' ? [] : sourceFiles(full);
return /\.tsx?$/.test(name) ? [full] : [];
});
}
function specifiers(file: string): string[] {
const src = readFileSync(file, 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^\s*\/\/.*$/gm, '');
const out: string[] = [];
const patterns = [
/^\s*import\s+(?!type\s)(?:[^'"]*?\s+from\s+)?['"]([^'"]+)['"]/gm,
/^\s*export\s+(?!type\s)[^'"]*?\s+from\s+['"]([^'"]+)['"]/gm,
/\brequire\(\s*['"]([^'"]+)['"]\s*\)/g,
/\bimport\(\s*['"]([^'"]+)['"]\s*\)/g,
];
for (const re of patterns) {
for (const m of src.matchAll(re)) {
// `import { type A, type B } from` is erased too.
const clause = m[0];
if (/import\s*\{\s*(type\s+[\w$]+\s*,?\s*)+\}\s*from/.test(clause)) continue;
out.push(m[1]);
}
}
return out;
}
function resolve(from: string, spec: string): string | null {
if (!spec.startsWith('.')) return null;
const base = path.resolve(path.dirname(from), spec);
for (const candidate of [base, `${base}.ts`, `${base}.tsx`, path.join(base, 'index.ts')]) {
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
}
return null;
}
function impureChains(entry: string): string[] {
const problems: string[] = [];
const seen = new Set<string>();
const walk = (file: string, chain: string[]) => {
if (seen.has(file)) return;
seen.add(file);
for (const spec of specifiers(file)) {
const resolved = resolve(file, spec);
const at = [...chain, path.relative(ROOT, file)];
if (!resolved) {
if (FORBIDDEN.some((re) => re.test(spec))) problems.push(`${at.join(' → ')} → ${spec}`);
continue;
}
if (FORBIDDEN_FILES.some((re) => re.test(resolved))) {
problems.push(`${at.join(' → ')} → ${path.relative(ROOT, resolved)}`);
continue;
}
walk(resolved, at);
}
};
walk(entry, []);
return problems;
}
describe('device sync purity', () => {
const entries = sourceFiles(ROOT).filter((f) => !isBoundary(f));
it('scans the pure modules', () => {
expect(entries.map((f) => path.relative(ROOT, f))).toEqual(expect.arrayContaining(['types.ts', path.join('common', 'json.ts')]));
});
it('keeps react-native, expo, stores and the jmapClient singleton out of the mappers, merge rules and engine', () => {
const problems = entries.flatMap(impureChains);
expect(problems).toEqual([]);
});
it('would catch an impure import', () => {
const probe = path.join(ROOT, '..', 'lib', 'calendar-timezone.ts');
expect(impureChains(probe).length).toBeGreaterThan(0);
});
});
+346
View File
@@ -0,0 +1,346 @@
/**
* Column names and constant values of Android's ContactsContract and
* CalendarContract, as the providers spell them. The engine reads and writes
* rows by these names through the native bridge (src/device-sync/native.ts),
* so a typo here is a column the provider silently ignores or rejects.
*
* Only what device sync uses is listed. Pure constants: no React Native import.
*/
// ─── ContactsContract ──────────────────────────────────
export const RawContacts = {
_ID: '_id',
ACCOUNT_NAME: 'account_name',
ACCOUNT_TYPE: 'account_type',
SOURCE_ID: 'sourceid',
VERSION: 'version',
DIRTY: 'dirty',
DELETED: 'deleted',
SYNC1: 'sync1',
SYNC2: 'sync2',
SYNC3: 'sync3',
SYNC4: 'sync4',
RAW_CONTACT_IS_READ_ONLY: 'raw_contact_is_read_only',
AGGREGATION_MODE: 'aggregation_mode',
STARRED: 'starred',
DISPLAY_NAME_PRIMARY: 'display_name',
} as const;
export const Data = {
_ID: '_id',
RAW_CONTACT_ID: 'raw_contact_id',
MIMETYPE: 'mimetype',
IS_PRIMARY: 'is_primary',
IS_SUPER_PRIMARY: 'is_super_primary',
IS_READ_ONLY: 'is_read_only',
DATA_VERSION: 'data_version',
DATA1: 'data1',
DATA2: 'data2',
DATA3: 'data3',
DATA4: 'data4',
DATA5: 'data5',
DATA6: 'data6',
DATA7: 'data7',
DATA8: 'data8',
DATA9: 'data9',
DATA10: 'data10',
DATA11: 'data11',
DATA12: 'data12',
DATA13: 'data13',
DATA14: 'data14',
DATA15: 'data15',
DATA_SYNC1: 'data_sync1',
DATA_SYNC2: 'data_sync2',
DATA_SYNC3: 'data_sync3',
DATA_SYNC4: 'data_sync4',
} as const;
/** `Data.MIMETYPE` values of the kinds device sync maps. */
export const MimeType = {
STRUCTURED_NAME: 'vnd.android.cursor.item/name',
NICKNAME: 'vnd.android.cursor.item/nickname',
EMAIL: 'vnd.android.cursor.item/email_v2',
PHONE: 'vnd.android.cursor.item/phone_v2',
STRUCTURED_POSTAL: 'vnd.android.cursor.item/postal-address_v2',
ORGANIZATION: 'vnd.android.cursor.item/organization',
WEBSITE: 'vnd.android.cursor.item/website',
EVENT: 'vnd.android.cursor.item/contact_event',
RELATION: 'vnd.android.cursor.item/relation',
NOTE: 'vnd.android.cursor.item/note',
PHOTO: 'vnd.android.cursor.item/photo',
GROUP_MEMBERSHIP: 'vnd.android.cursor.item/group_membership',
} as const;
export const StructuredName = {
DISPLAY_NAME: 'data1',
GIVEN_NAME: 'data2',
FAMILY_NAME: 'data3',
PREFIX: 'data4',
MIDDLE_NAME: 'data5',
SUFFIX: 'data6',
PHONETIC_GIVEN_NAME: 'data7',
PHONETIC_MIDDLE_NAME: 'data8',
PHONETIC_FAMILY_NAME: 'data9',
FULL_NAME_STYLE: 'data10',
PHONETIC_NAME_STYLE: 'data11',
} as const;
/** Columns shared by the typed kinds (Email, Phone, Postal, …). */
export const CommonKind = {
DATA: 'data1',
TYPE: 'data2',
LABEL: 'data3',
} as const;
export const Nickname = { NAME: 'data1', TYPE: 'data2', LABEL: 'data3' } as const;
export const NicknameType = { CUSTOM: 0, DEFAULT: 1, OTHER_NAME: 2, MAIDEN_NAME: 3, SHORT_NAME: 4, INITIALS: 5 } as const;
export const Email = { ADDRESS: 'data1', TYPE: 'data2', LABEL: 'data3', DISPLAY_NAME: 'data4' } as const;
export const EmailType = { CUSTOM: 0, HOME: 1, WORK: 2, OTHER: 3, MOBILE: 4 } as const;
export const Phone = { NUMBER: 'data1', TYPE: 'data2', LABEL: 'data3', NORMALIZED_NUMBER: 'data4' } as const;
export const PhoneType = {
CUSTOM: 0,
HOME: 1,
MOBILE: 2,
WORK: 3,
FAX_WORK: 4,
FAX_HOME: 5,
PAGER: 6,
OTHER: 7,
CALLBACK: 8,
CAR: 9,
COMPANY_MAIN: 10,
ISDN: 11,
MAIN: 12,
OTHER_FAX: 13,
RADIO: 14,
TELEX: 15,
TTY_TDD: 16,
WORK_MOBILE: 17,
WORK_PAGER: 18,
ASSISTANT: 19,
MMS: 20,
} as const;
export const StructuredPostal = {
FORMATTED_ADDRESS: 'data1',
TYPE: 'data2',
LABEL: 'data3',
STREET: 'data4',
POBOX: 'data5',
NEIGHBORHOOD: 'data6',
CITY: 'data7',
REGION: 'data8',
POSTCODE: 'data9',
COUNTRY: 'data10',
} as const;
export const PostalType = { CUSTOM: 0, HOME: 1, WORK: 2, OTHER: 3 } as const;
export const Organization = {
COMPANY: 'data1',
TYPE: 'data2',
LABEL: 'data3',
TITLE: 'data4',
DEPARTMENT: 'data5',
JOB_DESCRIPTION: 'data6',
SYMBOL: 'data7',
PHONETIC_NAME: 'data8',
OFFICE_LOCATION: 'data9',
} as const;
export const OrganizationType = { CUSTOM: 0, WORK: 1, OTHER: 2 } as const;
export const Website = { URL: 'data1', TYPE: 'data2', LABEL: 'data3' } as const;
export const WebsiteType = { CUSTOM: 0, HOMEPAGE: 1, BLOG: 2, PROFILE: 3, HOME: 4, WORK: 5, FTP: 6, OTHER: 7 } as const;
export const ContactEvent = { START_DATE: 'data1', TYPE: 'data2', LABEL: 'data3' } as const;
export const ContactEventType = { CUSTOM: 0, ANNIVERSARY: 1, OTHER: 2, BIRTHDAY: 3 } as const;
export const Relation = { NAME: 'data1', TYPE: 'data2', LABEL: 'data3' } as const;
export const RelationType = {
CUSTOM: 0,
ASSISTANT: 1,
BROTHER: 2,
CHILD: 3,
DOMESTIC_PARTNER: 4,
FATHER: 5,
FRIEND: 6,
MANAGER: 7,
MOTHER: 8,
PARENT: 9,
PARTNER: 10,
REFERRED_BY: 11,
RELATIVE: 12,
SISTER: 13,
SPOUSE: 14,
} as const;
export const Note = { NOTE: 'data1' } as const;
export const Photo = { PHOTO_FILE_ID: 'data14', PHOTO: 'data15' } as const;
export const GroupMembership = { GROUP_ROW_ID: 'data1', GROUP_SOURCE_ID: 'group_sourceid' } as const;
export const Groups = {
_ID: '_id',
ACCOUNT_NAME: 'account_name',
ACCOUNT_TYPE: 'account_type',
SOURCE_ID: 'sourceid',
VERSION: 'version',
DIRTY: 'dirty',
DELETED: 'deleted',
TITLE: 'title',
NOTES: 'notes',
GROUP_VISIBLE: 'group_visible',
SHOULD_SYNC: 'should_sync',
GROUP_IS_READ_ONLY: 'group_is_read_only',
SYNC1: 'sync1',
SYNC2: 'sync2',
SYNC3: 'sync3',
SYNC4: 'sync4',
} as const;
export const ContactsSettings = {
ACCOUNT_NAME: 'account_name',
ACCOUNT_TYPE: 'account_type',
UNGROUPED_VISIBLE: 'ungrouped_visible',
SHOULD_SYNC: 'should_sync',
} as const;
// ─── CalendarContract ──────────────────────────────────
export const Calendars = {
_ID: '_id',
ACCOUNT_NAME: 'account_name',
ACCOUNT_TYPE: 'account_type',
_SYNC_ID: '_sync_id',
DIRTY: 'dirty',
NAME: 'name',
CALENDAR_DISPLAY_NAME: 'calendar_displayName',
CALENDAR_COLOR: 'calendar_color',
CALENDAR_COLOR_KEY: 'calendar_color_index',
CALENDAR_ACCESS_LEVEL: 'calendar_access_level',
OWNER_ACCOUNT: 'ownerAccount',
VISIBLE: 'visible',
SYNC_EVENTS: 'sync_events',
CALENDAR_TIME_ZONE: 'calendar_timezone',
ALLOWED_REMINDERS: 'allowedReminders',
ALLOWED_AVAILABILITY: 'allowedAvailability',
ALLOWED_ATTENDEE_TYPES: 'allowedAttendeeTypes',
MAX_REMINDERS: 'maxReminders',
CAN_ORGANIZER_RESPOND: 'canOrganizerRespond',
CAN_MODIFY_TIME_ZONE: 'canModifyTimeZone',
/** Must stay 0: with 1 the provider keeps a LAST_SYNCED copy of every event an app edits. */
CAN_PARTIALLY_UPDATE: 'canPartiallyUpdate',
IS_PRIMARY: 'isPrimary',
CAL_SYNC1: 'cal_sync1',
CAL_SYNC2: 'cal_sync2',
CAL_SYNC3: 'cal_sync3',
} as const;
/** `Calendars.CALENDAR_ACCESS_LEVEL` values. */
export const CalendarAccess = {
NONE: 0,
FREEBUSY: 100,
READ: 200,
RESPOND: 300,
OVERRIDE: 400,
CONTRIBUTOR: 500,
EDITOR: 600,
OWNER: 700,
ROOT: 800,
} as const;
export const Events = {
_ID: '_id',
CALENDAR_ID: 'calendar_id',
ACCOUNT_NAME: 'account_name',
ACCOUNT_TYPE: 'account_type',
_SYNC_ID: '_sync_id',
DIRTY: 'dirty',
DELETED: 'deleted',
MUTATORS: 'mutators',
SYNC_DATA1: 'sync_data1',
SYNC_DATA2: 'sync_data2',
SYNC_DATA3: 'sync_data3',
SYNC_DATA4: 'sync_data4',
SYNC_DATA5: 'sync_data5',
SYNC_DATA6: 'sync_data6',
UID_2445: 'uid2445',
TITLE: 'title',
DESCRIPTION: 'description',
EVENT_LOCATION: 'eventLocation',
STATUS: 'eventStatus',
AVAILABILITY: 'availability',
ACCESS_LEVEL: 'accessLevel',
EVENT_COLOR: 'eventColor',
EVENT_COLOR_KEY: 'eventColor_index',
ORGANIZER: 'organizer',
IS_ORGANIZER: 'isOrganizer',
SELF_ATTENDEE_STATUS: 'selfAttendeeStatus',
HAS_ALARM: 'hasAlarm',
HAS_ATTENDEE_DATA: 'hasAttendeeData',
GUESTS_CAN_MODIFY: 'guestsCanModify',
GUESTS_CAN_INVITE_OTHERS: 'guestsCanInviteOthers',
GUESTS_CAN_SEE_GUESTS: 'guestsCanSeeGuests',
DTSTART: 'dtstart',
DTEND: 'dtend',
DURATION: 'duration',
EVENT_TIMEZONE: 'eventTimezone',
EVENT_END_TIMEZONE: 'eventEndTimezone',
ALL_DAY: 'allDay',
RRULE: 'rrule',
RDATE: 'rdate',
EXRULE: 'exrule',
EXDATE: 'exdate',
LAST_DATE: 'lastDate',
ORIGINAL_ID: 'original_id',
ORIGINAL_SYNC_ID: 'original_sync_id',
ORIGINAL_INSTANCE_TIME: 'originalInstanceTime',
ORIGINAL_ALL_DAY: 'originalAllDay',
CUSTOM_APP_PACKAGE: 'customAppPackage',
CUSTOM_APP_URI: 'customAppUri',
} as const;
export const EventStatus = { TENTATIVE: 0, CONFIRMED: 1, CANCELED: 2 } as const;
export const EventAvailability = { BUSY: 0, FREE: 1, TENTATIVE: 2 } as const;
export const EventAccess = { DEFAULT: 0, CONFIDENTIAL: 1, PRIVATE: 2, PUBLIC: 3 } as const;
export const Attendees = {
_ID: '_id',
EVENT_ID: 'event_id',
ATTENDEE_NAME: 'attendeeName',
ATTENDEE_EMAIL: 'attendeeEmail',
ATTENDEE_RELATIONSHIP: 'attendeeRelationship',
ATTENDEE_TYPE: 'attendeeType',
ATTENDEE_STATUS: 'attendeeStatus',
} as const;
export const AttendeeRelationship = { NONE: 0, ATTENDEE: 1, ORGANIZER: 2, PERFORMER: 3, SPEAKER: 4 } as const;
export const AttendeeType = { NONE: 0, REQUIRED: 1, OPTIONAL: 2, RESOURCE: 3 } as const;
export const AttendeeStatus = { NONE: 0, ACCEPTED: 1, DECLINED: 2, INVITED: 3, TENTATIVE: 4 } as const;
export const Reminders = {
_ID: '_id',
EVENT_ID: 'event_id',
MINUTES: 'minutes',
METHOD: 'method',
} as const;
export const ReminderMethod = { DEFAULT: 0, ALERT: 1, EMAIL: 2, SMS: 3, ALARM: 4 } as const;
export const ExtendedProperties = {
_ID: '_id',
EVENT_ID: 'event_id',
NAME: 'name',
VALUE: 'value',
} as const;
export const Colors = {
_ID: '_id',
ACCOUNT_NAME: 'account_name',
ACCOUNT_TYPE: 'account_type',
COLOR_TYPE: 'color_type',
COLOR_KEY: 'color_index',
COLOR: 'color',
} as const;
export const ColorType = { CALENDAR: 0, EVENT: 1 } as const;
+17
View File
@@ -0,0 +1,17 @@
/**
* The gate of every app-side device sync feature (docs/device-sync.md, "App
* integration"): Android, and a build that carries the native module. iOS,
* and an Android build without the module, behave as if device sync did not
* exist.
*/
import { supportsDeviceSync } from '../../lib/platform-capabilities';
import { isDeviceSyncAvailable } from '../native';
export function deviceSyncAvailable(): boolean {
if (!supportsDeviceSync) return false;
try {
return isDeviceSyncAvailable();
} catch {
return false;
}
}
+350
View File
@@ -0,0 +1,350 @@
/**
* The address books and calendars an app account can sync, for the chooser in
* Settings (docs/device-sync.md, "Settings sections").
*
* Listed through a JMAPClient of the account's own, never the UI's
* singleton: the chooser covers every signed-in account, and re-binding the
* singleton would send the UI's requests with another account's credentials.
* Collections of the capability's primary account are "personal" (on by
* default); every other session account that advertises the capability holds
* shared ones (off by default). Calendars without `mayReadItems` and
* calendars that hold only tasks are not offered (the engine does not sync
* them); the birthday calendar is the app's own and never on the server.
*/
import { AuthenticationError, JMAPClient, NetworkError } from '../../api/jmap-client';
import type { JMAPMethodCall } from '../../api/types';
import {
findTasksOnlyCalendarIds,
SCAN_PROPERTIES,
type ScannedCalendarObject,
} from '../../lib/calendar-component-detection';
import {
isCollectionSelected,
selectionFor,
syncOnInApp,
useDeviceSyncStore,
type AccountDeviceSync,
} from '../../stores/device-sync-store';
import { collectionKey } from '../common/ids';
import {
CALENDAR_AUTHORITY,
CONTACTS_AUTHORITY,
JMAP_CALENDARS,
JMAP_CONTACTS,
JMAP_CORE,
type Authority,
type CollectionKey,
} from '../types';
export interface SyncCollection {
/** `<jmapAccountId>/<id>`, as in the selections. */
key: CollectionKey;
jmapAccountId: string;
id: string;
name: string;
/** The JMAP account's name (who shares it, for a shared collection). */
accountName: string;
/** In the capability's primary account: synced unless the user turned it off. */
isPersonal: boolean;
/** No write rights, or (calendars) a subscribed feed. */
readOnly: boolean;
isDefault: boolean;
color?: string;
}
/** Why the list could not be loaded. */
export type CollectionsErrorKind = 'signedOut' | 'auth' | 'network' | 'server';
export class CollectionsError extends Error {
constructor(readonly kind: CollectionsErrorKind, message: string) {
super(message);
this.name = 'CollectionsError';
}
}
/** The part of JMAPClient the listing uses. */
export interface CollectionsClient {
loadAccount(registryId: string): Promise<boolean>;
readonly currentSession: {
primaryAccounts: Record<string, string>;
accounts: Record<string, { name: string; isPersonal: boolean; accountCapabilities?: Record<string, unknown> }>;
} | null;
request(calls: JMAPMethodCall[], using?: string[]): Promise<{ methodResponses: Array<[string, any, string]> }>;
getMaxCallsInRequest(): number;
}
export interface ListOptions {
/** Skip the cache. */
force?: boolean;
/** For tests: the client to list with. */
client?: CollectionsClient;
/** Calendars mirroring subscribed iCal feeds (read-only on the device); `accountId` absent = the primary account. */
feeds?: ReadonlyArray<{ calendarId: string; accountId?: string }>;
}
const CACHE_TTL_MS = 2 * 60_000;
/** How many objects of a calendar tell whether it holds only tasks (the engine checks the same). */
const TASK_SCAN_LIMIT = 50;
const ADDRESS_BOOK_PROPERTIES = ['id', 'name', 'isDefault', 'sortOrder', 'myRights'];
const CALENDAR_PROPERTIES = ['id', 'name', 'color', 'isDefault', 'sortOrder', 'isSubscribed', 'myRights'];
const cache = new Map<string, { at: number; collections: SyncCollection[] }>();
const inFlight = new Map<string, Promise<SyncCollection[]>>();
type LoadedListener = (registryId: string, authority: Authority, collections: SyncCollection[]) => void;
const loadedListeners = new Set<LoadedListener>();
const cacheKey = (registryId: string, authority: Authority) => `${registryId}\n${authority}`;
/**
* Calls `listener` with every list loaded from the server, so each view of an
* account's collections (the settings row's counts, the chooser) shows the
* freshest one. Returns the unsubscribe.
*/
export function onSyncCollectionsLoaded(listener: LoadedListener): () => void {
loadedListeners.add(listener);
return () => {
loadedListeners.delete(listener);
};
}
/** The last list loaded for an account, if it is still fresh. */
export function cachedSyncCollections(registryId: string, authority: Authority): SyncCollection[] | null {
const hit = cache.get(cacheKey(registryId, authority));
return hit && Date.now() - hit.at < CACHE_TTL_MS ? hit.collections : null;
}
/** Drops the cached lists of one account, or of all (tests). */
export function forgetSyncCollections(registryId?: string): void {
for (const key of [...cache.keys()]) {
if (!registryId || key.startsWith(`${registryId}\n`)) cache.delete(key);
}
}
/** The collections of one app account for an authority, personal ones first. */
export function listSyncCollections(
registryId: string,
authority: Authority,
options: ListOptions = {},
): Promise<SyncCollection[]> {
const key = cacheKey(registryId, authority);
if (!options.force && !options.client) {
const hit = cachedSyncCollections(registryId, authority);
if (hit) return Promise.resolve(hit);
const running = inFlight.get(key);
if (running) return running;
}
const run = load(registryId, authority, options).then((collections) => {
cache.set(key, { at: Date.now(), collections });
for (const listener of [...loadedListeners]) listener(registryId, authority, collections);
return collections;
});
if (!options.client) {
inFlight.set(key, run);
void run.finally(() => {
if (inFlight.get(key) === run) inFlight.delete(key);
}).catch(() => undefined);
}
return run;
}
async function openClient(registryId: string, client: CollectionsClient): Promise<CollectionsClient> {
let ok: boolean;
try {
ok = await client.loadAccount(registryId);
} catch (err) {
if (err instanceof AuthenticationError || (err instanceof Error && err.name === 'AuthenticationError')) {
throw new CollectionsError('auth', err.message);
}
if (err instanceof NetworkError || (err instanceof Error && err.name === 'NetworkError')) {
throw new CollectionsError('network', err.message);
}
throw new CollectionsError('server', err instanceof Error ? err.message : String(err));
}
if (!ok) throw new CollectionsError('signedOut', 'No stored credentials');
return client;
}
async function send(
client: CollectionsClient,
calls: JMAPMethodCall[],
using: string[],
): Promise<Array<[string, any, string]>> {
const out: Array<[string, any, string]> = [];
// Result references only point inside one request, so a query and the
// get that reads it always go together.
const perRequest = Math.max(2, client.getMaxCallsInRequest() - (client.getMaxCallsInRequest() % 2));
for (let i = 0; i < calls.length; i += perRequest) {
try {
const res = await client.request(calls.slice(i, i + perRequest), using);
out.push(...(res.methodResponses ?? []));
} catch (err) {
if (err instanceof Error && err.name === 'AuthenticationError') throw new CollectionsError('auth', err.message);
throw new CollectionsError('network', err instanceof Error ? err.message : String(err));
}
}
return out;
}
function responseOf(responses: Array<[string, any, string]>, callId: string): any {
const hit = responses.find((r) => r[2] === callId);
return hit && hit[0] !== 'error' ? hit[1] : null;
}
interface RawCollection {
id: string;
name?: string;
color?: string;
isDefault?: boolean;
sortOrder?: number;
myRights?: Record<string, boolean | undefined>;
}
async function load(registryId: string, authority: Authority, options: ListOptions): Promise<SyncCollection[]> {
const client = await openClient(registryId, options.client ?? new JMAPClient());
const session = client.currentSession;
if (!session) throw new CollectionsError('server', 'No session');
const capability = authority === CONTACTS_AUTHORITY ? JMAP_CONTACTS : JMAP_CALENDARS;
const using = [JMAP_CORE, capability];
const capable = Object.entries(session.accounts ?? {})
.filter(([, info]) => !!info?.accountCapabilities?.[capability])
.map(([id]) => id);
const primary = session.primaryAccounts?.[capability] ?? capable[0];
if (!primary) return [];
const accountIds = [primary, ...capable.filter((id) => id !== primary)];
// For the push routes and the trigger filter; only into a store that has
// been read, which would otherwise be overwritten.
if (useDeviceSyncStore.persist.hasHydrated()
&& useDeviceSyncStore.getState().accounts[registryId]?.primaryJmapAccounts?.[authority] !== primary) {
useDeviceSyncStore.getState().rememberPrimaryJmapAccount(registryId, authority, primary);
}
const method = authority === CONTACTS_AUTHORITY ? 'AddressBook/get' : 'Calendar/get';
const properties = authority === CONTACTS_AUTHORITY ? ADDRESS_BOOK_PROPERTIES : CALENDAR_PROPERTIES;
const responses = await send(
client,
accountIds.map((accountId, i) => [method, { accountId, properties }, `g${i}`]),
using,
);
const listed: Array<{ jmapAccountId: string; raw: RawCollection }> = [];
accountIds.forEach((jmapAccountId, i) => {
const body = responseOf(responses, `g${i}`);
// An account that refuses (a revoked share) must not hide the others.
if (!body) {
if (jmapAccountId === primary) throw new CollectionsError('server', `${method} failed`);
return;
}
for (const raw of (body.list ?? []) as RawCollection[]) {
if (raw && typeof raw.id === 'string') listed.push({ jmapAccountId, raw });
}
});
let offered = listed;
if (authority === CALENDAR_AUTHORITY) {
offered = listed.filter(({ raw }) => raw.myRights?.mayReadItems !== false);
const taskOnly = await taskOnlyCalendars(client, offered, using);
offered = offered.filter(({ jmapAccountId, raw }) => !taskOnly.has(collectionKey(jmapAccountId, raw.id)));
}
const feeds = new Set((options.feeds ?? []).map((f) => collectionKey(f.accountId ?? primary, f.calendarId)));
const collections = offered.map(({ jmapAccountId, raw }): SyncCollection => {
const key = collectionKey(jmapAccountId, raw.id);
const rights = raw.myRights;
const readOnly = authority === CONTACTS_AUTHORITY
? rights?.mayWrite === false
: feeds.has(key) || (!!rights && !rights.mayWriteAll && !rights.mayWriteOwn);
return {
key,
jmapAccountId,
id: raw.id,
name: raw.name?.trim() || raw.id,
accountName: session.accounts?.[jmapAccountId]?.name ?? jmapAccountId,
isPersonal: jmapAccountId === primary,
readOnly,
isDefault: !!raw.isDefault,
...(typeof raw.color === 'string' && raw.color ? { color: raw.color } : {}),
};
});
const order = new Map(accountIds.map((id, i) => [id, i]));
const sortOf = new Map(offered.map(({ jmapAccountId, raw }) => [collectionKey(jmapAccountId, raw.id), raw.sortOrder ?? 0]));
return collections.sort((a, b) =>
(order.get(a.jmapAccountId)! - order.get(b.jmapAccountId)!)
|| (sortOf.get(a.key)! - sortOf.get(b.key)!)
|| a.name.localeCompare(b.name));
}
/**
* Calendars whose first objects are all tasks. One query and one get per
* calendar; a calendar whose scan fails is kept (offered).
*/
async function taskOnlyCalendars(
client: CollectionsClient,
calendars: Array<{ jmapAccountId: string; raw: RawCollection }>,
using: string[],
): Promise<Set<CollectionKey>> {
const calls: JMAPMethodCall[] = [];
calendars.forEach(({ jmapAccountId, raw }, i) => {
calls.push(['CalendarEvent/query', { accountId: jmapAccountId, filter: { inCalendar: raw.id }, limit: TASK_SCAN_LIMIT }, `q${i}`]);
calls.push([
'CalendarEvent/get',
{
accountId: jmapAccountId,
'#ids': { resultOf: `q${i}`, name: 'CalendarEvent/query', path: '/ids' },
properties: SCAN_PROPERTIES,
},
`o${i}`,
]);
});
const out = new Set<CollectionKey>();
if (calls.length === 0) return out;
let responses: Array<[string, any, string]>;
try {
responses = await send(client, calls, using);
} catch {
return out;
}
calendars.forEach(({ jmapAccountId, raw }, i) => {
const body = responseOf(responses, `o${i}`);
if (!body) return;
const objects = ((body.list ?? []) as ScannedCalendarObject[])
.map((o) => ({ ...o, calendarIds: { [raw.id]: true } }));
if (findTasksOnlyCalendarIds(objects, [raw.id]).has(raw.id)) out.add(collectionKey(jmapAccountId, raw.id));
});
return out;
}
function serverOf(serverUrl: string): string {
try {
return new URL(serverUrl).host.toLowerCase();
} catch {
return serverUrl.replace(/^https?:\/\//i, '').replace(/\/.*$/, '').toLowerCase();
}
}
/**
* The other signed-in accounts on the same server that already sync this
* collection (the same JMAP account and id): the chooser warns about the
* second copy on the device, it does not refuse it.
*/
export function otherAccountsSyncing(
registryId: string,
authority: Authority,
collection: Pick<SyncCollection, 'key' | 'jmapAccountId'> & Partial<Pick<SyncCollection, 'name'>>,
registry: ReadonlyArray<{ id: string; serverUrl: string; email?: string; username: string }>,
accounts: Record<string, AccountDeviceSync>,
): string[] {
const self = registry.find((a) => a.id === registryId);
if (!self) return [];
const server = serverOf(self.serverUrl);
return registry
.filter((other) => other.id !== registryId && serverOf(other.serverUrl) === server)
.filter((other) => {
const entry = accounts[other.id];
if (!entry || !syncOnInApp(entry, authority)) return false;
const isPersonal = entry.primaryJmapAccounts?.[authority] === collection.jmapAccountId;
return isCollectionSelected(selectionFor(entry, authority), authority, { ...collection, isPersonal });
})
.map((other) => other.email || other.username);
}
+40
View File
@@ -0,0 +1,40 @@
/**
* The questions device sync asks outside the settings screen, as system
* alerts: signing out while changes made on the device have not reached the
* server (docs/device-sync.md, "Lifecycle").
*/
import { Alert } from 'react-native';
import { t } from '../../stores/locale-store';
/**
* Asks whether to sign out although `pending` device changes (unknown when
* the upload could not even be tried) would be lost. Resolves true to go on.
*/
export function confirmSignOutLosingChanges(pending: number): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
const answer = (value: boolean) => {
if (settled) return;
settled = true;
resolve(value);
};
Alert.alert(
t('device_sync.sign_out_pending_title', 'Sign out anyway?'),
pending > 0
? t(
'device_sync.sign_out_pending_message',
"{count, plural, one {# change made on this device hasn't reached the server. It is lost if you sign out now.} other {# changes made on this device haven't reached the server. They are lost if you sign out now.}}",
{ count: pending },
)
: t(
'device_sync.sign_out_unknown_message',
"Changes made on this device may not have reached the server. They are lost if you sign out now.",
),
[
{ text: t('common.cancel', 'Cancel'), style: 'cancel', onPress: () => answer(false) },
{ text: t('device_sync.sign_out_anyway', 'Sign out'), style: 'destructive', onPress: () => answer(true) },
],
{ cancelable: true, onDismiss: () => answer(false) },
);
});
}
+699
View File
@@ -0,0 +1,699 @@
/**
* Turning device sync on and off, signing out, and keeping Android's accounts
* in step with the app's account registry (docs/device-sync.md, "Lifecycle").
*
* - Enable: permissions (asked by the settings first) → the Android account
* → automatic sync on → the periodic sync → a manual sync → push routes and
* push types.
* - Disable an authority: automatic sync off, then the engine's teardown
* uploads what the device changed and deletes the authority's rows. When
* changes could not be uploaded nothing is deleted and the caller asks the
* user; only a confirmed turn-off deletes them. With both authorities off
* the Android account goes.
* - Sign-out: the same for every authority, before the credentials go.
* - Reconcile (launch, foreground, accounts changed): an Android account whose
* app account the registry dropped is suspended (sync off, one notification,
* data kept); an app account whose Android account was removed in Android
* Settings shows sync off and is never recreated on its own.
*
* The engine (`../task`) is imported on first use only, so starting the app
* does not load it.
*/
import {
clearSyncProblem,
ensureAndroidAccount,
getSyncSettings,
listAndroidAccounts,
openAccountSettings,
removeAndroidAccount,
requestSync,
setPeriodicSync,
setPushRoutes,
setSyncEnabled,
showSyncProblem,
} from '../native';
import {
AUTHORITIES,
CALENDAR_AUTHORITY,
type AndroidAccount,
type Authority,
type ReminderOwner,
} from '../types';
import { useAccountStore } from '../../stores/account-store';
import {
accountDeviceSync,
anySyncOnInApp,
computePushRoutes,
intervalFor,
syncOnInApp,
useDeviceSyncStore,
waitForDeviceSyncHydration,
type AccountDeviceSync,
} from '../../stores/device-sync-store';
import { t } from '../../stores/locale-store';
import { toast } from '../../stores/toast-store';
import { refreshPushSubscriptionTypes } from '../../lib/push-notifications';
import { deviceSyncAvailable } from './available';
import { confirmSignOutLosingChanges } from './confirm';
import { hasSyncPermissions } from './permissions';
import { STATE_TYPES_BY_AUTHORITY } from './prefs';
import { cancelDeviceSyncRequests } from './request';
/** How long the teardown may spend uploading before it gives up (and asks). */
export const TEARDOWN_TIMEOUT_MS = 30_000;
/** After the user agreed to lose what is waiting: one short last try to upload it. */
export const FORCED_TEARDOWN_TIMEOUT_MS = 5_000;
/** Where the "sign in again" notification of a suspended account leads: the accounts pane. */
export const SIGN_IN_AGAIN_URI = 'bulwarkmobile://settings/account';
const REGISTRY_HYDRATION_TIMEOUT_MS = 5000;
const noop = () => undefined;
function errorMessage(err: unknown): string {
return err instanceof Error && err.message ? err.message : String(err);
}
/** The registry, once read; null when it could not be read in time. */
async function readRegistry(): Promise<ReturnType<typeof useAccountStore.getState>['accounts'] | null> {
const persist = useAccountStore.persist;
if (!persist.hasHydrated()) {
const hydrated = await new Promise<boolean>((resolve) => {
const timer = setTimeout(() => {
unsubscribe();
resolve(false);
}, REGISTRY_HYDRATION_TIMEOUT_MS);
const unsubscribe = persist.onFinishHydration(() => {
clearTimeout(timer);
unsubscribe();
resolve(true);
});
});
if (!hydrated) return null;
}
return useAccountStore.getState().accounts;
}
// ─── Android account names ─────────────────────────────
/**
* The Android account name for an app account: its login, or its registry id
* when another app account's Android account already has that name (the same
* login on two servers).
*/
export function preferredAndroidAccountName(registryId: string, androidAccounts: readonly AndroidAccount[]): string {
const login = useAccountStore.getState().getAccountById(registryId)?.username?.trim();
if (!login) return registryId;
const takenOnDevice = androidAccounts.some((a) => a.name === login && a.registryId !== registryId);
const takenInApp = Object.entries(useDeviceSyncStore.getState().accounts)
.some(([id, entry]) => id !== registryId && entry.androidAccountName === login);
return takenOnDevice || takenInApp ? registryId : login;
}
/** Creates the app account's Android account if needed; resolves its name. */
async function ensureAccountFor(registryId: string): Promise<string> {
const androidAccounts = await listAndroidAccounts();
const own = androidAccounts.find((a) => a.registryId === registryId);
if (own) return own.name;
const name = preferredAndroidAccountName(registryId, androidAccounts);
try {
await ensureAndroidAccount(name, registryId);
return name;
} catch (err) {
// The name belongs to another app account after all (the list was stale). Any
// other failure fails the turn-on: a second account would show in the apps.
if (name === registryId || (err as { code?: unknown } | null)?.code !== 'conflict') throw err;
await ensureAndroidAccount(registryId, registryId);
return registryId;
}
}
async function androidAccountOf(registryId: string): Promise<AndroidAccount | null> {
try {
return (await listAndroidAccounts()).find((a) => a.registryId === registryId) ?? null;
} catch {
return null;
}
}
// ─── Push ──────────────────────────────────────────────
let lastRoutesJson: string | null = null;
/** Hands the native push router the JMAP accounts that feed each Android account. */
export async function refreshPushRoutes(): Promise<void> {
if (!deviceSyncAvailable()) return;
await waitForDeviceSyncHydration();
const routes = computePushRoutes(useDeviceSyncStore.getState().accounts);
const json = JSON.stringify(routes);
if (json === lastRoutesJson) return;
try {
await setPushRoutes(routes);
lastRoutesJson = json;
} catch {
// Kept for the next refresh.
}
}
/** Push routes plus the account's push subscription types, after a toggle changed. */
async function refreshPushState(registryId: string): Promise<void> {
await refreshPushRoutes();
await refreshPushSubscriptionTypes(registryId).catch(noop);
}
// ─── One step at a time ────────────────────────────────
// Turning sync on and off for one app account runs one step after the other:
// a turn-on that starts while a turn-off still tears down (the user left the
// settings and came back) would otherwise be undone by it, down to the
// Android account being removed right after it was turned on again.
const accountSteps = new Map<string, Promise<unknown>>();
function oneAtATime<T>(registryId: string, step: () => Promise<T>): Promise<T> {
const next = (accountSteps.get(registryId) ?? Promise.resolve()).catch(noop).then(step);
const settled = next.catch(noop);
accountSteps.set(registryId, settled);
void settled.then(() => {
if (accountSteps.get(registryId) === settled) accountSteps.delete(registryId);
});
return next;
}
/** One step in the queue of every one of the accounts, taken in a fixed order so two such steps never wait for each other. */
function oneAtATimeForAll<T>(registryIds: readonly string[], step: () => Promise<T>): Promise<T> {
const [first, ...rest] = registryIds;
return first === undefined ? step() : oneAtATime(first, () => oneAtATimeForAll(rest, step));
}
// Sign-outs under way, per app account: true once they released it.
const signOuts = new Map<string, Promise<boolean>>();
/**
* A turn-on or turn-off of one app account, in its queue. One asked for
* while the account signs out runs after the sign-out, and does nothing
* (`released`) when that released the account.
*/
function accountStep<T>(registryId: string, step: () => Promise<T>, released: T): Promise<T> {
const signOut = signOuts.get(registryId);
return oneAtATime(registryId, async () => ((await signOut) ? released : step()));
}
// ─── Enable ────────────────────────────────────────────
export type EnableOutcome =
| { kind: 'enabled'; accountName: string }
/** The runtime permissions are missing: the caller asks for them first. */
| { kind: 'permission' }
| { kind: 'failed'; message: string };
/**
* Turns device sync on for one authority of an app account. The caller has
* asked for the permissions (and, for calendars, who reminds the user).
*/
export function enableDeviceSync(registryId: string, authority: Authority): Promise<EnableOutcome> {
return accountStep(registryId, () => enable(registryId, authority), { kind: 'failed', message: 'The account was signed out' });
}
async function enable(registryId: string, authority: Authority): Promise<EnableOutcome> {
if (!deviceSyncAvailable()) return { kind: 'failed', message: 'Device sync is not available' };
if (!(await hasSyncPermissions(authority))) return { kind: 'permission' };
await waitForDeviceSyncHydration();
let accountName: string | null = null;
try {
accountName = await ensureAccountFor(registryId);
useDeviceSyncStore.getState().update(registryId, {
androidAccountName: accountName,
removedInAndroidSettings: undefined,
suspended: undefined,
});
await setSyncEnabled(accountName, authority, true);
await setPeriodicSync(accountName, authority, intervalFor(accountDeviceSync(registryId), authority));
const entry = accountDeviceSync(registryId);
useDeviceSyncStore.getState().update(registryId, { enabled: { ...entry.enabled, [authority]: true } });
await requestSync(accountName, authority, { manual: true }).catch(noop);
void refreshPushState(registryId);
return { kind: 'enabled', accountName };
} catch (err) {
if (accountName) {
await setSyncEnabled(accountName, authority, false).catch(noop);
// Don't leave an account behind that syncs nothing.
if (!anySyncOnInApp(accountDeviceSync(registryId))) {
useDeviceSyncStore.getState().update(registryId, { androidAccountName: undefined });
await removeAndroidAccount(accountName).catch(noop);
}
}
return { kind: 'failed', message: errorMessage(err) };
}
}
/** Automatic sync back on after the user kept syncing instead of losing changes. */
export function resumeDeviceSync(registryId: string, authority: Authority): Promise<void> {
return accountStep(registryId, () => resume(registryId, authority), undefined);
}
async function resume(registryId: string, authority: Authority): Promise<void> {
if (!deviceSyncAvailable()) return;
await waitForDeviceSyncHydration();
const accountName = accountDeviceSync(registryId).androidAccountName ?? (await androidAccountOf(registryId))?.name;
if (!accountName) return;
await setSyncEnabled(accountName, authority, true).catch(noop);
await requestSync(accountName, authority, { manual: true }).catch(noop);
}
// ─── Disable ───────────────────────────────────────────
export interface TurnOffResult {
/** Rows deleted and the authority off in the app. */
done: boolean;
/** Device changes that could not be uploaded (nothing was deleted). */
pending: number;
/** The teardown itself failed: whether changes are waiting is unknown. */
failed?: boolean;
}
interface TeardownResult {
pending: number;
failed: boolean;
}
// The engine, loaded once on first use (sign-outs tear several accounts down
// at the same time).
let engine: Promise<typeof import('../task')> | null = null;
function loadEngine(): Promise<typeof import('../task')> {
engine ??= import('../task').catch((err: unknown) => {
engine = null;
throw err;
});
return engine;
}
async function teardown(
registryId: string,
accountName: string,
authority: Authority,
force: boolean,
): Promise<TeardownResult> {
try {
const { teardownAuthority } = await loadEngine();
const result = await teardownAuthority(
registryId,
accountName,
authority,
force
? { force: true, timeoutMs: FORCED_TEARDOWN_TIMEOUT_MS }
: { uploadFirst: true, timeoutMs: TEARDOWN_TIMEOUT_MS },
);
return { pending: Math.max(0, result?.pending ?? 0), failed: false };
} catch (err) {
console.warn('[device-sync] teardown failed', errorMessage(err));
return { pending: 0, failed: true };
}
}
/** The authority's JMAP states, dropped with its rows so no old state passes for an echo. */
function withoutAuthorityStates(entry: AccountDeviceSync, authority: Authority): AccountDeviceSync['knownStates'] {
if (!entry.knownStates) return undefined;
const types = STATE_TYPES_BY_AUTHORITY[authority];
const out: Record<string, Record<string, string>> = {};
for (const [jmapAccountId, states] of Object.entries(entry.knownStates)) {
const kept = Object.fromEntries(Object.entries(states ?? {}).filter(([type]) => !types.includes(type)));
if (Object.keys(kept).length > 0) out[jmapAccountId] = kept;
}
return Object.keys(out).length > 0 ? out : undefined;
}
/**
* Turns device sync off for one authority. Uploads what the device changed
* first; when that is not possible, nothing is deleted and the result says
* how many changes are waiting: call again with `force` once the user agreed
* to lose them, or `resumeDeviceSync` to keep syncing.
*/
export function disableDeviceSync(
registryId: string,
authority: Authority,
options: { force?: boolean } = {},
): Promise<TurnOffResult> {
// Released for signing out meanwhile: nothing of it is left on the device.
return accountStep(registryId, () => disable(registryId, authority, options), { done: true, pending: 0 });
}
async function disable(registryId: string, authority: Authority, options: { force?: boolean }): Promise<TurnOffResult> {
if (!deviceSyncAvailable()) return { done: true, pending: 0 };
await waitForDeviceSyncHydration();
const store = useDeviceSyncStore.getState();
const accountName = accountDeviceSync(registryId).androidAccountName ?? (await androidAccountOf(registryId))?.name;
cancelDeviceSyncRequests(registryId, authority);
if (!accountName) {
store.setEnabled(registryId, authority, false);
return { done: true, pending: 0 };
}
await setSyncEnabled(accountName, authority, false).catch(noop);
const result = await teardown(registryId, accountName, authority, !!options.force);
if (result.failed || result.pending > 0) {
return { done: false, pending: result.pending, failed: result.failed || undefined };
}
const before = accountDeviceSync(registryId);
const { [authority]: _lastRun, ...lastRun } = before.lastRun;
useDeviceSyncStore.getState().update(registryId, {
enabled: { ...before.enabled, [authority]: false },
lastRun,
knownStates: withoutAuthorityStates(before, authority),
// Asked again the next time calendar sync is turned on.
...(authority === CALENDAR_AUTHORITY ? { reminderOwner: null } : {}),
});
if (!anySyncOnInApp(accountDeviceSync(registryId))) {
// Forget the name first: the removal fires `accountsChanged`, and the
// reconcile must not take it for a removal in Android Settings.
useDeviceSyncStore.getState().update(registryId, { androidAccountName: undefined, knownStates: undefined });
await removeAndroidAccount(accountName).catch(noop);
await clearSyncProblem(accountName).catch(noop);
}
void refreshPushState(registryId);
return { done: true, pending: 0 };
}
// ─── Sign-out ──────────────────────────────────────────
/**
* Before app accounts sign out (`auth-store` logout, logoutAll,
* removeAccount): uploads what their devices changed, deletes the synced
* rows and removes their Android accounts, while the credentials are still
* there. When changes could not be uploaded, `confirm` asks whether to sign
* out anyway. Resolves false when the user chose to stay signed in; their
* accounts then keep syncing. It is a step of the accounts' own turn-ons and
* turn-offs: it waits for those under way, and those asked for meanwhile find
* the accounts released and do nothing.
*/
export function releaseDeviceSyncBeforeSignOut(
registryIds: readonly string[],
confirm: (pending: number) => Promise<boolean> = confirmSignOutLosingChanges,
): Promise<boolean> {
if (!deviceSyncAvailable() || registryIds.length === 0) return Promise.resolve(true);
const ids = [...new Set(registryIds)].sort();
const signOut = oneAtATimeForAll(ids, async () => {
try {
return await release(registryIds, confirm);
} catch (err) {
// Never keep the user from signing out over a bug here; an Android
// account left behind is suspended by the next reconcile.
console.warn('[device-sync] release before sign-out failed', errorMessage(err));
return true;
}
});
for (const id of ids) signOuts.set(id, signOut);
void signOut.then(() => {
for (const id of ids) if (signOuts.get(id) === signOut) signOuts.delete(id);
});
return signOut;
}
async function release(
registryIds: readonly string[],
confirm: (pending: number) => Promise<boolean>,
): Promise<boolean> {
await waitForDeviceSyncHydration();
let androidAccounts: AndroidAccount[] = [];
try {
androidAccounts = await listAndroidAccounts();
} catch {
androidAccounts = [];
}
const targets = registryIds.flatMap((registryId) => {
const accountName = androidAccounts.find((a) => a.registryId === registryId)?.name
?? accountDeviceSync(registryId).androidAccountName;
return accountName ? [{ registryId, accountName }] : [];
});
if (targets.length > 0) {
toast.info(t('device_sync.signing_out', 'Removing synced contacts and calendars from this device…'));
}
// Accounts in parallel (each has its own server and run lock), the
// authorities of one account one after the other.
const leftovers: Array<{ registryId: string; accountName: string; authority: Authority }> = [];
let pending = 0;
await Promise.all(targets.map(async ({ registryId, accountName }) => {
cancelDeviceSyncRequests(registryId);
for (const authority of AUTHORITIES) {
await setSyncEnabled(accountName, authority, false).catch(noop);
const result = await teardown(registryId, accountName, authority, false);
if (result.failed || result.pending > 0) {
leftovers.push({ registryId, accountName, authority });
pending += result.pending;
}
}
}));
if (leftovers.length > 0) {
if (!(await confirm(pending))) {
// Staying signed in: sync goes on where it was on.
for (const { registryId, accountName } of targets) {
const entry = accountDeviceSync(registryId);
for (const authority of AUTHORITIES) {
if (syncOnInApp(entry, authority)) await setSyncEnabled(accountName, authority, true).catch(noop);
}
}
return false;
}
for (const { registryId, accountName, authority } of leftovers) {
await teardown(registryId, accountName, authority, true);
}
}
// Forgotten before the removal: its `accountsChanged` must not read as a
// removal in Android Settings.
for (const registryId of registryIds) {
cancelDeviceSyncRequests(registryId);
useDeviceSyncStore.getState().forget(registryId);
}
for (const { accountName } of targets) {
await removeAndroidAccount(accountName).catch(noop);
await clearSyncProblem(accountName).catch(noop);
}
void refreshPushRoutes();
return true;
}
// ─── Sign-in and reconcile ─────────────────────────────
/**
* After an app account signed in successfully: clears its "sign in again"
* notification, resumes a suspended account where it synced, and syncs what
* a failed sign-in held back.
*/
export async function deviceSyncSignedIn(registryId: string): Promise<void> {
if (!deviceSyncAvailable()) return;
try {
await waitForDeviceSyncHydration();
const account = await androidAccountOf(registryId);
if (!account) return;
await clearSyncProblem(account.name).catch(noop);
const entry = accountDeviceSync(registryId);
useDeviceSyncStore.getState().update(registryId, { androidAccountName: account.name, suspended: undefined });
const resumed = accountDeviceSync(registryId);
for (const authority of AUTHORITIES) {
if (!syncOnInApp(resumed, authority)) continue;
if (entry.suspended) await setSyncEnabled(account.name, authority, true).catch(noop);
await requestSync(account.name, authority, { manual: true }).catch(noop);
}
await refreshPushRoutes();
} catch {
// Best effort: the next reconcile catches up.
}
}
/** An Android account whose app account the registry dropped: sync off, one notification, data kept. */
async function suspendAccount(account: AndroidAccount & { registryId: string }): Promise<void> {
const entry = useDeviceSyncStore.getState().accounts[account.registryId];
if (entry?.suspended) return;
// Remember what synced, so signing in again resumes exactly that.
const enabled: Partial<Record<Authority, boolean>> = { ...entry?.enabled };
try {
const settings = await getSyncSettings(account.name);
for (const authority of AUTHORITIES) {
if (settings.authorities?.[authority]?.automatic) enabled[authority] = true;
}
} catch {
// What the app knows will do.
}
for (const authority of AUTHORITIES) await setSyncEnabled(account.name, authority, false).catch(noop);
cancelDeviceSyncRequests(account.registryId);
useDeviceSyncStore.getState().update(account.registryId, {
androidAccountName: account.name,
enabled,
suspended: true,
});
await showSyncProblem(
account.name,
t('device_sync.signed_out_title', 'Sign in to keep syncing'),
t(
'device_sync.signed_out_text',
'{account} was signed out, so its contacts and calendars on this device no longer sync. Sign in again to resume. Changes made on this device are kept.',
{ account: account.name },
),
SIGN_IN_AGAIN_URI,
t('device_sync.problem_channel', 'Sync problems'),
).catch(noop);
}
let reconciling: Promise<void> | null = null;
/**
* Brings the app's view in step with Android's accounts: at launch, on
* foreground and when accounts of our type change. Concurrent calls share
* one pass.
*/
export function reconcileDeviceAccounts(): Promise<void> {
if (!deviceSyncAvailable()) return Promise.resolve();
if (!reconciling) {
reconciling = reconcile()
.catch((err) => console.warn('[device-sync] reconcile failed', errorMessage(err)))
.finally(() => {
reconciling = null;
});
}
return reconciling;
}
async function reconcile(): Promise<void> {
await waitForDeviceSyncHydration();
const registry = await readRegistry();
// Never act on an empty or unreadable registry: a failed read must not
// suspend or forget anything.
if (!registry || registry.length === 0) return;
const androidAccounts = await listAndroidAccounts();
const registryIds = new Set(registry.map((a) => a.id));
const store = () => useDeviceSyncStore.getState();
// Android accounts that are gone: removed in Android Settings (or by the
// system). Sync shows off and the app does not recreate them.
for (const [registryId, entry] of Object.entries(store().accounts)) {
const name = entry.androidAccountName;
if (!name || androidAccounts.some((a) => a.name === name && (a.registryId ?? registryId) === registryId)) continue;
cancelDeviceSyncRequests(registryId);
if (!registryIds.has(registryId)) {
// Neither signed in nor on the device any more: nothing left to keep.
store().forget(registryId);
continue;
}
store().update(registryId, {
androidAccountName: undefined,
enabled: {},
suspended: undefined,
knownStates: undefined,
removedInAndroidSettings: anySyncOnInApp(entry) || entry.removedInAndroidSettings || undefined,
});
void refreshPushSubscriptionTypes(registryId).catch(noop);
}
for (const account of androidAccounts) {
const registryId = account.registryId;
if (!registryId) continue;
if (!registryIds.has(registryId)) {
await suspendAccount({ ...account, registryId });
continue;
}
const entry = accountDeviceSync(registryId);
if (entry.suspended) {
// Signed in again through a path that did not report it.
await deviceSyncSignedIn(registryId);
continue;
}
// Android is the source of truth: an authority turned on in Android
// Settings counts as on in the app too.
const enabled = { ...entry.enabled };
let changed = entry.androidAccountName !== account.name || !!entry.removedInAndroidSettings;
try {
const settings = await getSyncSettings(account.name);
for (const authority of AUTHORITIES) {
if (settings.authorities?.[authority]?.automatic && !enabled[authority]) {
enabled[authority] = true;
changed = true;
}
}
} catch {
// Keep what the app knows.
}
if (changed) {
store().update(registryId, { androidAccountName: account.name, enabled, removedInAndroidSettings: undefined });
}
}
await refreshPushRoutes();
}
// ─── Settings actions ──────────────────────────────────
/** The periodic sync interval (0 = manual only). */
export async function changeSyncInterval(registryId: string, authority: Authority, seconds: number): Promise<void> {
await waitForDeviceSyncHydration();
useDeviceSyncStore.getState().setIntervalSeconds(registryId, authority, seconds);
const entry = accountDeviceSync(registryId);
if (!deviceSyncAvailable() || !entry.androidAccountName || !syncOnInApp(entry, authority)) return;
await setPeriodicSync(entry.androidAccountName, authority, intervalFor(entry, authority)).catch(noop);
}
/** Who reminds the user of synced events; a change rewrites the device's reminders with a calendar sync. */
export async function changeReminderOwner(registryId: string, owner: ReminderOwner): Promise<void> {
await waitForDeviceSyncHydration();
const before = accountDeviceSync(registryId).reminderOwner;
useDeviceSyncStore.getState().setReminderOwner(registryId, owner);
const entry = accountDeviceSync(registryId);
if (before === owner || !deviceSyncAvailable() || !entry.androidAccountName) return;
if (!syncOnInApp(entry, CALENDAR_AUTHORITY)) return;
await requestSync(entry.androidAccountName, CALENDAR_AUTHORITY, { manual: true }).catch(noop);
}
/** A sync right away: manual and expedited. Resolves false when the account has no Android account. */
export async function syncNow(registryId: string, authority: Authority): Promise<boolean> {
const accountName = accountDeviceSync(registryId).androidAccountName;
if (!deviceSyncAvailable() || !accountName) return false;
await requestSync(accountName, authority, { manual: true, expedited: true });
return true;
}
/**
* After a run held back many deletions made on the device
* (`tooManyDeletions`): delete those items on the server too, or bring them
* back on the device from the server. Android's own screens offer neither.
*/
export async function resolveDeletions(registryId: string, authority: Authority, choice: 'delete' | 'restore'): Promise<boolean> {
const accountName = accountDeviceSync(registryId).androidAccountName;
if (!deviceSyncAvailable() || !accountName) return false;
await requestSync(
accountName,
authority,
choice === 'delete' ? { manual: true, overrideTooManyDeletions: true } : { manual: true, discardLocalDeletions: true },
);
return true;
}
/** Android's sync screen of the account ("Android account settings"). */
export async function openAndroidAccountSettings(registryId: string): Promise<boolean> {
const accountName = accountDeviceSync(registryId).androidAccountName;
if (!deviceSyncAvailable() || !accountName) return false;
await openAccountSettings(accountName);
return true;
}
/**
* Drops a suspended account (one the app signed out while it synced) from
* this device: its Android account and, with it, every synced row. Changes
* made on the device that never reached the server are lost; the caller
* asks first.
*/
export async function removeSuspendedAccount(registryId: string): Promise<void> {
if (!deviceSyncAvailable()) return;
await waitForDeviceSyncHydration();
const accountName = accountDeviceSync(registryId).androidAccountName ?? (await androidAccountOf(registryId))?.name;
cancelDeviceSyncRequests(registryId);
// Forgotten first, so the removal's `accountsChanged` finds nothing to mark.
useDeviceSyncStore.getState().forget(registryId);
if (accountName) {
await removeAndroidAccount(accountName).catch(noop);
await clearSyncProblem(accountName).catch(noop);
}
void refreshPushRoutes();
}
+54
View File
@@ -0,0 +1,54 @@
/**
* Runtime permissions device sync needs (docs/device-sync.md, "Settings
* sections"): read and write access to contacts or calendars. Without them
* Android fails every sync before our code runs, so the settings check them
* themselves.
*/
import { Linking, PermissionsAndroid } from 'react-native';
import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY, type Authority } from '../types';
type Permission = Parameters<typeof PermissionsAndroid.check>[0];
export const SYNC_PERMISSIONS: Record<Authority, readonly Permission[]> = {
[CONTACTS_AUTHORITY]: ['android.permission.READ_CONTACTS', 'android.permission.WRITE_CONTACTS'],
[CALENDAR_AUTHORITY]: ['android.permission.READ_CALENDAR', 'android.permission.WRITE_CALENDAR'],
};
/**
* `granted`: both permissions held. `denied`: the user said no; asking again
* shows the dialog again. `blocked`: Android no longer asks ("don't ask
* again", or denied twice), only the app's system settings can grant them.
*/
export type PermissionOutcome = 'granted' | 'denied' | 'blocked';
export async function hasSyncPermissions(authority: Authority): Promise<boolean> {
try {
const held = await Promise.all(SYNC_PERMISSIONS[authority].map((p) => PermissionsAndroid.check(p)));
return held.every(Boolean);
} catch {
return false;
}
}
export async function requestSyncPermissions(authority: Authority): Promise<PermissionOutcome> {
if (await hasSyncPermissions(authority)) return 'granted';
const wanted = SYNC_PERMISSIONS[authority];
try {
const results = (await PermissionsAndroid.requestMultiple([...wanted])) as Record<string, string | undefined>;
const answers = wanted.map((p) => results[p]);
if (answers.every((a) => a === 'granted')) return 'granted';
if (answers.some((a) => a === 'never_ask_again')) return 'blocked';
return 'denied';
} catch {
return 'denied';
}
}
/** The app's page in Android's settings, where blocked permissions are granted. */
export async function openAppSystemSettings(): Promise<void> {
try {
await Linking.openSettings();
} catch {
// Nothing else to offer.
}
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Pure helpers over the persisted device sync preferences, shared by the
* store (src/stores/device-sync-store.ts) and by code that must not create
* the store: the push setup reads the persisted JSON straight from
* AsyncStorage (docs/device-sync.md, "Triggers").
*
* Types only; nothing here may import react-native or a store.
*/
import {
AUTHORITIES,
CALENDAR_AUTHORITY,
CONTACTS_AUTHORITY,
type Authority,
} from '../types';
/** AsyncStorage key of the device sync store (zustand `persist` name). */
export const DEVICE_SYNC_STORAGE_KEY = 'device-sync:v1';
/** The fields that say whether an account syncs an authority. */
export interface SyncSwitches {
/** Authorities the user turned on in the app. */
enabled?: Partial<Record<Authority, boolean>>;
/** The Android account was removed in Android Settings. */
removedInAndroidSettings?: boolean;
/** The app dropped the account from its registry; automatic sync is off until the user signs in again. */
suspended?: boolean;
}
/**
* Whether the app has device sync on for this authority: turned on in the
* app and not removed in Android Settings since. Android's own toggle can
* still pause it (see `getSyncAutomatically`).
*/
export function syncOnInApp(entry: SyncSwitches | undefined | null, authority: Authority): boolean {
return !!entry && !!entry.enabled?.[authority] && !entry.removedInAndroidSettings && !entry.suspended;
}
/** JMAP data types whose changes a synced authority cares about. */
export const STATE_TYPES_BY_AUTHORITY: Record<Authority, readonly string[]> = {
[CONTACTS_AUTHORITY]: ['ContactCard', 'AddressBook'],
[CALENDAR_AUTHORITY]: ['CalendarEvent', 'Calendar'],
};
/** The authority a JMAP data type belongs to, if any. */
export function authorityOfType(type: string): Authority | null {
for (const authority of AUTHORITIES) {
if (STATE_TYPES_BY_AUTHORITY[authority].includes(type)) return authority;
}
return null;
}
/**
* Push types device sync adds to an account's push subscription:
* `ContactCard`, `AddressBook` while contacts sync, `CalendarEvent`,
* `Calendar` while calendars sync.
*/
export function deviceSyncPushTypes(entry: SyncSwitches | undefined | null): string[] {
const types: string[] = [];
for (const authority of AUTHORITIES) {
if (syncOnInApp(entry, authority)) types.push(...STATE_TYPES_BY_AUTHORITY[authority]);
}
return types;
}
/**
* The persisted accounts map from the store's JSON (`{ state: { accounts } }`),
* or an empty map when it is missing or unreadable.
*/
export function persistedAccounts(raw: string | null): Record<string, SyncSwitches> {
if (!raw) return {};
try {
const parsed = JSON.parse(raw) as { state?: { accounts?: unknown } } | null;
const accounts = parsed?.state?.accounts;
return accounts && typeof accounts === 'object' && !Array.isArray(accounts)
? (accounts as Record<string, SyncSwitches>)
: {};
} catch {
return {};
}
}
+142
View File
@@ -0,0 +1,142 @@
/**
* Who reminds the user of synced events (docs/device-sync.md, "Reminder
* owner"). When an account's calendars sync to the device and the calendar
* app owns the reminders, the device fires them from its Reminders rows, so
* Bulwark's own scheduler skips the events of those calendars. Tasks are not
* synced and keep Bulwark's reminders.
*
* Only while the device calendar receives the server's events, as Android
* last said: not while the account's calendar sync is paused in Android's
* settings, the device's auto-sync is off (Android then runs manual syncs
* only, and the periodic, pushed and app-triggered ones are not manual), or
* the calendar permission is revoked. Bulwark reminds then, and until
* Android's side was read: a double reminder is better than a missed one.
*/
import type { CalendarEvent } from '../../api/types';
import { getSyncSettings } from '../native';
import {
isCollectionSelected,
selectionFor,
syncOnInApp,
useDeviceSyncStore,
waitForDeviceSyncHydration,
type AccountDeviceSync,
} from '../../stores/device-sync-store';
import { collectionKey } from '../common/ids';
import { CALENDAR_AUTHORITY } from '../types';
import { deviceSyncAvailable } from './available';
import { hasSyncPermissions } from './permissions';
/** Whether the device's calendar app fires the reminders of an account's synced events. */
function deviceOwnsReminders(entry: AccountDeviceSync | undefined): entry is AccountDeviceSync {
// Unasked means the calendar app, as the engine writes Reminders rows then.
return !!entry && syncOnInApp(entry, CALENDAR_AUTHORITY) && (entry.reminderOwner ?? 'device') === 'device';
}
// ─── What Android says ─────────────────────────────────
/** App accounts whose device calendar receives the server's events, as last read from Android. */
let receiving = new Set<string>();
const androidListeners = new Set<() => void>();
let reads = 0;
async function calendarReceives(accountName: string | undefined): Promise<boolean> {
if (!accountName) return false;
try {
const settings = await getSyncSettings(accountName);
return settings.masterAutomatic !== false && !!settings.authorities?.[CALENDAR_AUTHORITY]?.automatic;
} catch {
return false;
}
}
/**
* Reads from Android whose device calendars receive the server's events;
* a change calls the `onDeviceRemindersChange` listeners. Only the latest
* read lands.
*/
async function readAndroid(): Promise<void> {
const read = ++reads;
await waitForDeviceSyncHydration();
const owners = Object.entries(useDeviceSyncStore.getState().accounts).filter(([, entry]) => deviceOwnsReminders(entry));
const next = new Set<string>();
if (owners.length > 0 && (await hasSyncPermissions(CALENDAR_AUTHORITY))) {
for (const [registryId, entry] of owners) {
if (await calendarReceives(entry.androidAccountName)) next.add(registryId);
}
}
if (read !== reads) return;
if (next.size === receiving.size && [...next].every((id) => receiving.has(id))) return;
receiving = next;
for (const listener of [...androidListeners]) listener();
}
// ─── The filter ────────────────────────────────────────
/**
* A test for the events whose reminders the device's calendar app fires for
* this app account, so Bulwark schedules none; null when there are none.
* `primaryJmapAccountId`: the JMAP account the app's calendar store loads
* events from when they carry no `accountId` (the user's own). Android's side
* is read again each time, for the next time: a change calls the
* `onDeviceRemindersChange` listeners.
*/
export function remindedOnDevice(
registryId: string | null | undefined,
primaryJmapAccountId: string | null | undefined,
): ((event: CalendarEvent) => boolean) | null {
if (!registryId || !deviceSyncAvailable()) return null;
const entry = useDeviceSyncStore.getState().accounts[registryId];
if (!deviceOwnsReminders(entry)) return null;
void readAndroid();
if (!receiving.has(registryId)) return null;
const selection = selectionFor(entry, CALENDAR_AUTHORITY);
const personal = entry.primaryJmapAccounts?.[CALENDAR_AUTHORITY] ?? primaryJmapAccountId ?? null;
return (event) => {
// Events of shared calendars carry their account and a namespaced copy
// of calendarIds; the raw ids are in originalCalendarIds.
const jmapAccountId = event.accountId ?? primaryJmapAccountId;
if (!jmapAccountId) return false;
const calendarIds = event.originalCalendarIds ?? event.calendarIds ?? {};
return Object.entries(calendarIds).some(([calendarId, member]) =>
!!member && isCollectionSelected(selection, CALENDAR_AUTHORITY, {
key: collectionKey(jmapAccountId, calendarId),
isPersonal: jmapAccountId === personal,
}));
};
}
// What decides which events the device reminds of, as one comparable string.
function reminderSignature(accounts: Record<string, AccountDeviceSync>): string {
const parts: unknown[] = [];
for (const registryId of Object.keys(accounts).sort()) {
const entry = accounts[registryId];
if (!deviceOwnsReminders(entry)) continue;
parts.push([registryId, entry.calendarSelection, entry.primaryJmapAccounts?.[CALENDAR_AUTHORITY] ?? null]);
}
return JSON.stringify(parts);
}
/**
* Calls `listener` whenever the set of events the device reminds of may have
* changed (reminder owner, calendar sync on or off, the calendar selection,
* whether Android lets the device calendar sync). Android's side is read
* right away, so the first reminders are scheduled with it known. Returns
* the unsubscribe.
*/
export function onDeviceRemindersChange(listener: () => void): () => void {
let last = reminderSignature(useDeviceSyncStore.getState().accounts);
const unsubscribe = useDeviceSyncStore.subscribe((state) => {
const next = reminderSignature(state.accounts);
if (next === last) return;
last = next;
listener();
});
const android = () => listener();
androidListeners.add(android);
if (deviceSyncAvailable()) void readAndroid();
return () => {
unsubscribe();
androidListeners.delete(android);
};
}
+92
View File
@@ -0,0 +1,92 @@
/**
* Asks Android to sync one account's contacts or calendars because something
* changed while the app runs (docs/device-sync.md, "Triggers"): a StateChange
* on the live stream, the app's own contact or calendar edit, the app coming
* back to the foreground.
*
* Requests are coalesced per account and authority: the first one arms a
* 5-second timer and the rest ride along, so an edit and the StateChange
* echoing it make one sync. A request caused only by StateChanges is dropped
* when the timer fires if the engine has synced exactly those states in the
* meantime (its own uploads echo back).
*
* The request is an ordinary (not manual) one: Android drops it while the
* user has paused the account or the device's auto-sync is off.
*/
import { requestSync } from '../native';
import type { Authority } from '../types';
import { useAccountStore } from '../../stores/account-store';
import { isKnownState, syncOnInApp, useDeviceSyncStore } from '../../stores/device-sync-store';
import { deviceSyncAvailable } from './available';
export const TRIGGER_DELAY_MS = 5000;
/** The StateChange that asked for a sync. */
export interface StateReason {
jmapAccountId: string;
type: string;
state: string;
}
interface Pending {
timer: ReturnType<typeof setTimeout>;
reasons: StateReason[];
/** Asked for by something other than a StateChange: never skipped as an echo. */
unconditional: boolean;
}
const pending = new Map<string, Pending>();
const slotKey = (registryId: string, authority: Authority) => `${registryId}\n${authority}`;
/**
* Request a sync of `authority` for an app account (the active one when
* `registryId` is omitted), coalesced for 5 seconds. A no-op unless the
* account has device sync on for the authority.
*/
export function requestDeviceSync(authority: Authority, registryId?: string | null, reason?: StateReason): void {
if (!deviceSyncAvailable()) return;
const id = registryId ?? useAccountStore.getState().activeAccountId;
if (!id) return;
if (!syncOnInApp(useDeviceSyncStore.getState().accounts[id], authority)) return;
const key = slotKey(id, authority);
let slot = pending.get(key);
if (!slot) {
slot = {
timer: setTimeout(() => fire(key, id, authority), TRIGGER_DELAY_MS),
reasons: [],
unconditional: false,
};
pending.set(key, slot);
}
if (reason) slot.reasons.push(reason);
else slot.unconditional = true;
}
function fire(key: string, registryId: string, authority: Authority): void {
const slot = pending.get(key);
pending.delete(key);
if (!slot) return;
const entry = useDeviceSyncStore.getState().accounts[registryId];
const accountName = entry?.androidAccountName;
if (!accountName || !syncOnInApp(entry, authority)) return;
const echoesOnly = !slot.unconditional
&& slot.reasons.every((r) => isKnownState(registryId, r.jmapAccountId, r.type, r.state));
if (echoesOnly) return;
void Promise.resolve()
.then(() => requestSync(accountName, authority, {}))
.catch(() => undefined);
}
/**
* Drops waiting requests: of one authority of an account, of every authority
* of an account, or all of them. Used before sync is turned off.
*/
export function cancelDeviceSyncRequests(registryId?: string, authority?: Authority): void {
for (const [key, slot] of pending) {
if (registryId && authority && key !== slotKey(registryId, authority)) continue;
if (registryId && !key.startsWith(`${registryId}\n`)) continue;
clearTimeout(slot.timer);
pending.delete(key);
}
}
+119
View File
@@ -0,0 +1,119 @@
/**
* What makes the running app ask Android for device syncs
* (docs/device-sync.md, "Triggers"), started once from the app's launch
* effect:
*
* - StateChanges on the live stream for `AddressBook`/`ContactCard` and
* `Calendar`/`CalendarEvent` (the stream serves the active account), except
* the states the engine synced itself;
* - the app returning to the foreground, and the launch itself: every account
* with sync on, after the accounts were reconciled with Android's;
* - accounts of our type changing (removed in Android Settings);
* - the preferences changing: the native push router's routes follow them.
*
* The app's own contact and calendar edits call `requestDeviceSync` from the
* stores. Every request is coalesced per account and authority (./request).
*/
import { AppState, type AppStateStatus } from 'react-native';
import { onAccountsChanged } from '../native';
import { AUTHORITIES, type Authority } from '../types';
import { onStateChangeType } from '../../lib/state-change-bus';
import { useAccountStore } from '../../stores/account-store';
import {
feedingJmapAccounts,
isKnownState,
syncOnInApp,
useDeviceSyncStore,
} from '../../stores/device-sync-store';
import { deviceSyncAvailable } from './available';
import { reconcileDeviceAccounts, refreshPushRoutes } from './lifecycle';
import { STATE_TYPES_BY_AUTHORITY } from './prefs';
import { requestDeviceSync } from './request';
/** Foreground syncs of one account and authority at most this often. */
export const FOREGROUND_MIN_INTERVAL_MS = 60_000;
const ROUTES_DELAY_MS = 1000;
let stopTriggers: (() => void) | null = null;
const lastForegroundRequest = new Map<string, number>();
/** A StateChange of the active account's stream. */
export function handleStateChange(authority: Authority, type: string, jmapAccountId: string, state: string): void {
const registryId = useAccountStore.getState().activeAccountId;
if (!registryId) return;
const entry = useDeviceSyncStore.getState().accounts[registryId];
if (!entry || !syncOnInApp(entry, authority)) return;
// The engine's own upload echoing back.
if (isKnownState(registryId, jmapAccountId, type, state)) return;
// A shared account nothing is synced from, once the app knows which
// accounts feed the device.
if (entry.primaryJmapAccounts?.[authority] && !feedingJmapAccounts(entry, authority).includes(jmapAccountId)) return;
requestDeviceSync(authority, registryId, { jmapAccountId, type, state });
}
/** Launch and every return to the foreground. */
export async function handleForeground(now: number = Date.now()): Promise<void> {
await reconcileDeviceAccounts();
const { accounts } = useDeviceSyncStore.getState();
for (const account of useAccountStore.getState().accounts) {
for (const authority of AUTHORITIES) {
if (!syncOnInApp(accounts[account.id], authority)) continue;
const key = `${account.id}\n${authority}`;
const last = lastForegroundRequest.get(key);
if (last !== undefined && now - last < FOREGROUND_MIN_INTERVAL_MS) continue;
lastForegroundRequest.set(key, now);
requestDeviceSync(authority, account.id);
}
}
}
/** Starts the triggers; idempotent. Returns the stop function (for tests). */
export function startDeviceSyncTriggers(): () => void {
if (stopTriggers) return stopTriggers;
if (!deviceSyncAvailable()) return () => undefined;
const cleanups: Array<() => void> = [];
for (const authority of AUTHORITIES) {
for (const type of STATE_TYPES_BY_AUTHORITY[authority]) {
cleanups.push(onStateChangeType(type, (jmapAccountId, state) => {
handleStateChange(authority, type, jmapAccountId, state);
}));
}
}
let appState: AppStateStatus = AppState.currentState;
const appStateSubscription = AppState.addEventListener('change', (next) => {
const cameBack = next === 'active' && appState !== 'active';
appState = next;
if (cameBack) void handleForeground();
});
cleanups.push(() => appStateSubscription.remove());
try {
cleanups.push(onAccountsChanged(() => { void reconcileDeviceAccounts(); }));
} catch {
// Without the event, launch and foreground still reconcile.
}
let routesTimer: ReturnType<typeof setTimeout> | null = null;
cleanups.push(useDeviceSyncStore.subscribe((state, prev) => {
if (state.accounts === prev.accounts) return;
if (routesTimer) clearTimeout(routesTimer);
routesTimer = setTimeout(() => {
routesTimer = null;
void refreshPushRoutes();
}, ROUTES_DELAY_MS);
}));
cleanups.push(() => {
if (routesTimer) clearTimeout(routesTimer);
});
void handleForeground();
stopTriggers = () => {
for (const cleanup of cleanups) cleanup();
stopTriggers = null;
lastForegroundRequest.clear();
};
return stopTriggers;
}
+90
View File
@@ -0,0 +1,90 @@
/**
* After the server accepted an upload (docs/device-sync.md, "Uploads",
* "Afterwards"): when the rows were edited again while the upload was under
* way, the clearing group's assert fails and `keepDirtyOps` apply instead.
* They write the identity and the new shadow, and set the baselines of the
* uploaded units to the values that were uploaded (the rows as read before
* the upload), leaving the rows and DIRTY alone: the newer edit, or a revert
* to the old value, uploads next time.
*/
import { Events } from '../android-columns';
import type { CalendarContext, EventBaseline, LocalEvent, LocalEventRow, OpGroup } from '../planner';
import type { CalendarEventWire } from '../wire';
import { exceptionRef, objectRef } from '../common/ids';
import { clone } from '../common/json';
import { EXCEPTION_CELLS, MASTER_CELLS, encodeBaseline, makeBaseline } from './columns';
import { COLUMN_UNITS } from './units';
export interface UploadedUnits {
/** Everything was uploaded (a create). */
all: boolean;
master: Set<string>;
/** By exception row id. */
exceptions: Map<number, Set<string>>;
/** Exception rows whose instance is excluded on the server now. */
removed: Set<number>;
}
function unitColumns(unit: string): readonly string[] {
return COLUMN_UNITS[unit] ?? (unit.startsWith('exdate:') ? [Events.EXDATE] : []);
}
function uploadedBaseline(row: LocalEventRow, isException: boolean, units: Set<string> | null, all: boolean): EventBaseline {
const uploaded = makeBaseline(
row.cells,
isException ? EXCEPTION_CELLS : MASTER_CELLS,
row.attendees.map((a) => a.cells),
row.reminders.map((r) => r.cells),
);
if (all || !row.baseline) return uploaded;
const next = clone(row.baseline);
for (const unit of units ?? []) {
for (const c of unitColumns(unit)) next.cells[c] = uploaded.cells[c] ?? null;
if (unit === 'reminders') next.reminders = uploaded.reminders;
if (unit.startsWith('attendee')) next.attendees = uploaded.attendees;
}
return next;
}
export function keepDirtyGroup(local: LocalEvent, server: CalendarEventWire, uploaded: UploadedUnits, ctx: CalendarContext): OpGroup {
const masterRef = objectRef(ctx.jmapAccountId, server.id);
const ops: OpGroup['ops'] = [
{
op: 'update',
table: 'events',
id: local.eventId,
values: {
[Events._SYNC_ID]: masterRef,
[Events.UID_2445]: typeof server.uid === 'string' ? server.uid : null,
[Events.SYNC_DATA1]: JSON.stringify(server),
[Events.SYNC_DATA3]: null,
[Events.SYNC_DATA4]: encodeBaseline(uploadedBaseline(local, false, uploaded.master, uploaded.all)),
[Events.SYNC_DATA5]: null,
},
expectCount: 1,
},
];
for (const x of local.exceptions) {
// An instance deleted on the device is an exclusion on the server now: its row has done its job.
if (uploaded.removed.has(x.eventId)) {
ops.push({ op: 'delete', table: 'events', id: x.eventId, expectCount: 1 });
continue;
}
const units = uploaded.exceptions.get(x.eventId);
if ((!uploaded.all && !units) || !x.recurrenceId) continue;
const all = uploaded.all || !!units?.has('new');
ops.push({
op: 'update',
table: 'events',
id: x.eventId,
values: {
[Events._SYNC_ID]: exceptionRef(masterRef, x.recurrenceId),
[Events.SYNC_DATA2]: x.recurrenceId,
[Events.ORIGINAL_SYNC_ID]: masterRef,
[Events.SYNC_DATA4]: encodeBaseline(uploadedBaseline(x, true, units ?? null, all)),
},
expectCount: 1,
});
}
return { ref: masterRef, ops };
}
+165
View File
@@ -0,0 +1,165 @@
/**
* Participants ↔ Attendees rows (docs/device-sync.md, "Other properties").
*
* Only participants with a `mailto:` calendar address become rows; the rest
* stay on the server untouched. Android decides who "me" is by comparing
* ATTENDEE_EMAIL with the calendar's OWNER_ACCOUNT case-sensitively, so the
* user's own address, under whichever alias, is written as exactly
* OWNER_ACCOUNT. Stalwart can list one address twice (the organizer derived
* from ORGANIZER plus its ATTENDEE entry); such entries become one row.
* Participant ids never reach the device: rows are matched to participants
* by address.
*/
import type { Participant } from '../../api/types';
import { AttendeeRelationship, Attendees } from '../android-columns';
import type { Row } from '../types';
import { attendeeTypeToDevice, participationToDevice, relationshipToDevice } from './values';
export interface SelfContext {
/** OWNER_ACCOUNT of our calendars, lowercase, no `mailto:`. */
ownerAccount: string;
/** Every address of the user, lowercase, no `mailto:`. */
selfAddresses: string[];
}
type Participants = Record<string, Participant> | null | undefined;
/** The address of a `mailto:` calendar address (lowercased), else null. */
export function mailtoAddress(p: Participant | null | undefined): string | null {
const addr = p?.calendarAddress;
if (typeof addr !== 'string') return null;
const m = /^mailto:(.+)$/i.exec(addr.trim());
if (!m) return null;
let email = m[1].trim();
try {
email = decodeURIComponent(email);
} catch {
// Keep it as written.
}
return email ? email.toLowerCase() : null;
}
export function isSelfAddress(email: string | null | undefined, ctx: SelfContext): boolean {
if (!email) return false;
const e = email.trim().replace(/^mailto:/i, '').toLowerCase();
return e === ctx.ownerAccount.toLowerCase() || ctx.selfAddresses.some((a) => a.toLowerCase() === e);
}
/** The address as written to ATTENDEE_EMAIL / ORGANIZER: the user's own ones as OWNER_ACCOUNT. */
export function deviceAddress(email: string, ctx: SelfContext): string {
return isSelfAddress(email, ctx) ? ctx.ownerAccount : email;
}
/** A device address as the key rows and participants are matched by (the user's aliases are one key). */
export function addressKey(email: unknown, ctx: SelfContext): string {
const e = String(email ?? '').trim().replace(/^mailto:/i, '').toLowerCase();
return isSelfAddress(e, ctx) ? `self:${ctx.ownerAccount.toLowerCase()}` : e;
}
interface AddressGroup {
key: string;
email: string;
ids: string[];
preferred: string;
}
/**
* Participants with a `mailto:` address grouped by address, in server
* order. The preferred entry of a group is the one that attends (Stalwart's
* organizer entry has no `attendee` role), then one with a status.
*/
export function addressGroups(participants: Participants, ctx: SelfContext): AddressGroup[] {
const groups = new Map<string, AddressGroup>();
for (const [id, p] of Object.entries(participants ?? {})) {
const email = mailtoAddress(p);
if (!email) continue;
const key = addressKey(email, ctx);
const group = groups.get(key);
if (group) group.ids.push(id);
else groups.set(key, { key, email, ids: [id], preferred: id });
}
const list = participants ?? {};
for (const group of groups.values()) {
const score = (id: string) => (list[id]?.roles?.attendee ? 2 : 0) + (list[id]?.participationStatus ? 1 : 0);
group.preferred = group.ids.reduce((best, id) => (score(id) > score(best) ? id : best), group.ids[0]);
}
return [...groups.values()];
}
/** The organizer's address for ORGANIZER: `organizerCalendarAddress`, else an owner participant's. */
export function organizerAddress(
event: { organizerCalendarAddress?: string | null; participants?: Participants },
ctx: SelfContext,
): string | null {
const direct = event.organizerCalendarAddress
? mailtoAddress({ calendarAddress: /^mailto:/i.test(event.organizerCalendarAddress) ? event.organizerCalendarAddress : `mailto:${event.organizerCalendarAddress}` })
: null;
if (direct) return deviceAddress(direct, ctx);
for (const p of Object.values(event.participants ?? {})) {
if (p?.roles?.owner) {
const email = mailtoAddress(p);
if (email) return deviceAddress(email, ctx);
}
}
return null;
}
/** Whether the event has participants that become Attendees rows. */
export function hasMailtoParticipants(participants: Participants): boolean {
return Object.values(participants ?? {}).some((p) => mailtoAddress(p) !== null);
}
/** Attendees rows for the participants, one per address, in server order. */
export function attendeeRows(
event: { organizerCalendarAddress?: string | null; participants?: Participants },
ctx: SelfContext,
): Row[] {
const participants = event.participants ?? {};
const organizer = organizerAddress(event, ctx);
return addressGroups(participants, ctx).map((group) => {
const p = participants[group.preferred];
const email = deviceAddress(group.email, ctx);
const isOrganizer =
group.ids.some((id) => participants[id]?.roles?.owner) || (organizer !== null && organizer.toLowerCase() === email.toLowerCase());
const name = p.name ?? group.ids.map((id) => participants[id]?.name).find((n) => !!n) ?? null;
return {
[Attendees.ATTENDEE_EMAIL]: email,
[Attendees.ATTENDEE_NAME]: name || null,
[Attendees.ATTENDEE_RELATIONSHIP]: relationshipToDevice(isOrganizer),
[Attendees.ATTENDEE_TYPE]: attendeeTypeToDevice(p),
[Attendees.ATTENDEE_STATUS]: participationToDevice(p.participationStatus),
};
});
}
/** The participant id a device address stands for (the group's preferred entry), or null. */
export function participantIdFor(participants: Participants, email: unknown, ctx: SelfContext): string | null {
const key = addressKey(email, ctx);
return addressGroups(participants, ctx).find((g) => g.key === key)?.preferred ?? null;
}
/** Whether an attendee row is the organizer's. */
export function isOrganizerRow(row: Row): boolean {
return Number(row[Attendees.ATTENDEE_RELATIONSHIP]) === AttendeeRelationship.ORGANIZER;
}
/** Whether anyone but the user takes part. */
export function hasOtherParticipants(participants: Participants, ctx: SelfContext): boolean {
return Object.values(participants ?? {}).some((p) => {
const email = mailtoAddress(p);
return email !== null && !isSelfAddress(email, ctx);
});
}
/**
* Whether the user organizes the event: the organizer address or an owner
* participant is theirs. An event without an organizer counts too: once it
* has attendees Stalwart makes the account's first address its organizer.
*/
export function userIsOrganizer(
event: { organizerCalendarAddress?: string | null; participants?: Participants },
ctx: SelfContext,
): boolean {
const organizer = organizerAddress(event, ctx);
return organizer ? isSelfAddress(organizer, ctx) : true;
}
+141
View File
@@ -0,0 +1,141 @@
/**
* JMAP calendars ↔ CalendarContract.Calendars rows (docs/device-sync.md,
* "Calendar rows").
*
* Every column CalendarProvider and the calendar apps need is written:
* SYNC_EVENTS=1 (the default 0 hides every instance), a non-null
* OWNER_ACCOUNT (Etar crashes on NULL), CAN_PARTIALLY_UPDATE=0 (else the
* provider keeps LAST_SYNCED copies), MAX_REMINDERS by who owns reminders.
* VISIBLE is written on insert only: afterwards it is the user's. CAL_SYNC1
* stays empty because the provider sends it as the `feed` extra of the sync
* it requests for such calendars. A row is updated only where it differs.
*/
import { CalendarAccess, Calendars } from '../android-columns';
import type { CalendarContext, LocalCalendar, OpGroup } from '../planner';
import type { Row, WriteRow } from '../types';
import type { CalendarLike } from '../wire';
import { collectionKey, parseCollectionKey } from '../common/ids';
import { deepEqual, parseJsonColumn } from '../common/json';
import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar';
import { colors } from '../../theme/tokens';
import { isSelfAddress } from './attendees';
import { CAN_PARTIALLY_UPDATE } from './columns';
import { cssColorToArgb } from './values';
import { canonicalZone } from './zoned-time';
export interface SelectedCalendar {
jmapAccountId: string;
calendar: CalendarLike;
readOnly: boolean;
/** The JMAP account's name, appended to calendars of shared accounts. */
accountName: string;
}
type CalendarsContext = Omit<CalendarContext, 'jmapAccountId'>;
/** The app's palette colour for a calendar without one (`getCalendarColor` in lib/calendar-utils.ts). */
const PALETTE = [
colors.calendar.blue,
colors.calendar.green,
colors.calendar.purple,
colors.calendar.orange,
colors.calendar.red,
colors.calendar.pink,
colors.calendar.teal,
colors.calendar.indigo,
];
function paletteColor(id: string): string {
let hash = 0;
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) | 0;
return PALETTE[Math.abs(hash) % PALETTE.length];
}
/**
* CALENDAR_ACCESS_LEVEL from `myRights`: write all → OWNER, write own →
* CONTRIBUTOR (Etar's threshold for editing), RSVP → RESPOND, else READ. A
* read-only calendar whose rights allow writing is a subscribed feed: READ.
*/
export function accessLevel(calendar: CalendarLike, readOnly: boolean): { level: number; reason: 'rights' | 'subscription' | null } {
const rights = calendar.myRights;
const writable = !rights || !!rights.mayWriteAll || !!rights.mayWriteOwn;
if (readOnly && writable) return { level: CalendarAccess.READ, reason: 'subscription' };
const level = !rights || rights.mayWriteAll
? CalendarAccess.OWNER
: rights.mayWriteOwn
? CalendarAccess.CONTRIBUTOR
: rights.mayRSVP
? CalendarAccess.RESPOND
: CalendarAccess.READ;
return { level: readOnly && level >= CalendarAccess.CONTRIBUTOR ? CalendarAccess.RESPOND : level, reason: readOnly ? 'rights' : null };
}
/** The columns device sync keeps on a calendar row (VISIBLE aside). */
export function calendarValues(selected: SelectedCalendar, ctx: CalendarsContext): WriteRow {
const { calendar } = selected;
const shared = !!selected.accountName && !isSelfAddress(selected.accountName, ctx);
const name = calendar.name || calendar.id;
const access = accessLevel(calendar, selected.readOnly);
const flags = access.reason ? { readOnly: access.reason } : {};
return {
[Calendars._SYNC_ID]: collectionKey(selected.jmapAccountId, calendar.id),
[Calendars.NAME]: name,
[Calendars.CALENDAR_DISPLAY_NAME]: shared ? `${name} (${selected.accountName})` : name,
[Calendars.CALENDAR_COLOR]: cssColorToArgb(calendar.color) ?? cssColorToArgb(paletteColor(calendar.id))!,
[Calendars.CALENDAR_ACCESS_LEVEL]: access.level,
[Calendars.OWNER_ACCOUNT]: ctx.ownerAccount,
[Calendars.SYNC_EVENTS]: 1,
[Calendars.CALENDAR_TIME_ZONE]: canonicalZone(calendar.timeZone) ?? ctx.deviceZone,
[Calendars.ALLOWED_REMINDERS]: '1,2',
[Calendars.ALLOWED_AVAILABILITY]: '0,1',
[Calendars.ALLOWED_ATTENDEE_TYPES]: '0,1,2,3',
[Calendars.MAX_REMINDERS]: ctx.reminderOwner === 'bulwark' ? 0 : 5,
[Calendars.CAN_ORGANIZER_RESPOND]: 0,
[CAN_PARTIALLY_UPDATE]: 0,
[Calendars.CAL_SYNC2]: JSON.stringify(calendar),
[Calendars.CAL_SYNC3]: JSON.stringify(flags),
};
}
const JSON_COLUMNS = new Set<string>([Calendars.CAL_SYNC2, Calendars.CAL_SYNC3]);
function differs(column: string, have: Row[string] | undefined, want: WriteRow[string]): boolean {
if (JSON_COLUMNS.has(column)) return !deepEqual(parseJsonColumn(have), parseJsonColumn(want));
if (typeof want === 'number') return Number(have) !== want || have === null || have === undefined;
return (have ?? null) !== (want ?? null);
}
export function planCalendars(
selected: SelectedCalendar[],
local: LocalCalendar[],
ctx: CalendarsContext,
): { groups: OpGroup[]; deleteCalendarRows: number[] } {
const groups: OpGroup[] = [];
const rows = new Map<string, LocalCalendar>();
for (const row of local) if (row.syncId && !rows.has(row.syncId)) rows.set(row.syncId, row);
const wanted = new Set<string>();
for (const s of selected) {
// The birthday calendar is the app's own, generated from contacts.
if (s.calendar.id === BIRTHDAY_CALENDAR_ID) continue;
const key = collectionKey(s.jmapAccountId, s.calendar.id);
wanted.add(key);
const values = calendarValues(s, ctx);
const row = rows.get(key);
if (!row) {
// DIRTY is NULL after a sync-adapter insert unless written.
groups.push({ ref: key, ops: [{ op: 'insert', table: 'calendars', values: { ...values, [Calendars.VISIBLE]: 1, [Calendars.DIRTY]: 0 } }] });
continue;
}
const changes: WriteRow = {};
for (const [column, want] of Object.entries(values)) {
if (differs(column, row.cells[column], want)) changes[column] = want;
}
if (Object.keys(changes).length) {
groups.push({ ref: key, ops: [{ op: 'update', table: 'calendars', id: row.calendarRowId, values: changes, expectCount: 1 }] });
}
}
const deleteCalendarRows = local
.filter((row) => row.syncId && parseCollectionKey(row.syncId) && !wanted.has(row.syncId))
.map((row) => row.calendarRowId);
return { groups, deleteCalendarRows };
}
+263
View File
@@ -0,0 +1,263 @@
/**
* What the calendar planner reads and writes on each CalendarContract row,
* how a cell compares to another (an app's `''` is the provider's NULL, a
* `P3600S` is a `PT1H`) and the baseline format in SYNC_DATA4
* (docs/device-sync.md, "Calendar columns" and "Baselines").
*/
import { Attendees, Calendars, Events, Reminders } from '../android-columns';
import type { Cell, Row } from '../types';
import type { EventBaseline } from '../planner';
import { parseJsonColumn, utf8 } from '../common/json';
import { sha256Hex } from '../../lib/sha256';
import { canonicalRRule } from './rrule';
import { exdateSet } from './exdate';
import { durationTextSeconds } from './duration';
/** Calendars.CAN_PARTIALLY_UPDATE, re-exported for the calendar modules. */
export const CAN_PARTIALLY_UPDATE = Calendars.CAN_PARTIALLY_UPDATE;
export const CALENDAR_COLUMNS = [
Calendars._ID,
Calendars._SYNC_ID,
Calendars.NAME,
Calendars.CALENDAR_DISPLAY_NAME,
Calendars.CALENDAR_COLOR,
Calendars.CALENDAR_ACCESS_LEVEL,
Calendars.OWNER_ACCOUNT,
Calendars.VISIBLE,
Calendars.SYNC_EVENTS,
Calendars.CALENDAR_TIME_ZONE,
Calendars.ALLOWED_REMINDERS,
Calendars.ALLOWED_AVAILABILITY,
Calendars.ALLOWED_ATTENDEE_TYPES,
Calendars.MAX_REMINDERS,
Calendars.CAN_ORGANIZER_RESPOND,
CAN_PARTIALLY_UPDATE,
Calendars.CAL_SYNC2,
Calendars.CAL_SYNC3,
] as const;
/** Event columns that map to JSCalendar (a master's projection). */
export const MASTER_CELLS = [
Events.CALENDAR_ID,
Events.TITLE,
Events.DESCRIPTION,
Events.EVENT_LOCATION,
Events.STATUS,
Events.AVAILABILITY,
Events.ACCESS_LEVEL,
Events.EVENT_COLOR,
Events.ORGANIZER,
Events.DTSTART,
Events.DTEND,
Events.DURATION,
Events.EVENT_TIMEZONE,
Events.ALL_DAY,
Events.RRULE,
Events.EXDATE,
] as const;
/** An exception row's projection: no rule of its own, plus the instance it replaces. */
export const EXCEPTION_CELLS = [
Events.CALENDAR_ID,
Events.TITLE,
Events.DESCRIPTION,
Events.EVENT_LOCATION,
Events.STATUS,
Events.AVAILABILITY,
Events.ACCESS_LEVEL,
Events.EVENT_COLOR,
Events.ORGANIZER,
Events.DTSTART,
Events.DTEND,
Events.DURATION,
Events.EVENT_TIMEZONE,
Events.ALL_DAY,
Events.ORIGINAL_INSTANCE_TIME,
Events.ORIGINAL_ALL_DAY,
] as const;
/** Read but never part of a projection: identity, sync state and provider-computed columns. */
const EVENT_EXTRA_COLUMNS = [
Events._ID,
Events._SYNC_ID,
Events.DIRTY,
Events.DELETED,
Events.MUTATORS,
Events.SYNC_DATA1,
Events.SYNC_DATA2,
Events.SYNC_DATA3,
Events.SYNC_DATA4,
Events.SYNC_DATA5,
Events.UID_2445,
Events.HAS_ATTENDEE_DATA,
Events.RDATE,
Events.EXRULE,
Events.LAST_DATE,
Events.ORIGINAL_ID,
Events.ORIGINAL_SYNC_ID,
] as const;
export const EVENT_COLUMNS = [...new Set<string>([...EVENT_EXTRA_COLUMNS, ...MASTER_CELLS, ...EXCEPTION_CELLS])];
export const ATTENDEE_CELLS = [
Attendees.ATTENDEE_EMAIL,
Attendees.ATTENDEE_NAME,
Attendees.ATTENDEE_RELATIONSHIP,
Attendees.ATTENDEE_TYPE,
Attendees.ATTENDEE_STATUS,
] as const;
export const ATTENDEE_COLUMNS = [Attendees._ID, Attendees.EVENT_ID, ...ATTENDEE_CELLS];
export const REMINDER_CELLS = [Reminders.MINUTES, Reminders.METHOD] as const;
export const REMINDER_COLUMNS = [Reminders._ID, Reminders.EVENT_ID, ...REMINDER_CELLS];
// ─── Cells ──────────────────────────────────────────────
const TEXT_COLUMNS = new Set<string>([
Events.TITLE,
Events.DESCRIPTION,
Events.EVENT_LOCATION,
Events.ORGANIZER,
Events.EVENT_TIMEZONE,
Attendees.ATTENDEE_EMAIL,
Attendees.ATTENDEE_NAME,
]);
/** A cell as the bridge may hand it back: numbers stay numbers, `''` and NULL are one value for text. */
export function normalCell(column: string, value: unknown): Cell {
if (value === undefined || value === null) return TEXT_COLUMNS.has(column) ? '' : null;
if (TEXT_COLUMNS.has(column)) return String(value);
if (typeof value === 'number') return value;
if (typeof value === 'string') {
if (value === '') return null;
const n = Number(value);
return Number.isFinite(n) && /^-?\d+(\.\d+)?$/.test(value.trim()) ? n : value;
}
return null;
}
/** Whether two values of a column mean the same, the way device sync reads them. */
export function sameCell(column: string, a: unknown, b: unknown): boolean {
if (column === Events.DURATION) {
const sa = durationTextSeconds(a);
const sb = durationTextSeconds(b);
if (sa !== null || sb !== null) return sa === sb;
return empty(a) === empty(b);
}
if (column === Events.RRULE) return canonicalRRule(a) === canonicalRRule(b);
if (column === Events.EXDATE) return sameSet(exdateSet(a), exdateSet(b));
if (column === Events.ALL_DAY || column === Events.ORIGINAL_ALL_DAY) return truthy(a) === truthy(b);
if (isHashed(a) || isHashed(b)) return textDigest(a) === textDigest(b);
return normalCell(column, a) === normalCell(column, b);
}
const empty = (v: unknown) => v === null || v === undefined || v === '';
const truthy = (v: unknown) => Number(v ?? 0) === 1;
function sameSet(a: Set<string>, b: Set<string>): boolean {
return a.size === b.size && [...a].every((x) => b.has(x));
}
// ─── Long text in baselines ─────────────────────────────
const HASH_PREFIX = 'sha256:';
const TRUNCATED_PREFIX = 'truncated:';
const LONG_TEXT_BYTES = 1024;
/** The largest description written to a row; longer ones are cut, marked in the baseline and never uploaded. */
export const DESCRIPTION_LIMIT_BYTES = 65_536;
function isHashed(v: unknown): boolean {
return typeof v === 'string' && (v.startsWith(HASH_PREFIX) || v.startsWith(TRUNCATED_PREFIX + HASH_PREFIX));
}
/** Text over 1 KB as `sha256:<hex>`, anything else unchanged. */
export function baselineText(value: string): string {
return utf8(value).length > LONG_TEXT_BYTES ? HASH_PREFIX + sha256Hex(utf8(value)) : value;
}
/** A truncated description's baseline: its hash, marked so a later edit is never uploaded. */
export function truncatedBaseline(value: string): string {
return TRUNCATED_PREFIX + HASH_PREFIX + sha256Hex(utf8(value));
}
export function isTruncatedBaseline(value: unknown): boolean {
return typeof value === 'string' && value.startsWith(TRUNCATED_PREFIX);
}
/** What a text cell or its baseline form is compared by: the hash for hashed or long text. */
function textDigest(v: unknown): string {
if (typeof v !== 'string') return empty(v) ? '' : String(v);
if (v.startsWith(TRUNCATED_PREFIX)) return v.slice(TRUNCATED_PREFIX.length);
if (v.startsWith(HASH_PREFIX)) return v;
return utf8(v).length > LONG_TEXT_BYTES ? HASH_PREFIX + sha256Hex(utf8(v)) : v;
}
/** Cuts text to at most `limit` UTF-8 bytes without splitting a character. */
export function truncateUtf8(text: string, limit = DESCRIPTION_LIMIT_BYTES): { text: string; truncated: boolean } {
if (utf8(text).length <= limit) return { text, truncated: false };
let lo = 0;
let hi = text.length;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (utf8(text.slice(0, mid)).length <= limit) lo = mid;
else hi = mid - 1;
}
let end = lo;
// Never end on the first half of a surrogate pair.
if (end > 0 && /[\uD800-\uDBFF]/.test(text[end - 1])) end -= 1;
return { text: text.slice(0, end), truncated: true };
}
// ─── Baselines (SYNC_DATA4) ─────────────────────────────
/**
* The columns of a row, each in its normal form (numbers as numbers even when
* the provider handed them back as text), so a read-back never differs from
* a baseline by type alone.
*/
function normalRow(row: Row, columns: readonly string[]): Row {
const out: Row = {};
for (const c of columns) {
const v = row[c] ?? null;
out[c] = v === null || TEXT_COLUMNS.has(c) || typeof v !== 'string' ? v : normalCell(c, v);
}
return out;
}
/** A baseline from cells as stored: normal forms, long text hashed, a truncated description marked. */
export function makeBaseline(
cells: Row,
columns: readonly string[],
attendees: Row[],
reminders: Row[],
truncatedDescription = false,
): EventBaseline {
const out = normalRow(cells, columns);
for (const c of columns) {
const v = out[c];
if (c === Events.DESCRIPTION && truncatedDescription && typeof v === 'string') out[c] = truncatedBaseline(v);
else if (typeof v === 'string' && !isHashed(v)) out[c] = baselineText(v);
}
return {
cells: out,
attendees: attendees.map((a) => normalRow(a, ATTENDEE_CELLS)),
reminders: reminders.map((r) => normalRow(r, REMINDER_CELLS)),
};
}
export function encodeBaseline(baseline: EventBaseline): string {
return JSON.stringify({ v: 1, ...baseline });
}
/** SYNC_DATA4 read defensively: another app may have written anything there. */
export function decodeBaseline(text: unknown): EventBaseline | null {
const parsed = parseJsonColumn<Record<string, unknown>>(text);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
const cells = parsed.cells;
if (!cells || typeof cells !== 'object' || Array.isArray(cells)) return null;
const rows = (v: unknown): Row[] =>
Array.isArray(v) ? v.filter((r): r is Row => !!r && typeof r === 'object' && !Array.isArray(r)) : [];
return { cells: cells as Row, attendees: rows(parsed.attendees), reminders: rows(parsed.reminders) };
}
+298
View File
@@ -0,0 +1,298 @@
/**
* New and deleted events (docs/device-sync.md, "Uploads" and "Identity").
*
* A new master row is claimed before anything is sent: the uid and target go
* into SYNC_DATA3, UID_2445 and `_SYNC_ID = ~pending/<uid>`, so an app delete
* becomes a soft delete the engine can act on and a retried create is found
* by uid. The row keeps a uid it carries unless another row does too (a
* CONTENT_EXCEPTION_URI split clones UID_2445). The create then sends the
* whole event, with the exceptions and exclusions made before its upload as
* overrides. A deleted event is destroyed, or loses only its synced calendar
* memberships when it is in others as well.
*/
import type { Participant } from '../../api/types';
import { AttendeeType, Attendees, Events } from '../android-columns';
import type { CalendarContext, LocalEvent, LocalException, OpGroup, UploadAction } from '../planner';
import type { Row } from '../types';
import type { CalendarEventWire } from '../wire';
import { collectionKey, parseCollectionKey, parseObjectRef, pendingRef, pendingUidOf } from '../common/ids';
import type { PatchObject } from '../common/patch';
import { isSelfAddress } from './attendees';
import { applyAttendeeEdits, attendeeEdits, descriptionValue, locationChanges, reminderChanges, titleValue } from './edits';
import { exdateEntryKey } from './exdate';
import type { Override } from './exceptions';
import { isRemovedInstance } from './merge';
import { rruleParts, type RuleZone } from './rrule';
import { sendsSchedulingMessages } from './scheduling';
import { fixedOffsetTiming, rowTiming } from './timing';
import { exdateEntries, sideOfRow } from './units';
import { SKIP, SkipUpload, deviceRule, newOverride, put } from './upload';
import { argbToCss, availabilityFromDevice, participationFromDevice, privacyFromDevice, statusFromDevice } from './values';
import { canonicalZone, classifyLocal } from './zoned-time';
// ─── Creates ────────────────────────────────────────────
export interface CreateComputation {
object: Partial<CalendarEventWire>;
sendSchedulingMessages: boolean;
}
/** The event a new master row (with its exceptions and EXDATE) creates. Throws `SkipUpload`. */
export function computeCreate(local: LocalEvent, uid: string, calendarId: string, ctx: CalendarContext): CreateComputation {
const cells = local.cells;
const recurring = !!rruleParts(cells[Events.RRULE]);
let t = rowTiming(cells, { server: null, baselineZone: null, recurring, deviceZone: ctx.deviceZone });
if (!t) throw new SkipUpload('noStart');
if (t.ambiguous) {
if (recurring) throw new SkipUpload(SKIP.dstAmbiguous);
t = fixedOffsetTiming(t);
}
const object: Record<string, unknown> = {
'@type': 'Event',
uid,
calendarIds: { [calendarId]: true },
title: titleValue(cells),
start: t.start,
duration: t.duration,
freeBusyStatus: availabilityFromDevice(cells[Events.AVAILABILITY]),
};
// A row without STATUS gets JSCalendar's default, confirmed.
const status = statusFromDevice(cells[Events.STATUS]);
if (status) object.status = status;
if (t.showWithoutTime) object.showWithoutTime = true;
else object.timeZone = t.timeZone;
const description = descriptionValue(cells);
if (description) object.description = description;
const privacy = privacyFromDevice(cells[Events.ACCESS_LEVEL]);
if (privacy) object.privacy = privacy;
const color = argbToCss(cells[Events.EVENT_COLOR]);
if (color) object.color = color;
const location = locationChanges({}, cells[Events.EVENT_LOCATION], ctx.mintKey).locations;
if (location) object.locations = location;
const { participants, organizer } = createParticipants(local, ctx);
if (participants) {
object.participants = participants;
object.organizerCalendarAddress = `mailto:${organizer}`;
}
if (ctx.reminderOwner === 'device' && local.reminders.length) {
const alerts = reminderChanges({}, undefined, local.reminders.map((r) => r.cells), ctx.mintKey).alerts;
if (Object.keys(alerts).length) object.alerts = alerts;
}
const zone: RuleZone = t.showWithoutTime ? { allDay: true, zone: 'UTC' } : { allDay: false, zone: canonicalZone(t.timeZone) ?? 'UTC' };
const rule = recurring ? deviceRule(cells[Events.RRULE], null, null, zone, false) : null;
if (rule) {
object.recurrenceRule = rule;
const overrides: Record<string, Override> = {};
const checked = (key: string | null) => {
if (key && !zone.allDay && classifyLocal(key, zone.zone) === 'overlap') throw new SkipUpload(SKIP.dstAmbiguous);
return key;
};
for (const entry of exdateEntries(sideOfRow(local))) {
const key = exdateEntryKey(entry, zone);
// Etar's "this and following" copies the old series' EXDATE: dates before the start are none of this series.
if (!key || key < t.start) continue;
overrides[checked(key)!] = { excluded: true };
}
for (const x of local.exceptions) {
const key = checked(x.recurrenceId);
if (!key) continue;
overrides[key] = isRemovedInstance(x) ? { excluded: true } : createOverride(x, object as CalendarEventWire, key, local, ctx);
}
if (Object.keys(overrides).length) object.recurrenceOverrides = overrides;
}
return {
object: object as Partial<CalendarEventWire>,
sendSchedulingMessages: sendsSchedulingMessages(object as CalendarEventWire, { kind: 'create' }, ctx),
};
}
/** An exception of a series that is created with it: what the exception row says differently from its master row. */
function createOverride(x: LocalException, master: CalendarEventWire, key: string, local: LocalEvent, ctx: CalendarContext): Override {
const values: Record<string, unknown> = {};
let t = rowTiming(x.cells, {
server: master,
baselineZone: String(local.cells[Events.EVENT_TIMEZONE] ?? ''),
recurring: false,
deviceZone: ctx.deviceZone,
});
if (t) {
if (t.ambiguous) throw new SkipUpload(SKIP.dstAmbiguous);
if (t.showWithoutTime !== !!master.showWithoutTime) t = { ...t, showWithoutTime: !!master.showWithoutTime };
values.start = t.start;
values.duration = t.duration;
}
const cells = x.cells;
// What the exception row leaves empty is one its app did not model: it comes from the series.
const differs = (column: string) => {
const v = cells[column];
return v !== null && v !== undefined && v !== '' && String(v) !== String(local.cells[column] ?? '');
};
if (differs(Events.TITLE)) values.title = titleValue(cells);
if (differs(Events.DESCRIPTION)) values.description = descriptionValue(cells);
if (differs(Events.EVENT_LOCATION)) values.locations = locationChanges(master, cells[Events.EVENT_LOCATION], ctx.mintKey).locations;
if (differs(Events.STATUS)) values.status = statusFromDevice(cells[Events.STATUS]);
if (differs(Events.AVAILABILITY)) values.freeBusyStatus = availabilityFromDevice(cells[Events.AVAILABILITY]);
if (differs(Events.EVENT_COLOR)) values.color = argbToCss(cells[Events.EVENT_COLOR]);
if (x.attendees.length) {
const edits = attendeeEdits(x.attendees.map((a) => a.cells), local.attendees.map((a) => a.cells), ctx, false);
if (edits.length) values.participants = applyAttendeeEdits(master.participants, edits, ctx, ctx.mintKey, false).participants;
}
return newOverride(master, key, values);
}
/**
* Participants of a new event from its attendee rows: the user as organizer
* (unless ORGANIZER names someone else) and everyone else as attendees.
* None when the user is alone.
*/
function createParticipants(local: LocalEvent, ctx: CalendarContext): { participants: Record<string, Participant> | null; organizer: string } {
const rows = local.attendees.map((a) => a.cells);
const isSelf = (row: Row) => isSelfAddress(String(row[Attendees.ATTENDEE_EMAIL] ?? ''), ctx);
const organizerCell = String(local.cells[Events.ORGANIZER] ?? '').trim();
const organizer = organizerCell && !isSelfAddress(organizerCell, ctx) ? organizerCell : ctx.ownerAccount;
const guests = rows.filter((r) => !isSelf(r));
if (!guests.length) return { participants: null, organizer };
const participants: Record<string, Participant> = {};
const self = rows.find(isSelf);
const iOrganize = isSelfAddress(organizer, ctx);
if (iOrganize || self) {
const status = self ? participationFromDevice(self[Attendees.ATTENDEE_STATUS]) : null;
participants[ctx.mintKey(Object.keys(participants))] = {
'@type': 'Participant',
calendarAddress: `mailto:${ctx.ownerAccount}`,
roles: iOrganize ? { owner: true, attendee: true } : { attendee: true },
participationStatus: status ?? (iOrganize ? 'accepted' : 'needs-action'),
};
}
for (const row of guests) {
const email = String(row[Attendees.ATTENDEE_EMAIL]).trim();
const p: Participant = {
'@type': 'Participant',
calendarAddress: `mailto:${email}`,
roles: email.toLowerCase() === organizer.toLowerCase() ? { owner: true } : { attendee: true },
participationStatus: 'needs-action',
expectReply: true,
};
const name = row[Attendees.ATTENDEE_NAME];
if (typeof name === 'string' && name.trim()) p.name = name.trim();
if (Number(row[Attendees.ATTENDEE_TYPE]) === AttendeeType.OPTIONAL) p.roles = { ...p.roles, optional: true };
if (Number(row[Attendees.ATTENDEE_TYPE]) === AttendeeType.RESOURCE) p.kind = 'resource';
participants[ctx.mintKey(Object.keys(participants))] = p;
}
return { participants, organizer };
}
// ─── Claims ─────────────────────────────────────────────
/**
* The claim of a new row (see the header). A split clone also drops the
* source's shadow and baseline it was cloned with, points the source's
* exceptions back at the source (the provider's trigger may have followed the
* `_SYNC_ID` change) and marks the source dirty, so the source's capped rule
* uploads even when this group is applied before the source is planned.
*/
export function claimOps(local: LocalEvent, uid: string, target: string): OpGroup {
const ops: OpGroup['ops'] = [
{
op: 'assert',
table: 'events',
id: local.eventId,
values: { [Events._SYNC_ID]: local.syncId, [Events.DELETED]: local.cells[Events.DELETED] ?? null },
expectCount: 1,
},
];
const values: Row = {
[Events._SYNC_ID]: pendingRef(uid),
[Events.UID_2445]: uid,
[Events.SYNC_DATA3]: JSON.stringify({ uid, target }),
};
const sharedSyncId = local.split === 'clone' && parseObjectRef(local.syncId) ? local.syncId : null;
if (local.split === 'clone') {
Object.assign(values, { [Events.SYNC_DATA1]: null, [Events.SYNC_DATA2]: null, [Events.SYNC_DATA4]: null, [Events.SYNC_DATA5]: null });
}
ops.push({ op: 'update', table: 'events', id: local.eventId, values, expectCount: 1 });
if (sharedSyncId) {
ops.push({
op: 'update',
table: 'events',
where: `${Events.ORIGINAL_SYNC_ID} = ? AND (${Events.ORIGINAL_ID} IS NULL OR ${Events.ORIGINAL_ID} != ?)`,
args: [pendingRef(uid), local.eventId],
values: { [Events.ORIGINAL_SYNC_ID]: sharedSyncId },
});
// The clone no longer carries the shared `_SYNC_ID` here, so this reaches the source master(s) only.
ops.push({
op: 'update',
table: 'events',
where: `${Events._SYNC_ID} = ? AND ${Events.ORIGINAL_ID} IS NULL`,
args: [sharedSyncId],
values: { [Events.DIRTY]: 1 },
});
}
return { ref: `row:${local.eventId}`, ops };
}
/** The uid of a new row's complete, consistent claim, or null when it has to be claimed (again). */
export function claimedUid(local: LocalEvent): string | null {
if (local.split === 'clone' || !local.pending) return null;
const uid = local.pending.uid;
if (pendingUidOf(local.syncId) !== uid || local.cells[Events.UID_2445] !== uid) return null;
return uid;
}
/** The uid a claim writes: the row's own unless it is a clone of another row's. */
export function claimUid(local: LocalEvent, ctx: CalendarContext): string {
if (local.split === 'clone') return ctx.mintUid();
const own = local.cells[Events.UID_2445];
if (typeof own === 'string' && own.trim()) return own;
return pendingUidOf(local.syncId) ?? ctx.mintUid();
}
/** Where a new row's create goes: the target fixed at its claim, else the calendar of its row. */
export function createTarget(local: LocalEvent, ctx: CalendarContext): { key: string; calendarId: string } | null {
if (local.pending && local.split !== 'clone') {
const parsed = parseCollectionKey(local.pending.target);
if (parsed) return { key: local.pending.target, calendarId: parsed.id };
}
const cal = ctx.calendarIdOfRow(local.calendarRowId);
return cal ? { key: collectionKey(cal.jmapAccountId, cal.calendarId), calendarId: cal.calendarId } : null;
}
// ─── Deletions ──────────────────────────────────────────
/**
* Whether the server moved a deleted event out of the calendar its row is in
* (`own`), and into none this device syncs, before the device's deletion
* reached it: the deletion is moot then, and the event stays where it is now.
* A calendar deselected since is still the row's own, so that deletion goes
* up; so does one of an event moved into another synced calendar.
*/
export function movedAway(local: LocalEvent, own: { jmapAccountId: string; calendarId: string }, ctx: CalendarContext): boolean {
if (!local.shadow) return false;
const ownKey = collectionKey(own.jmapAccountId, own.calendarId);
const keys = Object.entries(local.shadow.calendarIds ?? {}).filter(([, on]) => on).map(([id]) => collectionKey(ctx.jmapAccountId, id));
return !keys.some((key) => key === ownKey || ctx.isSelected(key));
}
/**
* A deleted event with identity: an object in several calendars loses only
* the selected memberships; otherwise it is destroyed, with scheduling
* messages when others take part.
*/
export function deleteActions(local: LocalEvent, ctx: CalendarContext): UploadAction<CalendarEventWire>[] {
const ref = parseObjectRef(local.syncId)!;
const shadow = local.shadow;
if (shadow) {
const memberships = Object.entries(shadow.calendarIds ?? {}).filter(([, on]) => on).map(([id]) => id);
const selected = memberships.filter((id) => ctx.isSelected(collectionKey(ctx.jmapAccountId, id)));
if (selected.length && selected.length < memberships.length) {
const patch: PatchObject = {};
for (const id of selected) put(patch, ['calendarIds', id], null);
return [{ kind: 'update', id: ref.id, patch }];
}
}
const action: UploadAction<CalendarEventWire> = { kind: 'destroy', id: ref.id, uid: typeof shadow?.uid === 'string' ? shadow.uid : null };
if (shadow && sendsSchedulingMessages(shadow, { kind: 'destroy' }, ctx)) action.sendSchedulingMessages = true;
return [action];
}
+195
View File
@@ -0,0 +1,195 @@
/**
* Provider rows → the planner's view of local events and calendars
* (docs/device-sync.md, "Device data model"). Every sync column is read
* defensively: another app could have written anything there, and a value
* that doesn't parse counts as absent.
*
* Exception rows are grouped under their master by ORIGINAL_ID, else by
* ORIGINAL_SYNC_ID. Two masters sharing a `_SYNC_ID` are a
* CONTENT_EXCEPTION_URI split (the provider clones `_SYNC_ID`, SYNC_DATA*
* and UID_2445 and caps the old rule without DIRTY): the newer row is marked
* `clone`, the older `source`. A new master whose UID_2445 another row
* already carries is marked `clone` too, so its create gets a fresh uid
* instead of adopting the other row's server object.
*/
import { Attendees, Calendars, Events, Reminders } from '../android-columns';
import type { LocalAttendee, LocalCalendar, LocalEvent, LocalEventRow, LocalException, LocalReminder, PendingCreate, PoisonMarker } from '../planner';
import type { Row } from '../types';
import type { CalendarEventWire, CalendarLike } from '../wire';
import { parseJsonColumn } from '../common/json';
import { parseObjectRef, pendingUidOf } from '../common/ids';
import { decodeBaseline } from './columns';
import { instanceTimeToKey } from './exceptions';
import { canonicalZone, parseLocalDateTime } from './zoned-time';
const has = (v: unknown) => v !== null && v !== undefined && v !== '';
const num = (v: unknown) => (has(v) && Number.isFinite(Number(v)) ? Number(v) : null);
const str = (v: unknown) => (typeof v === 'string' && v ? v : typeof v === 'number' ? String(v) : null);
export function decodePending(text: unknown): PendingCreate | null {
const p = parseJsonColumn<Record<string, unknown>>(text);
if (!p || typeof p.uid !== 'string' || !p.uid || typeof p.target !== 'string' || !p.target) return null;
return { uid: p.uid, target: p.target };
}
export function decodePoison(text: unknown): PoisonMarker | null {
const p = parseJsonColumn<Record<string, unknown>>(text);
if (!p || typeof p.fp !== 'string' || typeof p.type !== 'string') return null;
const n = Number(p.n);
const until = Number(p.until);
return {
fp: p.fp,
type: p.type,
...(typeof p.description === 'string' ? { description: p.description } : {}),
n: Number.isFinite(n) ? n : 1,
until: Number.isFinite(until) ? until : 0,
};
}
export function decodeShadow(text: unknown): CalendarEventWire | null {
const e = parseJsonColumn<CalendarEventWire>(text);
return e && typeof e === 'object' && !Array.isArray(e) && typeof e.id === 'string' ? e : null;
}
/** Whether a row is an exception (ORIGINAL_ID or ORIGINAL_SYNC_ID set): it never uploads as an event of its own. */
export function isExceptionRow(cells: Row): boolean {
return has(cells[Events.ORIGINAL_ID]) || has(cells[Events.ORIGINAL_SYNC_ID]);
}
function decodeRow(row: Row, attendees: LocalAttendee[], reminders: LocalReminder[]): LocalEventRow {
const cells: Row = { ...row };
for (const c of [Events.SYNC_DATA1, Events.SYNC_DATA3, Events.SYNC_DATA4, Events.SYNC_DATA5]) delete cells[c];
return {
eventId: Number(row[Events._ID]),
calendarRowId: Number(row[Events.CALENDAR_ID]),
syncId: str(row[Events._SYNC_ID]),
dirty: Number(row[Events.DIRTY] ?? 0) === 1,
deleted: Number(row[Events.DELETED] ?? 0) === 1,
cells,
attendees,
reminders,
baseline: decodeBaseline(row[Events.SYNC_DATA4]),
pending: decodePending(row[Events.SYNC_DATA3]),
poison: decodePoison(row[Events.SYNC_DATA5]),
mutators: str(row[Events.MUTATORS]),
};
}
/** The recurrence id of an exception row: SYNC_DATA2 on our rows, else ORIGINAL_INSTANCE_TIME in the master's zone. */
export function exceptionKey(row: LocalEventRow, master: LocalEventRow | null): string | null {
const stored = row.cells[Events.SYNC_DATA2];
if (typeof stored === 'string' && parseLocalDateTime(stored)) return stored;
const oit = num(row.cells[Events.ORIGINAL_INSTANCE_TIME]);
if (oit === null) return null;
const source = master ?? row;
const allDay = Number(source.cells[Events.ALL_DAY] ?? row.cells[Events.ORIGINAL_ALL_DAY] ?? 0) === 1;
const zone = canonicalZone(source.cells[Events.EVENT_TIMEZONE]) ?? 'UTC';
return instanceTimeToKey(oit, allDay, allDay ? 'UTC' : zone);
}
export function decodeEvents(events: Row[], attendees: Row[], reminders: Row[]): LocalEvent[] {
const attendeesOf = new Map<number, LocalAttendee[]>();
for (const a of attendees) {
const id = Number(a[Attendees.EVENT_ID]);
const list = attendeesOf.get(id) ?? [];
const cells: Row = { ...a };
delete cells[Attendees._ID];
delete cells[Attendees.EVENT_ID];
list.push({ id: Number(a[Attendees._ID]), cells });
attendeesOf.set(id, list);
}
const remindersOf = new Map<number, LocalReminder[]>();
for (const r of reminders) {
const id = Number(r[Reminders.EVENT_ID]);
const list = remindersOf.get(id) ?? [];
const cells: Row = { ...r };
delete cells[Reminders._ID];
delete cells[Reminders.EVENT_ID];
list.push({ id: Number(r[Reminders._ID]), cells });
remindersOf.set(id, list);
}
const masters: LocalEvent[] = [];
const exceptionRows: LocalEventRow[] = [];
for (const row of [...events].sort((a, b) => Number(a[Events._ID]) - Number(b[Events._ID]))) {
const id = Number(row[Events._ID]);
const decoded = decodeRow(row, attendeesOf.get(id) ?? [], remindersOf.get(id) ?? []);
if (isExceptionRow(decoded.cells)) {
exceptionRows.push(decoded);
} else {
masters.push({ ...decoded, shadow: decodeShadow(row[Events.SYNC_DATA1]), exceptions: [] });
}
}
const byId = new Map(masters.map((m) => [m.eventId, m]));
const bySyncId = new Map<string, LocalEvent[]>();
for (const m of masters) {
if (!m.syncId) continue;
const list = bySyncId.get(m.syncId) ?? [];
list.push(m);
bySyncId.set(m.syncId, list);
}
const orphans: LocalEvent[] = [];
for (const row of exceptionRows) {
const originalId = num(row.cells[Events.ORIGINAL_ID]);
let master = originalId !== null ? byId.get(originalId) : undefined;
if (!master) {
const candidates = bySyncId.get(String(row.cells[Events.ORIGINAL_SYNC_ID] ?? '')) ?? [];
master = candidates.find((m) => m.calendarRowId === row.calendarRowId) ?? candidates[0];
}
if (!master) {
orphans.push({ ...row, shadow: null, exceptions: [] });
continue;
}
const exception: LocalException = {
...row,
recurrenceId: exceptionKey(row, master),
originalInstanceTime: num(row.cells[Events.ORIGINAL_INSTANCE_TIME]),
};
master.exceptions.push(exception);
}
// A CONTENT_EXCEPTION_URI split: the oldest row keeps the identity, the others are new events.
for (const group of bySyncId.values()) {
if (group.length < 2) continue;
group.sort((a, b) => a.eventId - b.eventId);
group[0].split = 'source';
for (const clone of group.slice(1)) clone.split = 'clone';
}
// A new row carrying a uid another row uses: create it under a fresh uid.
const uidCount = new Map<string, number>();
const uidsOf = (m: LocalEvent) => {
const uids = new Set<string>();
const uid = str(m.cells[Events.UID_2445]);
if (uid) uids.add(uid);
const pendingUid = pendingUidOf(m.syncId) ?? m.pending?.uid;
if (pendingUid) uids.add(pendingUid);
return uids;
};
for (const m of masters) for (const uid of uidsOf(m)) uidCount.set(uid, (uidCount.get(uid) ?? 0) + 1);
for (const m of masters) {
if (m.split || m.syncId !== null) continue;
if ([...uidsOf(m)].some((uid) => (uidCount.get(uid) ?? 0) > 1)) m.split = 'clone';
}
return [...masters, ...orphans];
}
export function decodeCalendar(row: Row): LocalCalendar {
const flags = parseJsonColumn<Record<string, unknown>>(row[Calendars.CAL_SYNC3]);
const shadow = parseJsonColumn<CalendarLike>(row[Calendars.CAL_SYNC2]);
const readOnly = flags?.readOnly === 'rights' || flags?.readOnly === 'subscription' ? flags.readOnly : undefined;
return {
calendarRowId: Number(row[Calendars._ID]),
syncId: str(row[Calendars._SYNC_ID]),
cells: { ...row },
shadow: shadow && typeof shadow === 'object' && !Array.isArray(shadow) && typeof shadow.id === 'string' ? shadow : null,
flags: flags && typeof flags === 'object' ? { ...(readOnly ? { readOnly } : {}), ...(flags.taskOnly === true ? { taskOnly: true } : {}) } : null,
};
}
/** Whether a master has no server identity yet (no `_SYNC_ID`, or a pending one). */
export function isNewMaster(local: LocalEventRow): boolean {
return parseObjectRef(local.syncId) === null;
}
+177
View File
@@ -0,0 +1,177 @@
/**
* Durations in the three dialects device sync meets (docs/device-sync.md,
* "Timing"):
*
* - JSCalendar (RFC 8984 §1.4.6) on the server: weeks, days and a time part
* with fractional seconds; `P1W` arrives from CalDAV DTENDs. The app's own
* parsers ignore weeks (`P1W` → 0) and signs, so none of them is reused.
* - AOSP's calendarcommon2 Duration in the DURATION column: `[+-]P` then
* `<digits><W|D|H|M|S>`, `T` ignored, `M` always minutes, no fractions and
* no `Y`. All-day rows must say `P<n>D`: CalendarProvider rewrites `P<n>S`
* with `Integer.parseInt` and throws on anything else ending in `S`.
* - Whole days are nominal (a calendar day in the event's zone), the rest is
* exact time.
*/
import {
addToWall,
canonicalZone,
resolveWallClock,
wallClockAt,
wallMs,
type WallClock,
} from './zoned-time';
export interface Duration {
negative: boolean;
/** Whole nominal days (weeks included). */
days: number;
/** The exact part in seconds (may be fractional). */
seconds: number;
}
const DAY_S = 86_400;
/**
* Parses any duration either side writes: RFC 8984/5545 text, signed or not,
* weeks and days, `M` as minutes, a fraction on the seconds, lower case, and
* AOSP's forms (`P3600S`, `P1DT0S`, `P0DT1H30M0S`). Null for anything else
* (years, months before the time part, an empty `P`).
*/
export function parseDuration(text: unknown): Duration | null {
if (typeof text !== 'string') return null;
const m = /^([+-])?P(.*)$/.exec(text.trim().toUpperCase());
if (!m || !m[2]) return null;
let days = 0;
let seconds = 0;
let any = false;
const token = /^(?:T|(\d+(?:[.,]\d+)?)([WDHMS]))/;
let rest = m[2];
while (rest) {
const t = token.exec(rest);
if (!t) return null;
rest = rest.slice(t[0].length);
if (t[0] === 'T') continue;
const unit = t[2];
const fractional = /[.,]/.test(t[1]);
if (fractional && unit !== 'S') return null;
const n = Number(t[1].replace(',', '.'));
any = true;
if (unit === 'W') days += n * 7;
else if (unit === 'D') days += n;
else if (unit === 'H') seconds += n * 3600;
else if (unit === 'M') seconds += n * 60;
else seconds += n;
}
if (!any) return null;
return { negative: m[1] === '-', days, seconds };
}
/** Total seconds with a day counted as 24 hours, signed: CalendarProvider's arithmetic. */
export function durationSeconds(d: Duration): number {
const total = d.days * DAY_S + d.seconds;
return d.negative ? -total : total;
}
/** Seconds of a duration text, or null when it can't be read. */
export function durationTextSeconds(text: unknown): number | null {
const d = parseDuration(text);
return d ? durationSeconds(d) : null;
}
/**
* A JSCalendar Duration in the strict grammar: `P<n>D`, `T<h>H<m>M<s>S` with
* minutes kept between hours and seconds (`PT1H0M5S`; `PT1H5S` is not
* valid), whole seconds, never weeks, `PT0S` for zero. Negative input is
* treated as zero: an event's duration is unsigned.
*/
export function formatJsDuration(days: number, seconds: number): string {
const d = Math.max(0, Math.floor(days));
let s = Math.max(0, Math.round(seconds));
let out = 'P';
if (d > 0) out += `${d}D`;
if (s > 0) {
const h = Math.floor(s / 3600);
s -= h * 3600;
const min = Math.floor(s / 60);
s -= min * 60;
out += 'T';
if (h > 0) out += `${h}H`;
if (min > 0 || (h > 0 && s > 0)) out += `${min}M`;
if (s > 0) out += `${s}S`;
}
return out === 'P' ? 'PT0S' : out;
}
/** A fixed number of seconds as a JSCalendar duration, with whole days split off as nominal days. */
export function formatJsDurationSeconds(totalSeconds: number): string {
const s = Math.max(0, Math.round(totalSeconds));
return formatJsDuration(Math.floor(s / DAY_S), s % DAY_S);
}
/**
* The DURATION column: `P<n>D` for all-day rows (at least one day; a time
* part is rounded up), `P<seconds>S` for timed rows. Never a fraction, a
* `Y`, a sign or weeks.
*/
export function formatAospDuration(totalSeconds: number, allDay: boolean): string {
if (allDay) return `P${Math.max(1, Math.ceil(totalSeconds / DAY_S))}D`;
return `P${Math.max(0, Math.round(totalSeconds))}S`;
}
/** Whole days of an all-day duration on the device: at least one, a time part rounded up. */
export function allDayDays(d: Duration | null): number {
if (!d || d.negative) return 1;
return Math.max(1, Math.ceil((d.days * DAY_S + d.seconds) / DAY_S));
}
/**
* The end of a duration that starts at `startUtc`: whole days in the zone's
* wall clock, then exact seconds. With no whole days the start instant is
* used as is, so a start that is the second instant of a repeated hour does
* not collapse onto the first one.
*/
export function addDuration(startUtc: number, d: Duration, zone: string): number {
const days = d.negative ? 0 : d.days;
const seconds = d.negative ? 0 : d.seconds;
if (days === 0) return startUtc + Math.round(seconds * 1000);
const z = canonicalZone(zone) ?? 'UTC';
const wall = wallClockAt(startUtc, z);
const shifted = resolveWallClock(addToWall(wall, days), z).utc + (startUtc % 1000 + 1000) % 1000;
return shifted + Math.round(seconds * 1000);
}
/**
* DTSTART/DTEND as a duration with whole days nominal in `zone` and the rest
* exact: 00:00 to 00:00 the next day is `P1D` even across a DST change.
*/
export function durationBetween(startUtc: number, endUtc: number, zone: string): Duration {
if (!(endUtc > startUtc)) return { negative: false, days: 0, seconds: 0 };
const z = canonicalZone(zone) ?? 'UTC';
const sw = wallClockAt(startUtc, z);
const ew = wallClockAt(endUtc, z);
let days = Math.round((dateMs(ew) - dateMs(sw)) / 86_400_000);
if (timeOfDay(ew) < timeOfDay(sw)) days -= 1;
days = Math.max(0, days);
let anchor = days > 0 ? addDuration(startUtc, { negative: false, days, seconds: 0 }, z) : startUtc;
while (days > 0 && anchor > endUtc) {
days -= 1;
anchor = days > 0 ? addDuration(startUtc, { negative: false, days, seconds: 0 }, z) : startUtc;
}
return { negative: false, days, seconds: Math.max(0, (endUtc - anchor) / 1000) };
}
function dateMs(w: WallClock): number {
return wallMs({ ...w, hour: 0, minute: 0, second: 0 });
}
function timeOfDay(w: WallClock): number {
return w.hour * 3600 + w.minute * 60 + w.second;
}
/** Equal lengths with a day counted as 24 hours (`PT24H` and `P1D` are the same to CalendarProvider). */
export function sameDurationLength(a: unknown, b: unknown): boolean {
const sa = durationTextSeconds(a);
const sb = durationTextSeconds(b);
if (sa === null || sb === null) return (a ?? null) === (b ?? null);
return sa === sb;
}
+248
View File
@@ -0,0 +1,248 @@
/**
* Device edits of attendees, reminders and the location, turned into
* JSCalendar values (docs/device-sync.md, "Other properties").
*
* - Attendees: the user's own status is their RSVP; an added row is a new
* participant (`roles: {attendee: true}`, `needs-action`, `expectReply`);
* a removed row deletes its participants. Other people's statuses, the
* organizer relationship and, for lossy editors, the attendee type are not
* the device's to change.
* - Reminders replace the alerts a Reminders row can hold, keeping the keys
* of those still there; the others stay.
* - The location edits the first location's name, adds one, or deletes it.
*
* Changes come back as paths (raw member names) so the caller escapes them
* once, with `ptr`, wherever the patch is rooted.
*/
import type { Alert, Participant } from '../../api/types';
import { Attendees, AttendeeType, Events } from '../android-columns';
import type { Row } from '../types';
import type { CalendarLike, CalendarEventWire } from '../wire';
import { clone } from '../common/json';
import { addressGroups, isSelfAddress, participantIdFor, type SelfContext } from './attendees';
import { attendeeDiffers, attendeesByKey } from './units';
import { effectiveAlerts, remindersToAlerts } from './reminders';
import { participationFromDevice } from './values';
export type MintKey = (taken?: Iterable<string>) => string;
/** One change: a path of raw member names below some root, and the value (null removes). */
export interface PathChange {
path: string[];
value: unknown;
}
export interface AttendeeEdit {
kind: 'add' | 'remove' | 'change';
key: string;
row?: Row;
baseline?: Row;
}
/** What the device changed in the attendee rows since `baselineRows`. */
export function attendeeEdits(currentRows: Row[], baselineRows: Row[], self: SelfContext, lossy: boolean): AttendeeEdit[] {
const cur = attendeesByKey(currentRows, self);
const bl = attendeesByKey(baselineRows, self);
const edits: AttendeeEdit[] = [];
for (const key of new Set([...bl.keys(), ...cur.keys()])) {
const row = cur.get(key);
const baseline = bl.get(key);
if (row && !baseline) edits.push({ kind: 'add', key, row });
else if (!row && baseline) edits.push({ kind: 'remove', key, baseline });
else if (attendeeDiffers(row, baseline, lossy)) edits.push({ kind: 'change', key, row, baseline });
}
return edits;
}
/** Whether an attendee address key is the user's. */
export function isSelfKey(key: string): boolean {
return key.startsWith('self:');
}
/** A participant for an attendee row an app added. */
export function newParticipant(row: Row, self: SelfContext): Participant {
const email = String(row[Attendees.ATTENDEE_EMAIL] ?? '').trim();
const p: Participant = {
'@type': 'Participant',
calendarAddress: `mailto:${email}`,
roles: { attendee: true },
participationStatus: 'needs-action',
expectReply: true,
};
const name = row[Attendees.ATTENDEE_NAME];
if (typeof name === 'string' && name.trim()) p.name = name.trim();
const type = Number(row[Attendees.ATTENDEE_TYPE]);
if (type === AttendeeType.OPTIONAL) p.roles = { attendee: true, optional: true };
if (type === AttendeeType.RESOURCE) p.kind = 'resource';
if (isSelfAddress(email, self)) {
const status = participationFromDevice(row[Attendees.ATTENDEE_STATUS]);
if (status) p.participationStatus = status;
delete p.expectReply;
}
return p;
}
export interface AttendeeChanges {
/** Changes below the participants map (`[id]`, `[id, 'participationStatus']`, …). */
changes: PathChange[];
/** The whole map after the edits. */
participants: Record<string, Participant>;
/** The user's own status changed. */
rsvp: boolean;
/** Someone was added or removed, or a name or type changed. */
others: boolean;
}
/** Applies attendee edits to a participants map (not changed in place). */
export function applyAttendeeEdits(
participants: Record<string, Participant> | null | undefined,
edits: AttendeeEdit[],
self: SelfContext,
mintKey: MintKey,
lossy: boolean,
): AttendeeChanges {
const map = clone(participants ?? {}) as Record<string, Participant>;
const changes: PathChange[] = [];
let rsvp = false;
let others = false;
const groups = addressGroups(map, self);
const idsOf = (key: string) => groups.find((g) => g.key === key)?.ids ?? [];
const setStatus = (id: string, row: Row, baseline?: Row) => {
if (baseline && Number(row[Attendees.ATTENDEE_STATUS]) === Number(baseline[Attendees.ATTENDEE_STATUS])) return;
const status = participationFromDevice(row[Attendees.ATTENDEE_STATUS]);
if (!status || map[id].participationStatus === status) return;
map[id] = { ...map[id], participationStatus: status };
changes.push({ path: [id, 'participationStatus'], value: status });
rsvp = true;
};
for (const edit of edits) {
const selfRow = isSelfKey(edit.key);
if (edit.kind === 'add') {
const existing = participantIdFor(map, edit.row![Attendees.ATTENDEE_EMAIL], self);
if (existing) {
// The server has this address already (added meanwhile): only the user's own status counts.
if (selfRow) setStatus(existing, edit.row!);
continue;
}
// An app adding the owner's row next to new guests: Stalwart adds the organizer itself.
if (selfRow) continue;
const id = mintKey(Object.keys(map));
map[id] = newParticipant(edit.row!, self);
changes.push({ path: [id], value: map[id] });
others = true;
} else if (edit.kind === 'remove') {
if (selfRow) continue;
for (const id of idsOf(edit.key)) {
delete map[id];
changes.push({ path: [id], value: null });
others = true;
}
} else {
const id = participantIdFor(map, edit.row![Attendees.ATTENDEE_EMAIL], self);
if (!id) continue;
if (selfRow) setStatus(id, edit.row!, edit.baseline);
const name = edit.row![Attendees.ATTENDEE_NAME];
if (String(name ?? '') !== String(edit.baseline![Attendees.ATTENDEE_NAME] ?? '')) {
if (typeof name === 'string' && name.trim()) {
map[id] = { ...map[id], name: name.trim() };
changes.push({ path: [id, 'name'], value: name.trim() });
} else if (map[id].name !== undefined) {
const { name: _gone, ...rest } = map[id];
map[id] = rest;
changes.push({ path: [id, 'name'], value: null });
}
others = true;
}
const type = Number(edit.row![Attendees.ATTENDEE_TYPE]);
if (!lossy && type !== Number(edit.baseline![Attendees.ATTENDEE_TYPE])) {
const roles = { ...(map[id].roles ?? { attendee: true }) };
if (type === AttendeeType.OPTIONAL) roles.optional = true;
else delete roles.optional;
map[id] = { ...map[id], roles };
changes.push({ path: [id, 'roles'], value: roles });
if (type === AttendeeType.RESOURCE && map[id].kind !== 'resource') {
map[id] = { ...map[id], kind: 'resource' };
changes.push({ path: [id, 'kind'], value: 'resource' });
} else if (type !== AttendeeType.RESOURCE && map[id].kind === 'resource') {
const { kind: _gone, ...rest } = map[id];
map[id] = rest;
changes.push({ path: [id, 'kind'], value: null });
}
others = true;
}
}
}
return { changes, participants: map, rsvp, others };
}
export interface AlertChanges {
/** Changes relative to the event: `['alerts', k]`, `['useDefaultAlerts']` or the whole `['alerts']`. */
changes: PathChange[];
/** The alerts in force afterwards. */
alerts: Record<string, Alert>;
}
/**
* The device's reminders as alert changes. With `useDefaultAlerts` the
* calendar's defaults were in force: the event switches to explicit alerts
* (built from them). An event without alerts gets the whole map, since a
* pointer below a missing property fails.
*/
export function reminderChanges(
event: Pick<CalendarEventWire, 'alerts' | 'useDefaultAlerts' | 'showWithoutTime'>,
calendar: CalendarLike | undefined,
reminders: Row[],
mintKey: MintKey,
): AlertChanges {
const current = effectiveAlerts(event, calendar);
const { alerts, removed, added } = remindersToAlerts(current, reminders, mintKey);
const changes: PathChange[] = [];
if (event.useDefaultAlerts) {
changes.push({ path: ['useDefaultAlerts'], value: false });
changes.push({ path: ['alerts'], value: Object.keys(alerts).length ? alerts : null });
} else if (!Object.keys(event.alerts ?? {}).length) {
if (Object.keys(added).length) changes.push({ path: ['alerts'], value: alerts });
} else {
for (const key of removed) changes.push({ path: ['alerts', key], value: null });
for (const [key, alert] of Object.entries(added)) changes.push({ path: ['alerts', key], value: alert });
}
return { changes, alerts };
}
/** The location edit, as changes relative to the event and as the resulting map. */
export function locationChanges(
event: Pick<CalendarEventWire, 'locations'>,
name: unknown,
mintKey: MintKey,
): { changes: PathChange[]; locations: Record<string, unknown> | null } {
const locations = clone((event.locations ?? {}) as Record<string, Record<string, unknown>>);
const first = Object.keys(locations)[0];
const text = typeof name === 'string' ? name.trim() : '';
const changes: PathChange[] = [];
if (!text) {
if (first) {
delete locations[first];
changes.push({ path: ['locations', first], value: null });
}
} else if (first) {
locations[first] = { ...locations[first], name: text };
changes.push({ path: ['locations', first, 'name'], value: text });
} else {
const key = mintKey();
locations[key] = { '@type': 'Location', name: text };
changes.push({ path: ['locations'], value: { [key]: locations[key] } });
}
return { changes, locations: Object.keys(locations).length ? locations : null };
}
/** The title cell as JSCalendar text. */
export function titleValue(cells: Row): string {
const t = cells[Events.TITLE];
return typeof t === 'string' ? t : t === null || t === undefined ? '' : String(t);
}
/** The description cell as JSCalendar text; null removes it. */
export function descriptionValue(cells: Row): string | null {
const d = cells[Events.DESCRIPTION];
return typeof d === 'string' && d ? d : null;
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Recurrence overrides and the exception rows they become
* (docs/device-sync.md, "Recurrence").
*
* An override value is a PatchObject against the master (RFC 8984 §4.3.4),
* so an exception row shows master ⊕ override, applied pointer by pointer
* (the app's own expansion shallow-merges and turns
* `participants/p1/participationStatus` into a junk key, R3 B10). Stalwart
* drops a handful of properties from every override it stores; they are
* never sent in one.
*/
import type { CalendarEventWire } from '../wire';
import { applyPatch, ptrSegments } from '../common/patch';
import { clone } from '../common/json';
import { dateToUtcMidnight, localToUtc, utcMidnightToDate, utcToLocal } from './zoned-time';
/** Properties calcard removes from an override (`remove_forbidden_override_patches`). */
export const FORBIDDEN_OVERRIDE_KEYS = [
'@type',
'method',
'organizerCalendarAddress',
'privacy',
'prodId',
'recurrenceId',
'recurrenceIdTimeZone',
'sentBy',
'uid',
'recurrenceRule',
'recurrenceOverrides',
] as const;
const FORBIDDEN = new Set<string>(FORBIDDEN_OVERRIDE_KEYS);
/** Properties of the master that never belong to an instance. */
const SERIES_ONLY = ['recurrenceRule', 'recurrenceRules', 'recurrenceOverrides', 'excludedRecurrenceRule', 'excludedRecurrenceRules'];
export type Override = Record<string, unknown>;
export function isExcluded(override: unknown): boolean {
return !!override && typeof override === 'object' && (override as Override).excluded === true;
}
/** An override without what Stalwart would drop from it (top-level and pointer keys alike). */
export function withoutForbiddenKeys(override: Override): Override {
const out: Override = {};
for (const [key, value] of Object.entries(override)) {
if (FORBIDDEN.has(ptrSegments(key)[0])) continue;
out[key] = value;
}
return out;
}
/**
* One instance of a series: the master without its recurrence, patched with
* the override (pointer by pointer; a pointer whose parent is missing is
* skipped rather than failing the instance), starting at the recurrence id
* unless the override moves it.
*/
export function instanceOf(master: CalendarEventWire, key: string, override?: Override | null): CalendarEventWire {
let instance = clone(master) as Record<string, unknown>;
for (const name of SERIES_ONLY) delete instance[name];
instance.start = key;
for (const [pointer, value] of Object.entries(override ?? {})) {
if (pointer === 'excluded' || pointer === 'updated') continue;
if (FORBIDDEN.has(ptrSegments(pointer)[0])) continue;
const patched = applyPatch(instance, { [pointer]: value });
if (patched) instance = patched;
}
return instance as CalendarEventWire;
}
/** ORIGINAL_INSTANCE_TIME of a recurrence id: its instant in the master's zone, UTC midnight for all-day series. */
export function keyToInstanceTime(key: string, allDay: boolean, zone: string): number | null {
return allDay ? dateToUtcMidnight(key) : localToUtc(key, zone);
}
/** The recurrence id of an ORIGINAL_INSTANCE_TIME (the inverse of `keyToInstanceTime`). */
export function instanceTimeToKey(originalInstanceTime: number, allDay: boolean, zone: string): string {
return allDay ? utcMidnightToDate(originalInstanceTime) : utcToLocal(originalInstanceTime, zone);
}
/** The overrides of an event as a plain map (Stalwart omits the property when there are none). */
export function overridesOf(event: CalendarEventWire | null | undefined): Record<string, Override> {
const map = event?.recurrenceOverrides;
return map && typeof map === 'object' ? (map as Record<string, Override>) : {};
}
+114
View File
@@ -0,0 +1,114 @@
/**
* The EXDATE column ↔ excluded recurrence overrides (docs/device-sync.md,
* "Recurrence"). AOSP's RecurrenceSet reads lines split by `\n`, each
* `[<tzid>;]<date>[,<date>…]` with a bare Olson id as prefix; dates are
* `YYYYMMDD`, `YYYYMMDDTHHMMSS` or `…Z`, and a line without a prefix is UTC,
* not the event's zone. Device sync writes one line without a prefix: UTC
* date-times for timed events, dates for all-day ones.
*/
import {
canonicalZone,
dateToUtcMidnight,
formatDate,
localToUtc,
resolveWallClock,
utcMidnightToDate,
utcToLocal,
wallFromMs,
type WallClock,
} from './zoned-time';
import type { RuleZone } from './rrule';
const compactUtc = (utc: number) => {
const w = wallFromMs(utc);
const p = (n: number) => String(n).padStart(2, '0');
return `${formatDate(w).replace(/-/g, '')}T${p(w.hour)}${p(w.minute)}${p(w.second)}Z`;
};
/** One EXDATE entry for a recurrence id (a LocalDateTime in the event's zone). */
export function exdateEntry(key: string, ctx: RuleZone): string | null {
if (ctx.allDay) {
const midnight = dateToUtcMidnight(key);
return midnight === null ? null : formatDate(wallFromMs(midnight)).replace(/-/g, '');
}
const utc = localToUtc(key, ctx.zone);
return utc === null ? null : compactUtc(utc);
}
/** The EXDATE column for a set of recurrence ids, sorted; null for none. */
export function formatExdate(keys: Iterable<string>, ctx: RuleZone): string | null {
const entries = [...new Set([...keys].map((k) => exdateEntry(k, ctx)).filter((e): e is string => !!e))].sort();
return entries.length ? entries.join(',') : null;
}
interface ParsedEntry {
/** The instant (timed) or UTC midnight of the date. */
utc: number;
dateOnly: boolean;
}
function parseEntry(text: string, zone: string | null): ParsedEntry | null {
const m = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/i.exec(text.trim());
if (!m) return null;
const wall: WallClock = {
year: Number(m[1]),
month: Number(m[2]),
day: Number(m[3]),
hour: Number(m[4] ?? 0),
minute: Number(m[5] ?? 0),
second: Number(m[6] ?? 0),
};
const dateOnly = m[4] === undefined;
if (m[7] || !zone) return { utc: resolveWallClock(wall, 'UTC').utc, dateOnly };
return { utc: resolveWallClock(wall, zone).utc, dateOnly };
}
/** Every entry of an EXDATE (or RDATE) column as an instant; garbage is skipped. */
export function exdateInstants(text: unknown): ParsedEntry[] {
if (typeof text !== 'string' || !text.trim()) return [];
const out: ParsedEntry[] = [];
for (const line of text.split(/\r?\n/)) {
if (!line.trim()) continue;
const semi = line.indexOf(';');
const zone = semi >= 0 ? canonicalZone(line.slice(0, semi).trim()) : null;
const dates = semi >= 0 ? line.slice(semi + 1) : line;
for (const d of dates.split(',')) {
const entry = parseEntry(d, zone);
if (entry) out.push(entry);
}
}
return out;
}
/**
* The recurrence ids an EXDATE column excludes, in the event's zone
* (all-day: the date at midnight). A timed entry's key is its instant in the
* zone; a date-only entry of a timed event is AOSP's UTC midnight of it.
*/
export function parseExdate(text: unknown, ctx: RuleZone): string[] {
const keys = new Set<string>();
for (const entry of exdateInstants(text)) {
keys.add(ctx.allDay ? utcMidnightToDate(entry.utc) : utcToLocal(entry.utc, ctx.zone));
}
return [...keys].sort();
}
/**
* The entries as a set of normalised instants (`YYYYMMDDTHHMMSSZ`, or
* `YYYYMMDD` for dates and for every entry of an all-day event): the
* column's meaning independent of how an app spelled it.
*/
export function exdateSet(text: unknown, allDay = false): Set<string> {
const out = new Set<string>();
for (const entry of exdateInstants(text)) {
out.add(entry.dateOnly || allDay ? formatDate(wallFromMs(entry.utc)).replace(/-/g, '') : compactUtc(entry.utc));
}
return out;
}
/** The recurrence id of one normalised entry (see `exdateSet`). */
export function exdateEntryKey(entry: string, ctx: RuleZone): string | null {
const parsed = parseEntry(entry, null);
if (!parsed) return null;
return ctx.allDay ? utcMidnightToDate(parsed.utc) : utcToLocal(parsed.utc, ctx.zone);
}
+195
View File
@@ -0,0 +1,195 @@
/**
* A server event as the rows it becomes on the device: the master row's
* mapped cells, its attendees and reminders, and one exception row per
* override that is not an exclusion (docs/device-sync.md, "Calendar
* mapping"). The cells are what CalendarProvider stores after the write:
* all-day times at UTC midnight, DURATION only on recurring masters, the
* organizer the provider would fill in, STATUS never NULL. Predicting the
* provider keeps echo downloads free of writes.
*/
import { Events } from '../android-columns';
import type { CalendarContext } from '../planner';
import type { Row } from '../types';
import type { CalendarEventWire } from '../wire';
import { htmlToPlainText } from '../../lib/compose-html';
import { attendeeRows, hasMailtoParticipants, organizerAddress } from './attendees';
import { truncateUtf8 } from './columns';
import { formatExdate } from './exdate';
import { instanceOf, isExcluded, keyToInstanceTime, overridesOf } from './exceptions';
import { effectiveAlerts, reminderRows } from './reminders';
import { isRuleRepresentable, ruleToRRule } from './rrule';
import { eventZone, timingCells } from './timing';
import {
availabilityToDevice,
cssColorToArgb,
privacyToDevice,
statusToDevice,
} from './values';
import { parseLocalDateTime } from './zoned-time';
export type ImageContext = Pick<
CalendarContext,
'deviceZone' | 'ownerAccount' | 'selfAddresses' | 'reminderOwner' | 'calendar'
>;
export interface RowImage {
cells: Row;
attendees: Row[];
/** Null when Bulwark owns reminders: Reminders rows are then left alone. */
reminders: Row[] | null;
/** Whether the event has `mailto:` participants (ORGANIZER is written only then). */
hasParticipants: boolean;
truncatedDescription: boolean;
}
export interface ExceptionImage extends RowImage {
key: string;
}
export interface EventImage {
master: RowImage;
/** Exception rows by recurrence id. */
exceptions: Map<string, ExceptionImage>;
/** Recurrence ids of `{excluded: true}` overrides (the EXDATE column). */
excluded: Set<string>;
zone: string;
allDay: boolean;
floating: boolean;
recurring: boolean;
/** False when the rule needs RSCALE, SKIP or leap months: shown approximately, timing never uploaded. */
representable: boolean;
}
/** DESCRIPTION: HTML turned to text (and uploaded as text/plain only when edited), cut at 64 KB. */
export function descriptionCell(event: CalendarEventWire): { text: string | null; truncated: boolean } {
let text = typeof event.description === 'string' ? event.description : '';
if (text && String(event.descriptionContentType ?? '').toLowerCase().startsWith('text/html')) text = htmlToPlainText(text);
if (!text) return { text: null, truncated: false };
const cut = truncateUtf8(text);
return { text: cut.text, truncated: cut.truncated };
}
/** The first location's name, in server order. */
export function locationCell(event: CalendarEventWire): string | null {
const first = Object.values(event.locations ?? {})[0];
const name = first && typeof first === 'object' ? first.name : undefined;
return typeof name === 'string' && name ? name : null;
}
function rowImage(
event: CalendarEventWire,
timing: Row,
calendarRowId: number,
calendarId: string,
ctx: ImageContext,
): RowImage {
const description = descriptionCell(event);
const hasParticipants = hasMailtoParticipants(event.participants);
const cells: Row = {
[Events.CALENDAR_ID]: calendarRowId,
[Events.TITLE]: typeof event.title === 'string' && event.title ? event.title : null,
[Events.DESCRIPTION]: description.text,
[Events.EVENT_LOCATION]: locationCell(event),
[Events.STATUS]: statusToDevice(event.status),
[Events.AVAILABILITY]: availabilityToDevice(event.freeBusyStatus),
[Events.ACCESS_LEVEL]: privacyToDevice(event.privacy),
[Events.EVENT_COLOR]: cssColorToArgb(event.color),
[Events.ORGANIZER]: (hasParticipants ? organizerAddress(event, ctx) : null) ?? ctx.ownerAccount,
...timing,
};
const reminders =
ctx.reminderOwner === 'device'
? reminderRows(effectiveAlerts(event, ctx.calendar(calendarId)))
: null;
return {
cells,
attendees: hasParticipants ? attendeeRows(event, ctx) : [],
reminders,
hasParticipants,
truncatedDescription: description.truncated,
};
}
/**
* The rows of a server event in the calendar row `calendarRowId` (server
* calendar `calendarId`, whose default alerts apply). Null when the event's
* start can't be read.
*/
export function eventImage(
event: CalendarEventWire,
calendarRowId: number,
calendarId: string,
ctx: ImageContext,
): EventImage | null {
const { zone, allDay, floating } = eventZone(event, ctx.deviceZone);
const rule = event.recurrenceRule ?? null;
const rrule = rule ? ruleToRRule(rule, { allDay, zone }) : null;
const recurring = rrule !== null;
const timing = timingCells(event, recurring, ctx.deviceZone);
if (!timing) return null;
const overrides = recurring ? overridesOf(event) : {};
const excluded = new Set<string>();
const exceptions = new Map<string, ExceptionImage>();
for (const [key, override] of Object.entries(overrides)) {
if (!parseLocalDateTime(key) || !override || typeof override !== 'object') continue;
if (isExcluded(override)) {
excluded.add(key);
continue;
}
const originalInstanceTime = keyToInstanceTime(key, allDay, zone);
if (originalInstanceTime === null) continue;
const instance = instanceOf(event, key, override);
// An instance keeps the series' kind of time; only its start and length move.
const instanceTiming = timingCells(
{ ...instance, showWithoutTime: event.showWithoutTime, timeZone: instance.timeZone ?? event.timeZone },
false,
ctx.deviceZone,
);
if (!instanceTiming) continue;
const image = rowImage(instance, instanceTiming, calendarRowId, calendarId, ctx);
image.cells[Events.ORIGINAL_INSTANCE_TIME] = originalInstanceTime;
image.cells[Events.ORIGINAL_ALL_DAY] = allDay ? 1 : 0;
exceptions.set(key, { ...image, key });
}
const master = rowImage(event, timing, calendarRowId, calendarId, ctx);
master.cells[Events.RRULE] = rrule;
master.cells[Events.EXDATE] = recurring ? formatExdate(excluded, { allDay, zone }) : null;
return {
master,
exceptions,
excluded,
zone,
allDay,
floating,
recurring,
representable: isRuleRepresentable(rule),
};
}
/**
* The row an instance of the series would have without an exception row:
* the master at that recurrence id. The baseline of an exception an app
* creates, and what its changes are measured against.
*/
export function plainInstanceImage(
master: CalendarEventWire,
key: string,
calendarRowId: number,
calendarId: string,
ctx: ImageContext,
): ExceptionImage | null {
const { zone, allDay } = eventZone(master, ctx.deviceZone);
const override = overridesOf(master)[key];
const instance = instanceOf(master, key, override && !isExcluded(override) ? override : null);
const timing = timingCells(
{ ...instance, showWithoutTime: master.showWithoutTime, timeZone: instance.timeZone ?? master.timeZone },
false,
ctx.deviceZone,
);
const originalInstanceTime = keyToInstanceTime(key, allDay, zone);
if (!timing || originalInstanceTime === null) return null;
const image = rowImage(instance, timing, calendarRowId, calendarId, ctx);
image.cells[Events.ORIGINAL_INSTANCE_TIME] = originalInstanceTime;
image.cells[Events.ORIGINAL_ALL_DAY] = allDay ? 1 : 0;
return { ...image, key };
}
+499
View File
@@ -0,0 +1,499 @@
/**
* A server event into its rows (docs/device-sync.md, "Change detection and
* merge rules"). One writer serves downloads, accepted uploads, reverts and
* zone changes:
*
* - a clean row is rewritten to the image of the server event (only cells
* that differ are written, so an echo writes nothing);
* - a dirty row is merged per unit against its baseline (what the device
* changed) and the old shadow's image (what the server changed): the server
* wins real conflicts, local changes the server did not touch stay dirty
* for the upload;
* - exception rows are matched to overrides by recurrence id and merged the
* same way; a device deletion of an instance wins over a server edit, a
* server removal wins over a device edit.
*
* Every write into an existing item is guarded: the group first asserts
* DIRTY and the projection the plan was made from (with attendees and
* reminders for the rows that are dirty: a clean row's DIRTY catches any app
* edit of them) and the number of exception rows.
*/
import { Events } from '../android-columns';
import type { CalendarContext, EventBaseline, LocalEvent, LocalEventRow, LocalException, OpGroup } from '../planner';
import type { Row } from '../types';
import type { CalendarEventWire } from '../wire';
import { collectionKey, exceptionRef, objectRef } from '../common/ids';
import { EventStatus } from '../android-columns';
import type { SelfContext } from './attendees';
import { eventImage, plainInstanceImage, type EventImage, type ExceptionImage, type RowImage } from './image';
import {
GroupBuilder,
assertExceptionCount,
assertRow,
deleteRow,
eventInsert,
eventWrite,
insertRow,
updateRow,
type RowState,
} from './rows';
import {
COLUMN_UNITS,
EXCEPTION_COLUMN_UNITS,
MASTER_COLUMN_UNITS,
attendeeDiffers,
attendeesByKey,
baselineOfImage,
columnUnitDiffers,
deviceChangedUnit,
exdateEntries,
isLossyEditor,
remindersDiffer,
sideOfBaseline,
sideOfImage,
sideOfRow,
} from './units';
export interface CalendarTarget {
calendarRowId: number;
calendarId: string;
}
/**
* The calendar row an event lives in: the row it is already in while the
* event still belongs to that calendar, else its first selected calendar by
* id (docs/device-sync.md, "Decisions and limitations").
*/
export function pickCalendar(
event: CalendarEventWire,
local: LocalEventRow | null,
ctx: Pick<CalendarContext, 'jmapAccountId' | 'calendarRowId' | 'calendarIdOfRow' | 'isSelected'>,
): CalendarTarget | null {
const ids = Object.entries(event.calendarIds ?? {})
.filter(([, on]) => on)
.map(([id]) => id)
.sort();
const candidates = ids.filter((id) => ctx.calendarRowId(id) !== null && ctx.isSelected(collectionKey(ctx.jmapAccountId, id)));
if (local) {
const current = ctx.calendarIdOfRow(local.calendarRowId);
if (current && current.jmapAccountId === ctx.jmapAccountId && candidates.includes(current.calendarId)) {
return { calendarRowId: local.calendarRowId, calendarId: current.calendarId };
}
}
if (!candidates.length) return null;
return { calendarRowId: ctx.calendarRowId(candidates[0])!, calendarId: candidates[0] };
}
// ─── One row ────────────────────────────────────────────
export interface RowMerge {
cells: Row;
attendees: Row[];
reminders: Row[] | null;
baseline: EventBaseline;
/** Units whose local change survived (the row stays dirty). */
kept: string[];
conflicts: number;
}
export interface RowMergeInput {
isException: boolean;
current: LocalEventRow;
/** What the device last wrote (SYNC_DATA4), or an image standing in for it. */
baseline: EventBaseline;
/** The old server state's image of this row. */
base: RowImage;
/** The new server state's image of this row. */
remote: RowImage;
/** Whether the row's differences from its baseline are edits (DIRTY). */
dirty: boolean;
/** Units counted as edited even on a clean row (a split source's rule). */
forced?: ReadonlySet<string>;
/** An editor that rewrites attendee types (Fossify). */
lossy?: boolean;
self: SelfContext;
}
/** Per-unit merge of one row (see the header): what to write, the new baseline, what stays dirty. */
export function mergeRow(input: RowMergeInput): RowMerge {
const { current, baseline, base, remote, dirty } = input;
const forced = input.forced ?? new Set<string>();
const cur = sideOfRow(current);
const bl = sideOfBaseline(baseline);
const bs = sideOfImage(base);
const rm = sideOfImage(remote);
const cells: Row = { ...remote.cells };
const remoteBaseline = baselineOfImage(remote, input.isException);
const baseCells: Row = { ...remoteBaseline.cells };
const kept: string[] = [];
let conflicts = 0;
const decide = (unit: string, local: boolean, remoteChanged: boolean, same: boolean): 'local' | 'remote' => {
if (!local) return 'remote';
if (!remoteChanged) {
kept.push(unit);
return 'local';
}
if (!same) conflicts += 1;
return 'remote';
};
for (const unit of input.isException ? EXCEPTION_COLUMN_UNITS : MASTER_COLUMN_UNITS) {
const local = (dirty || forced.has(unit)) && deviceChangedUnit(unit, cur, bl);
const choice = decide(unit, local, columnUnitDiffers(unit, bs, rm), !columnUnitDiffers(unit, cur, rm));
if (choice === 'local') {
for (const c of COLUMN_UNITS[unit]) {
cells[c] = current.cells[c] ?? null;
baseCells[c] = baseline.cells[c] ?? null;
}
}
}
if (!input.isException) {
const curE = exdateEntries(cur);
const blE = exdateEntries(bl);
const bsE = exdateEntries(bs);
const rmE = exdateEntries(rm);
const final = new Set<string>();
const finalBaseline = new Set<string>();
for (const e of new Set([...curE, ...blE, ...bsE, ...rmE])) {
const choice = decide(`exdate:${e}`, dirty && curE.has(e) !== blE.has(e), bsE.has(e) !== rmE.has(e), curE.has(e) === rmE.has(e));
if (choice === 'remote') {
if (rmE.has(e)) {
final.add(e);
finalBaseline.add(e);
}
} else {
if (curE.has(e)) final.add(e);
if (blE.has(e)) finalBaseline.add(e);
}
}
const recurring = !!cells[Events.RRULE];
// Our own column text when nothing local is kept, so an echo compares equal character for character.
cells[Events.EXDATE] = !recurring || !final.size ? null : sameEntries(final, rmE) ? remote.cells[Events.EXDATE] : [...final].sort().join(',');
baseCells[Events.EXDATE] = !finalBaseline.size ? null : [...finalBaseline].sort().join(',');
}
const curA = attendeesByKey(cur.attendees, input.self);
const blA = attendeesByKey(bl.attendees, input.self);
const bsA = attendeesByKey(bs.attendees, input.self);
const rmA = attendeesByKey(rm.attendees, input.self);
const attendees: Row[] = [];
const baselineAttendees: Row[] = [];
for (const key of new Set([...rmA.keys(), ...curA.keys(), ...blA.keys(), ...bsA.keys()])) {
const local = dirty && attendeeDiffers(curA.get(key), blA.get(key), input.lossy);
const choice = decide(
`attendee:${key}`,
local,
attendeeDiffers(bsA.get(key), rmA.get(key)),
!attendeeDiffers(curA.get(key), rmA.get(key), input.lossy),
);
if (choice === 'remote') {
const r = rmA.get(key);
if (r) {
attendees.push(r);
baselineAttendees.push(r);
}
} else {
const c = curA.get(key);
const b = blA.get(key);
if (c) attendees.push(c);
if (b) baselineAttendees.push(b);
}
}
let reminders: Row[] | null = null;
let baselineReminders: Row[];
if (remote.reminders === null) {
// Bulwark owns reminders: the rows are an app's business, neither written nor uploaded.
baselineReminders = current.reminders.map((r) => r.cells);
} else {
const local = dirty && remindersDiffer(cur, bl);
const choice = decide('reminders', local, remindersDiffer(bs, rm), !remindersDiffer(cur, rm));
reminders = choice === 'remote' ? remote.reminders : current.reminders.map((r) => r.cells);
baselineReminders = choice === 'remote' ? remote.reminders : bl.reminders ?? [];
}
return {
cells,
attendees,
reminders,
baseline: { cells: baseCells, attendees: baselineAttendees, reminders: baselineReminders },
kept,
conflicts,
};
}
/** Exception rows the provider links to the master by ORIGINAL_ID (what the count assert counts). */
export function linkedExceptionCount(local: LocalEvent): number {
return local.exceptions.filter((x) => Number(x.cells[Events.ORIGINAL_ID]) === local.eventId).length;
}
function sameEntries(a: Set<string>, b: Set<string>): boolean {
return a.size === b.size && [...a].every((e) => b.has(e));
}
// ─── A whole event ──────────────────────────────────────
export type WriteMode =
/** A download: dirty rows are merged per unit. */
| 'merge'
/** After an accepted upload, a revert or a zone change: every row becomes the image, DIRTY cleared. */
| 'overwrite';
export interface EventWriteResult {
/** One group, or a chain for an event too big for one (see `eventWrite`). */
ops: OpGroup;
conflicts: number;
stillDirty: boolean;
}
/** Whether an exception row stands for "this instance was deleted" (a DELETED row or a new cancellation). */
export function isRemovedInstance(row: LocalEventRow): boolean {
if (row.deleted) return true;
if (Number(row.cells[Events.STATUS]) !== EventStatus.CANCELED) return false;
// A cancellation the row already had when device sync wrote it is the server's own status.
return !row.baseline || Number(row.baseline.cells[Events.STATUS]) !== EventStatus.CANCELED;
}
export interface EventWriteOptions {
mode: WriteMode;
ref: string;
ctx: CalendarContext;
/** The old server state the rows were written from; null: adopt (the rows' changes are measured against `event`). */
base: CalendarEventWire | null;
/** SYNC_DATA3 to write; undefined leaves it. */
pending?: null;
/** SYNC_DATA5 to write; undefined leaves it. */
clearPoison?: boolean;
}
/**
* The ops that bring a local event (master and exceptions) to `event`,
* merged per `options.mode`. `image` is `event`'s image in its calendar.
*/
export function writeEvent(
event: CalendarEventWire,
image: EventImage,
target: CalendarTarget,
local: LocalEvent,
options: EventWriteOptions,
): EventWriteResult {
const { ctx } = options;
const overwrite = options.mode === 'overwrite';
const masterRef = objectRef(ctx.jmapAccountId, event.id);
const uid = typeof event.uid === 'string' && event.uid ? event.uid : null;
const baseEvent = options.base ?? event;
const baseTarget = options.base ? { calendarRowId: local.calendarRowId, calendarId: ctx.calendarIdOfRow(local.calendarRowId)?.calendarId ?? target.calendarId } : target;
const baseImage = options.base ? eventImage(baseEvent, baseTarget.calendarRowId, baseTarget.calendarId, ctx) ?? image : image;
const lossy = isLossyEditor(local.mutators);
// Asserts first: the rows as read. Attendees and reminders only for dirty rows (see `assertRow`): a big
// meeting's occurrences would otherwise assert every attendee and outgrow one provider transaction.
const opsOf = (build: (group: GroupBuilder) => void) => {
const group = new GroupBuilder(options.ref);
build(group);
return group.build().ops;
};
const masterAsserts = opsOf((g) => assertRow(g, local, false, local.dirty || local.deleted));
const rowAsserts = new Map(local.exceptions.map((x) => [x.eventId, opsOf((g) => assertRow(g, x, true, x.dirty || x.deleted))]));
const countAssert = opsOf((g) => assertExceptionCount(g, local.eventId, linkedExceptionCount(local)));
let conflicts = 0;
let stillDirty = false;
const masterMerge = mergeRow({
isException: false,
current: local,
baseline: options.base && local.baseline ? local.baseline : baselineOfImage(options.base ? baseImage.master : image.master, false),
base: baseImage.master,
remote: image.master,
dirty: !overwrite && local.dirty,
// A CONTENT_EXCEPTION_URI split capped this rule without DIRTY.
forced: !overwrite && local.split === 'source' ? new Set(['rule']) : undefined,
lossy,
self: ctx,
});
conflicts += masterMerge.conflicts;
if (masterMerge.kept.length) stillDirty = true;
const masterState: RowState = {
isException: false,
cells: masterMerge.cells,
attendees: masterMerge.attendees,
reminders: masterMerge.reminders,
baseline: masterMerge.baseline,
syncId: masterRef,
uid,
shadow: event,
...(options.pending === null ? { pending: null } : {}),
...(options.clearPoison ? { poison: null } : {}),
clearDirty: overwrite || (local.dirty && !masterMerge.kept.length),
hasParticipants: image.master.hasParticipants,
};
const deletes: LocalException[] = [];
const updates: Array<{ row: LocalEventRow; state: RowState }> = [];
const inserts: RowState[] = [];
const byKey = new Map<string, LocalException[]>();
for (const x of local.exceptions) {
if (!x.recurrenceId) continue;
const list = byKey.get(x.recurrenceId) ?? [];
list.push(x);
byKey.set(x.recurrenceId, list);
}
const keys = new Set<string>([...byKey.keys(), ...image.exceptions.keys(), ...baseImage.exceptions.keys()]);
const finalCalendarRowId = Number(masterMerge.cells[Events.CALENDAR_ID]);
const exceptionState = (key: string, merged: { cells: Row; attendees: Row[]; reminders: Row[] | null; baseline: EventBaseline }, dirtyLeft: boolean, row: LocalEventRow | null, ours: boolean): RowState => ({
isException: true,
cells: { ...merged.cells, [Events.CALENDAR_ID]: finalCalendarRowId },
attendees: merged.attendees,
reminders: merged.reminders,
baseline: { ...merged.baseline, cells: { ...merged.baseline.cells, [Events.CALENDAR_ID]: finalCalendarRowId } },
syncId: ours ? exceptionRef(masterRef, key) : row?.syncId ?? null,
uid,
recurrenceKey: ours ? key : ((row?.cells[Events.SYNC_DATA2] as string | null | undefined) ?? null),
originalSyncId: masterRef,
clearDirty: !!row?.dirty && !dirtyLeft,
hasParticipants: merged.attendees.length > 0,
});
for (const key of [...keys].sort()) {
const rows = byKey.get(key) ?? [];
const [row, ...duplicates] = rows;
// Two rows for one instance (an app's own duplicate): the extra ones go when clean.
for (const extra of duplicates) {
if (overwrite || !(extra.dirty || extra.deleted)) deletes.push(extra);
else stillDirty = true;
}
const r = image.exceptions.get(key);
const b = baseImage.exceptions.get(key);
if (!row) {
if (r) inserts.push(imageState(r, finalCalendarRowId, masterRef, uid));
continue;
}
if (overwrite && row.deleted) {
// Its exclusion was uploaded (or is being reverted): a soft-deleted row is purged either way.
deletes.push(row);
if (r) inserts.push(imageState(r, finalCalendarRowId, masterRef, uid));
continue;
}
const rowDirty = !overwrite && (row.dirty || row.deleted);
if (!rowDirty) {
if (r) updates.push({ row, state: exceptionState(key, { ...r, baseline: baselineOfImage(r, true) }, false, row, true) });
else deletes.push(row);
continue;
}
if (isRemovedInstance(row)) {
// The device deleted this instance: that wins over a server edit and uploads as an exclusion.
stillDirty = true;
continue;
}
if ((b && !r) || image.excluded.has(key)) {
// The server removed or excluded the instance's override: the server delete wins.
deletes.push(row);
conflicts += 1;
continue;
}
const remoteSide = r ?? plainInstanceImage(event, key, finalCalendarRowId, target.calendarId, ctx);
const baseSide = b ?? plainInstanceImage(baseEvent, key, baseTarget.calendarRowId, baseTarget.calendarId, ctx) ?? remoteSide;
if (!remoteSide || !baseSide) {
stillDirty = true;
continue;
}
const merged = mergeRow({
isException: true,
current: row,
baseline: row.baseline ?? baselineOfImage(baseSide, true),
base: baseSide,
remote: remoteSide,
dirty: true,
lossy,
self: ctx,
});
conflicts += merged.conflicts;
// A local exception the server has no override for keeps its local identity until it is uploaded.
const left = merged.kept.length > 0 || !r;
if (left) stillDirty = true;
updates.push({ row, state: exceptionState(key, merged, left, row, !!r) });
}
const ops = eventWrite(options.ref, {
master: masterAsserts,
rows: rowAsserts,
count: countAssert,
deletes: deletes.map((row) => ({ row: row.eventId, asserts: [], writes: opsOf((g) => deleteRow(g, row.eventId)) })),
masterWrite: opsOf((g) => updateRow(g, local, masterState)),
updates: updates.map(({ row, state }) => ({ row: row.eventId, asserts: [], writes: opsOf((g) => updateRow(g, row, state)) })),
inserts: inserts.map((state) => ({ asserts: [], writes: opsOf((g) => insertRow(g, state, { id: local.eventId })) })),
});
return { ops, conflicts, stillDirty: !overwrite && (stillDirty || local.deleted) };
}
/** The row an exception image becomes, written clean. */
export function imageState(x: ExceptionImage, calendarRowId: number, masterRef: string, uid: string | null): RowState {
const cells = { ...x.cells, [Events.CALENDAR_ID]: calendarRowId };
const baseline = baselineOfImage({ ...x, cells }, true);
return {
isException: true,
cells,
attendees: x.attendees,
reminders: x.reminders,
baseline,
syncId: exceptionRef(masterRef, x.key),
uid,
recurrenceKey: x.key,
originalSyncId: masterRef,
hasParticipants: x.hasParticipants,
};
}
function masterInsertState(event: CalendarEventWire, image: EventImage, masterRef: string, uid: string | null): RowState {
return {
isException: false,
cells: image.master.cells,
attendees: image.master.attendees,
reminders: image.master.reminders,
baseline: baselineOfImage(image.master, false),
syncId: masterRef,
uid,
shadow: event,
hasParticipants: image.master.hasParticipants,
};
}
/** A new event's rows: the master and one row per exception, attendees and reminders included. */
export function insertEvent(event: CalendarEventWire, image: EventImage, ctx: CalendarContext, ref: string): GroupBuilder {
const group = new GroupBuilder(ref);
const masterRef = objectRef(ctx.jmapAccountId, event.id);
const uid = typeof event.uid === 'string' && event.uid ? event.uid : null;
const master = insertRow(group, masterInsertState(event, image, masterRef, uid));
for (const key of [...image.exceptions.keys()].sort()) {
const x = image.exceptions.get(key)!;
insertRow(group, imageState(x, Number(image.master.cells[Events.CALENDAR_ID]), masterRef, uid), { ref: master });
}
return group;
}
/** A new event's rows as one group, or as a chain for an event too big for one (see `eventInsert`). */
export function insertEventOps(event: CalendarEventWire, image: EventImage, ctx: CalendarContext, ref: string): OpGroup {
const masterRef = objectRef(ctx.jmapAccountId, event.id);
const uid = typeof event.uid === 'string' && event.uid ? event.uid : null;
const calendarRowId = Number(image.master.cells[Events.CALENDAR_ID]);
const opsOf = (build: (group: GroupBuilder) => void) => {
const group = new GroupBuilder(ref);
build(group);
return group.build().ops;
};
return eventInsert(ref, insertEvent(event, image, ctx, ref).build(), () => ({
masterRef,
master: opsOf((g) => insertRow(g, { ...masterInsertState(event, image, masterRef, uid), shadow: undefined })),
exceptions: [...image.exceptions.keys()].sort().map((key) => ({
asserts: [],
writes: opsOf((g) => insertRow(g, imageState(image.exceptions.get(key)!, calendarRowId, masterRef, uid))),
})),
shadow: event,
}));
}
+226
View File
@@ -0,0 +1,226 @@
/**
* Deletions and inserts that are one edit (docs/device-sync.md, "Uploads",
* "Pairs"). Etar moves an event to another calendar, and turns a series into
* a single event, by deleting the row and inserting a copy. A deleted row
* and a new row of the same run whose projections match apart from the
* calendar (or the rule) upload as a patch of the existing object, so its
* uid, participants and history stay; the new row then takes the old one's
* identity. Across JMAP accounts nothing pairs: that move is a create in the
* target account and a destroy in the source.
*/
import { Attendees, Events } from '../android-columns';
import type { CalendarContext, EventBaseline, LocalEvent, LocalEventRow, OpGroup } from '../planner';
import type { Row } from '../types';
import type { CalendarEventWire } from '../wire';
import { exceptionRef, parseObjectRef } from '../common/ids';
import { clone } from '../common/json';
import { applyPatch, type PatchObject } from '../common/patch';
import { isSelfAddress, type SelfContext } from './attendees';
import { EXCEPTION_CELLS, MASTER_CELLS, encodeBaseline, makeBaseline } from './columns';
import { durationTextSeconds } from './duration';
import { isExcluded, overridesOf } from './exceptions';
import { linkedExceptionCount } from './merge';
import { GroupBuilder, assertExceptionCount, assertRow, deleteRow, updateRow } from './rows';
import { rruleParts } from './rrule';
import { sendsSchedulingMessages } from './scheduling';
import { MASTER_COLUMN_UNITS, deviceChangedUnit, exdateEntries, remindersDiffer, sideOfBaseline, sideOfRow } from './units';
import { put } from './upload';
import { isUnsetStatus } from './values';
/** What must be equal for two rows to be one event (the calendar and the rule aside). */
const PAIR_COLUMNS = [
Events.TITLE,
Events.DESCRIPTION,
Events.EVENT_LOCATION,
Events.STATUS,
Events.AVAILABILITY,
Events.ACCESS_LEVEL,
Events.EVENT_COLOR,
Events.DTSTART,
Events.ALL_DAY,
];
const num = (v: unknown) => (v === null || v === undefined || v === '' ? null : Number(v));
function lengthMs(cells: Row): number | null {
const dtstart = num(cells[Events.DTSTART]);
const dtend = num(cells[Events.DTEND]);
if (dtstart !== null && dtend !== null) return dtend - dtstart;
const seconds = durationTextSeconds(cells[Events.DURATION]);
return seconds === null ? null : seconds * 1000;
}
/**
* The attendees' addresses, the user's own left out: Etar's copy of a meeting
* has no row for the user when the user was listed (its editor keeps the
* owner's row out of its attendee list), and one as the organizer when not.
*/
const attendeeSet = (row: LocalEventRow, self: SelfContext) =>
row.attendees
.map((a) => String(a.cells[Attendees.ATTENDEE_EMAIL] ?? '').toLowerCase())
.filter((email) => !isSelfAddress(email, self))
.sort()
.join(',');
export interface PairKind {
/** The same event in another calendar. */
move: boolean;
/** A series turned into a single event. */
unrecur: boolean;
}
/** How a deleted and a new row are one edit, or null. */
export function pairKind(deleted: LocalEvent, fresh: LocalEvent, self: SelfContext): PairKind | null {
for (const c of PAIR_COLUMNS) {
// A copy inserted without STATUS (Google Calendar) says nothing about it.
if (c === Events.STATUS && (isUnsetStatus(deleted.cells[c]) || isUnsetStatus(fresh.cells[c]))) continue;
if (String(deleted.cells[c] ?? '') !== String(fresh.cells[c] ?? '')) return null;
}
const allDay = Number(fresh.cells[Events.ALL_DAY] ?? 0) === 1;
if (!allDay && String(deleted.cells[Events.EVENT_TIMEZONE] ?? '') !== String(fresh.cells[Events.EVENT_TIMEZONE] ?? '')) return null;
if (lengthMs(deleted.cells) !== lengthMs(fresh.cells)) return null;
if (attendeeSet(deleted, self) !== attendeeSet(fresh, self)) return null;
const move = deleted.calendarRowId !== fresh.calendarRowId;
const deletedRule = rruleParts(deleted.cells[Events.RRULE]);
const freshRule = rruleParts(fresh.cells[Events.RRULE]);
const unrecur = !!deletedRule && !freshRule;
if (!unrecur && String(deleted.cells[Events.RRULE] ?? '') !== String(fresh.cells[Events.RRULE] ?? '')) return null;
return move || unrecur ? { move, unrecur } : null;
}
/** The patch a pair uploads: the calendar membership swapped, the rule and its overrides removed. */
export function pairPatch(
deleted: LocalEvent,
fresh: LocalEvent,
kind: PairKind,
ctx: CalendarContext,
): { patch: PatchObject; sendSchedulingMessages: boolean } | null {
const shadow = deleted.shadow;
if (!parseObjectRef(deleted.syncId) || !shadow) return null;
const patch: PatchObject = {};
if (kind.move) {
const from = ctx.calendarIdOfRow(deleted.calendarRowId);
const to = ctx.calendarIdOfRow(fresh.calendarRowId);
if (!from || !to || from.jmapAccountId !== ctx.jmapAccountId || to.jmapAccountId !== ctx.jmapAccountId) return null;
put(patch, ['calendarIds', from.calendarId], null);
put(patch, ['calendarIds', to.calendarId], true);
}
if (kind.unrecur) {
put(patch, ['recurrenceRule'], null);
if (Object.keys(overridesOf(shadow)).length) put(patch, ['recurrenceOverrides'], null);
}
// A move alone tells nobody; a series ending its recurrence does.
const scheduling = kind.unrecur && sendsSchedulingMessages(shadow, { kind: 'update', notifying: true, rsvp: false, addsAttendees: false }, ctx);
return { patch, sendSchedulingMessages: scheduling };
}
const children = (row: LocalEventRow) => ({ attendees: row.attendees.map((a) => a.cells), reminders: row.reminders.map((r) => r.cells) });
/**
* The new row's baseline: the old row's (what the server held, as last
* written) with what the patch changed, the calendar and, for a series turned
* single, its rule and its timing as a single event. So an edit saved with the
* move, or made before it, still differs from the baseline and uploads next.
* Attendee rows are the app's re-insert (Etar writes every attendee it copies
* as required, without a status), so their details are no edits: they are
* taken as they are, like the reminders of a row that copied none.
*/
function pairedBaseline(deleted: LocalEvent, fresh: LocalEvent, kind: PairKind, ctx: CalendarContext): EventBaseline {
const own = makeBaseline(fresh.cells, MASTER_CELLS, children(fresh).attendees, children(fresh).reminders);
if (!deleted.baseline) return own;
const next = clone(deleted.baseline);
next.cells[Events.CALENDAR_ID] = own.cells[Events.CALENDAR_ID];
next.attendees = own.attendees;
if (ctx.reminderOwner !== 'device' || !fresh.reminders.length) next.reminders = own.reminders;
if (kind.unrecur) {
const start = num(deleted.baseline.cells[Events.DTSTART]);
const length = lengthMs(deleted.baseline.cells);
next.cells[Events.DTEND] = start !== null && length !== null ? start + length : own.cells[Events.DTEND];
next.cells[Events.DURATION] = null;
next.cells[Events.RRULE] = own.cells[Events.RRULE];
next.cells[Events.EXDATE] = own.cells[Events.EXDATE];
}
return next;
}
/** Whether a master holds a change against `baseline` that an upload would look at. */
function changedSince(row: LocalEventRow, baseline: EventBaseline, ctx: CalendarContext): boolean {
const cur = sideOfRow(row);
const bl = sideOfBaseline(baseline);
if (MASTER_COLUMN_UNITS.some((unit) => deviceChangedUnit(unit, cur, bl))) return true;
const [now, before] = [exdateEntries(cur), exdateEntries(bl)];
if (now.size !== before.size || [...now].some((e) => !before.has(e))) return true;
return ctx.reminderOwner === 'device' && remindersDiffer(cur, bl);
}
/**
* Applied once the pair's patch is accepted: the new row takes the identity
* and the old row goes. The old row's exception rows move to the new row with
* what they wait to upload (Etar copies only the exceptions it never synced;
* an app's copy of one the old row has goes). A copied exception takes the
* identity of its override. The shadow is the old one with the patch applied
* until the server's comes back. A row that still differs from its baseline
* (see `pairedBaseline`) stays dirty and uploads next.
*/
export function pairOps(deleted: LocalEvent, fresh: LocalEvent, kind: PairKind, patch: PatchObject, ctx: CalendarContext): OpGroup {
const syncId = deleted.syncId!;
const shadow: CalendarEventWire = applyPatch(deleted.shadow!, patch) ?? deleted.shadow!;
const group = new GroupBuilder(syncId);
assertRow(group, deleted, false, false);
for (const x of deleted.exceptions) assertRow(group, x, true, x.dirty || x.deleted);
assertExceptionCount(group, deleted.eventId, linkedExceptionCount(deleted));
assertRow(group, fresh, false, true);
for (const x of fresh.exceptions) assertRow(group, x, true, true);
assertExceptionCount(group, fresh.eventId, linkedExceptionCount(fresh));
const kept = new Set<string>();
for (const x of deleted.exceptions) {
// A series turned single has no occurrences left.
if (kind.unrecur || !x.recurrenceId) {
deleteRow(group, x.eventId);
continue;
}
kept.add(x.recurrenceId);
const values: Row = { [Events.ORIGINAL_ID]: fresh.eventId, [Events.CALENDAR_ID]: fresh.calendarRowId };
if (x.baseline) values[Events.SYNC_DATA4] = encodeBaseline({ ...x.baseline, cells: { ...x.baseline.cells, [Events.CALENDAR_ID]: fresh.calendarRowId } });
group.write({ op: 'update', table: 'events', id: x.eventId, values, expectCount: 1 });
}
deleteRow(group, deleted.eventId);
const cells: Row = {};
for (const c of MASTER_CELLS) cells[c] = fresh.cells[c] ?? null;
const baseline = pairedBaseline(deleted, fresh, kind, ctx);
updateRow(
group,
{ ...fresh, shadow: null },
{
isException: false,
cells,
...children(fresh),
baseline,
syncId,
uid: typeof shadow.uid === 'string' ? shadow.uid : null,
shadow,
pending: null,
clearDirty: !changedSince(fresh, baseline, ctx),
hasParticipants: fresh.attendees.length > 0,
},
);
const overrides = overridesOf(shadow);
for (const x of fresh.exceptions) {
const key = x.recurrenceId;
if (key && kept.has(key)) {
deleteRow(group, x.eventId);
continue;
}
const values: Row = { [Events.ORIGINAL_SYNC_ID]: syncId };
if (key && overrides[key] && !isExcluded(overrides[key])) {
Object.assign(values, {
[Events._SYNC_ID]: exceptionRef(syncId, key),
[Events.SYNC_DATA2]: key,
[Events.SYNC_DATA4]: encodeBaseline(makeBaseline(x.cells, EXCEPTION_CELLS, children(x).attendees, children(x).reminders)),
[Events.DIRTY]: 0,
});
}
group.write({ op: 'update', table: 'events', id: x.eventId, values, expectCount: 1 });
}
return group.build();
}
+486
View File
@@ -0,0 +1,486 @@
/**
* The calendar planner (docs/device-sync.md, "Calendar mapping"): JSCalendar
* events and calendars ↔ CalendarContract rows, pure. The engine owns all
* I/O; this module turns rows and server objects into op groups and upload
* actions, following the contract in ../planner.ts.
*
* Tasks never reach CalendarContract: the engine filters them, and every
* entry point here refuses one as well.
*/
import { Events } from '../android-columns';
import type {
AcceptedPlan,
CalendarContext,
CalendarPlanner,
DownloadPlan,
LocalEvent,
LocalEventRow,
OpGroup,
PoisonMarker,
UploadAction,
UploadPlan,
} from '../planner';
import type { CalendarEventWire } from '../wire';
import { objectRef, parseObjectRef, pendingUidOf } from '../common/ids';
import { deepEqual, jsonHash } from '../common/json';
import { isTaskLikeObject } from '../../lib/calendar-component-detection';
import { keepDirtyGroup, type UploadedUnits } from './accepted';
import { ATTENDEE_COLUMNS, CALENDAR_COLUMNS, EVENT_COLUMNS, EXCEPTION_CELLS, MASTER_CELLS, REMINDER_COLUMNS, isTruncatedBaseline, makeBaseline } from './columns';
import { planCalendars } from './calendars';
import { claimOps, claimUid, claimedUid, computeCreate, createTarget, deleteActions, movedAway } from './create';
import { decodeCalendar, decodeEvents, isExceptionRow, isNewMaster } from './decode';
import { isExcluded, overridesOf } from './exceptions';
import { eventImage } from './image';
import { insertEvent, insertEventOps, isRemovedInstance, linkedExceptionCount, pickCalendar, writeEvent, type CalendarTarget } from './merge';
import { pairKind, pairOps, pairPatch } from './pairs';
import { GroupBuilder, assertExceptionCount, assertRow, deleteRow, writesOf } from './rows';
import { eventZone } from './timing';
import { columnUnitDiffers, remindersDiffer, sideOfBaseline, sideOfRow } from './units';
import { SKIP, SkipUpload, computeUpdate, type UploadComputation } from './upload';
type Plan = UploadPlan<CalendarEventWire>;
function refuseTask(event: CalendarEventWire | null | undefined): void {
if (event && isTaskLikeObject(event)) {
throw new Error('calendar planner: tasks are not synced to the device (filter them before planning)');
}
}
function refOf(local: LocalEventRow | null, event?: CalendarEventWire, jmapAccountId?: string): string {
if (local?.syncId) return local.syncId;
if (event && jmapAccountId) return objectRef(jmapAccountId, event.id);
return local ? `row:${local.eventId}` : 'new';
}
/** The calendar a row is in, when it is one of ours. */
function rowCalendar(local: LocalEventRow, ctx: CalendarContext): CalendarTarget | null {
const cal = ctx.calendarIdOfRow(local.calendarRowId);
return cal ? { calendarRowId: local.calendarRowId, calendarId: cal.calendarId } : null;
}
// ─── Downloads ──────────────────────────────────────────
function planDownload(event: CalendarEventWire, local: LocalEvent | null, ctx: CalendarContext): DownloadPlan {
refuseTask(event);
refuseTask(local?.shadow);
const ref = refOf(local, event, ctx.jmapAccountId);
const none = (stillDirty: boolean): DownloadPlan => ({ ops: { ref, ops: [] }, conflicts: 0, stillDirty, effect: 'none', writes: 0 });
// A different object under a reused id: its rows are replaced, never patched.
const uidChanged = !!local?.shadow && typeof local.shadow.uid === 'string' && typeof event.uid === 'string' && local.shadow.uid !== event.uid;
if (local?.deleted && local.split !== 'clone' && !uidChanged) {
// Deleted on the device: that wins over a server edit, so only the shadow follows, for the deletion.
// Also when no synced calendar holds the event any more: the deletion is then moot (see planUpload).
if (!local.shadow || deepEqual(local.shadow, event)) return none(true);
const group = new GroupBuilder(ref);
group.assert({ op: 'assert', table: 'events', id: local.eventId, values: { [Events.DELETED]: 1, [Events._SYNC_ID]: local.syncId }, expectCount: 1 });
group.write({ op: 'update', table: 'events', id: local.eventId, values: { [Events.SYNC_DATA1]: JSON.stringify(event) }, expectCount: 1 });
const ops = group.build();
return { ops, conflicts: 0, stillDirty: true, effect: 'update', writes: writesOf(ops) };
}
// An event another client moved out of every synced calendar still merges into its rows where they are
// (they wait for an upload; the accepted write then removes them).
const target = pickCalendar(event, local, ctx) ?? (local ? rowCalendar(local, ctx) : null);
if (!target) throw new Error(`calendar planner: ${ref} is in no synced calendar (the engine removes such rows)`);
const image = eventImage(event, target.calendarRowId, target.calendarId, ctx);
// Stalwart drops a start it can't resolve (a DST gap or overlap): nothing to show.
if (!image) return none(!!local && (local.dirty || local.deleted));
if (!local) {
const ops = insertEventOps(event, image, ctx, ref);
return { ops, conflicts: 0, stillDirty: false, effect: 'insert', writes: writesOf(ops) };
}
// A split clone only looks like this object: it is a new event and uploads as one. The source row is the object's.
if (local.split === 'clone') return none(true);
if (uidChanged) {
const group = new GroupBuilder(ref);
for (const x of local.exceptions) deleteRow(group, x.eventId);
deleteRow(group, local.eventId);
const inserted = insertEvent(event, image, ctx, ref).build().ops;
const offset = group.writeCount;
for (const op of inserted) {
if (op.op === 'insert' && op.refs) {
const refs = Object.fromEntries(Object.entries(op.refs).map(([k, v]) => [k, v + offset]));
group.write({ ...op, refs });
} else group.write(op);
}
const ops = group.build();
return { ops, conflicts: 0, stillDirty: false, effect: 'update', writes: writesOf(ops) };
}
const itemDirty = local.dirty || local.split === 'source' || local.exceptions.some((x) => x.dirty || x.deleted);
const result = writeEvent(event, image, target, local, {
mode: 'merge',
ref,
ctx,
// No shadow: our own create whose identity was never written. Adopt it; its rows count as edits of `event`.
base: local.shadow,
...(local.shadow ? {} : { pending: null }),
clearPoison: !itemDirty,
});
const ops = result.ops;
const writes = writesOf(ops);
return {
ops: writes ? ops : { ref, ops: [] },
conflicts: result.conflicts,
stillDirty: result.stillDirty,
effect: writes ? 'update' : 'none',
writes,
};
}
function planLocalDelete(local: LocalEvent): OpGroup {
const group = new GroupBuilder(refOf(local));
assertExceptionCount(group, local.eventId, linkedExceptionCount(local));
for (const x of local.exceptions) deleteRow(group, x.eventId);
deleteRow(group, local.eventId);
return group.build();
}
/**
* Rewrites the baselines of clean rows that drifted from them (the provider
* normalised a write, or a crash hit between a chunk and its read-back),
* behind DIRTY=0. A recurring master's timing and rule are never healed: a
* CONTENT_EXCEPTION_URI split changes them without DIRTY.
*/
function planBaselineHeal(local: LocalEvent): OpGroup | null {
if (local.split || local.deleted || isExceptionRow(local.cells)) return null;
const group = new GroupBuilder(refOf(local));
const heals: Array<{ row: LocalEventRow; isException: boolean; baseline: ReturnType<typeof makeBaseline> }> = [];
const consider = (row: LocalEventRow, isException: boolean) => {
if (row.dirty || row.deleted) return;
const columns = isException ? EXCEPTION_CELLS : MASTER_CELLS;
const truncated = isTruncatedBaseline(row.baseline?.cells[Events.DESCRIPTION]) && !columnUnitDiffers('description', sideOfRow(row), sideOfBaseline(row.baseline!));
const next = makeBaseline(row.cells, columns, row.attendees.map((a) => a.cells), row.reminders.map((r) => r.cells), truncated);
if (truncated) next.cells[Events.DESCRIPTION] = row.baseline!.cells[Events.DESCRIPTION];
const recurring = !isException && (!!row.cells[Events.RRULE] || !!row.baseline?.cells[Events.RRULE]);
if (recurring && row.baseline) {
for (const c of [Events.DTSTART, Events.DTEND, Events.DURATION, Events.EVENT_TIMEZONE, Events.ALL_DAY, Events.RRULE]) {
next.cells[c] = row.baseline.cells[c] ?? null;
}
}
if (row.baseline && deepEqual(next, row.baseline)) return;
heals.push({ row, isException, baseline: next });
};
consider(local, false);
for (const x of local.exceptions) consider(x, true);
if (!heals.length) return null;
for (const h of heals) assertRow(group, h.row, h.isException, false);
for (const h of heals) {
group.write({ op: 'update', table: 'events', id: h.row.eventId, values: { [Events.SYNC_DATA4]: JSON.stringify({ v: 1, ...h.baseline }) }, expectCount: 1 });
}
return group.build();
}
// ─── Uploads ────────────────────────────────────────────
/**
* The fingerprint a poison marker records for an event: what it would upload
* from (its flags, shadow, rows, attendees, reminders and exceptions), so the
* back-off holds while nothing changes and lifts when the user edits it. The
* engine writes the markers and checks them before planning
* (engine/calendar-sync.ts); this is the same content, so an item handed in
* anyway is still skipped.
*/
export function poisonFingerprint(e: LocalEvent): string {
return jsonHash({
deleted: e.deleted,
shadow: e.shadow,
cells: e.cells,
attendees: e.attendees.map((a) => JSON.stringify(a.cells)).sort(),
reminders: e.reminders.map((r) => JSON.stringify(r.cells)).sort(),
exceptions: e.exceptions
.map((x) => JSON.stringify([x.deleted, x.cells, x.attendees.map((a) => a.cells), x.reminders.map((r) => r.cells)]))
.sort(),
});
}
/** Writes a poison marker (SYNC_DATA5) on the master. */
export function poisonOps(local: LocalEvent, marker: PoisonMarker): OpGroup {
return {
ref: refOf(local),
ops: [{ op: 'update', table: 'events', id: local.eventId, values: { [Events.SYNC_DATA5]: JSON.stringify(marker) }, expectCount: 1 }],
};
}
function activePoison(local: LocalEvent, ctx: CalendarContext): PoisonMarker | null {
const p = local.poison;
if (!p || ctx.now >= p.until) return null;
return p.fp === poisonFingerprint(local) ? p : null;
}
/** Deletes a master and its exceptions (a purge, a revert that refetches). */
function purgeGroup(local: LocalEvent): OpGroup {
return planLocalDelete(local);
}
/** Rows rewritten from the shadow, DIRTY cleared: a read-only event's device edits are undone. */
function revertGroup(local: LocalEvent, ctx: CalendarContext): OpGroup | null {
const shadow = local.shadow;
if (!shadow) return null;
const target = pickCalendar(shadow, local, ctx) ?? rowCalendar(local, ctx);
if (!target) return null;
const image = eventImage(shadow, target.calendarRowId, target.calendarId, ctx);
if (!image) return null;
return writeEvent(shadow, image, target, local, { mode: 'overwrite', ref: refOf(local), ctx, base: shadow }).ops;
}
/** Clears DIRTY where nothing uploadable changed, taking the rows as they are as the new baselines. */
function cleanGroup(local: LocalEvent, computation: UploadComputation | null): OpGroup {
const group = new GroupBuilder(refOf(local));
// Attendees and reminders of the dirty rows only (see `assertRow`), so a big meeting fits one transaction.
assertRow(group, local, false, local.dirty || local.deleted);
for (const x of local.exceptions) assertRow(group, x, true, x.dirty || x.deleted);
const settle = (row: LocalEventRow, isException: boolean) => {
if (!row.dirty) return;
const truncated = isTruncatedBaseline(row.baseline?.cells[Events.DESCRIPTION]);
const baseline = makeBaseline(row.cells, isException ? EXCEPTION_CELLS : MASTER_CELLS, row.attendees.map((a) => a.cells), row.reminders.map((r) => r.cells), truncated);
group.write({ op: 'update', table: 'events', id: row.eventId, values: { [Events.DIRTY]: 0, [Events.SYNC_DATA4]: JSON.stringify({ v: 1, ...baseline }) }, expectCount: 1 });
};
settle(local, false);
const overrides = overridesOf(local.shadow);
for (const x of local.exceptions) {
const excludedAlready = !!x.recurrenceId && isExcluded(overrides[x.recurrenceId]);
if ((computation?.removedExceptions.has(x.eventId) || isRemovedInstance(x)) && excludedAlready) {
deleteRow(group, x.eventId);
continue;
}
settle(x, true);
}
return group.build();
}
function planUpload(local: LocalEvent, ctx: CalendarContext): Plan {
refuseTask(local.shadow);
// An exception row whose master is gone never uploads as an event of its own.
if (isExceptionRow(local.cells)) return { kind: 'purge', ops: purgeGroup(local) };
const calendar = ctx.calendarIdOfRow(local.calendarRowId);
if (!calendar) return { kind: 'skip', reason: SKIP.notOurCalendar };
const isNew = local.split === 'clone' || (!local.shadow && isNewMaster(local));
if (local.deleted) {
if (isNew) {
// Never created (or its outcome unknown): destroy whatever carries the pending uid, else just purge.
const uid = local.split === 'clone' ? null : local.pending?.uid ?? pendingUidOf(local.syncId);
if (!uid) return { kind: 'purge', ops: purgeGroup(local) };
return { kind: 'upload', actions: [{ kind: 'destroy', id: null, uid }] };
}
if (!parseObjectRef(local.syncId)) return { kind: 'purge', ops: purgeGroup(local) };
// Moved away on the server before the deletion reached it: nothing goes up, only the rows go.
if (movedAway(local, calendar, ctx)) return { kind: 'purge', ops: purgeGroup(local) };
if (ctx.isReadOnly(calendar.calendarId)) return { kind: 'revert', ops: purgeGroup(local), refetch: true, reason: SKIP.readOnly };
return { kind: 'upload', actions: deleteActions(local, ctx) };
}
if (isNew) {
if (ctx.isReadOnly(calendar.calendarId)) return { kind: 'revert', ops: purgeGroup(local), reason: SKIP.readOnly };
const uid = claimedUid(local);
const target = createTarget(local, ctx);
if (!target) return { kind: 'skip', reason: SKIP.notOurCalendar };
if (!uid) return { kind: 'claim', ops: claimOps(local, claimUid(local, ctx), target.key) };
const poison = activePoison(local, ctx);
if (poison) return { kind: 'skip', reason: poison.type };
try {
const create = computeCreate(local, uid, target.calendarId, ctx);
const action: UploadAction<CalendarEventWire> = { kind: 'create', uid, collectionId: target.calendarId, object: create.object };
if (create.sendSchedulingMessages) action.sendSchedulingMessages = true;
return { kind: 'upload', actions: [action] };
} catch (e) {
if (e instanceof SkipUpload) return { kind: 'skip', reason: e.reason };
throw e;
}
}
const ref = parseObjectRef(local.syncId);
const shadow = local.shadow;
if (!ref || !shadow) return { kind: 'skip', reason: 'noShadow' };
const poison = activePoison(local, ctx);
if (poison) return { kind: 'skip', reason: poison.type };
// An instance Stalwart holds without its series can't be changed on its own.
if (shadow.recurrenceId && !shadow.recurrenceRule) {
const ops = revertGroup(local, ctx);
return ops ? { kind: 'revert', ops, reason: SKIP.instanceOnly } : { kind: 'skip', reason: SKIP.instanceOnly };
}
let computation: UploadComputation;
try {
computation = computeUpdate(local, ctx);
} catch (e) {
if (e instanceof SkipUpload) return { kind: 'skip', reason: e.reason };
throw e;
}
const keys = Object.keys(computation.patch);
const readOnly = ctx.isReadOnly(calendar.calendarId);
const movedToReadOnly = keys.some((k) => {
const m = /^calendarIds\/(.+)$/.exec(k);
return m && computation.patch[k] === true && ctx.isReadOnly(m[1]);
});
if ((readOnly && keys.length) || movedToReadOnly) {
// RSVP, to the series or to occurrences, is the one change a calendar without write rights may take.
const rsvp = computation.rsvpOnly && !movedToReadOnly;
if (!(rsvp && ctx.calendar(calendar.calendarId)?.myRights?.mayRSVP)) {
const ops = revertGroup(local, ctx);
const reason = rsvp ? SKIP.rsvpRefused : SKIP.readOnly;
return ops ? { kind: 'revert', ops, reason } : { kind: 'skip', reason };
}
}
if (!keys.length) {
if (computation.dropped.some((u) => u === 'timing' || u === 'rule')) return { kind: 'skip', reason: SKIP.ruleNotRepresentable };
// A move into a calendar of another JMAP account is no patch (only new rows are created across accounts):
// the row goes back to its calendar instead of staying there unsynced. With other changes, their accepted
// write puts it back.
if (computation.dropped.includes('calendar')) {
const ops = revertGroup(local, ctx);
if (ops) return { kind: 'revert', ops, reason: SKIP.crossAccountMove };
}
// Every reminder of an occurrence removed where the server can't store none: back to what it holds, reported.
if (computation.dropped.includes('reminders')) {
const ops = revertGroup(local, ctx);
if (ops) return { kind: 'revert', ops, reason: SKIP.remindersNotRepresentable };
}
return { kind: 'clean', ops: cleanGroup(local, computation) };
}
const action: UploadAction<CalendarEventWire> = { kind: 'update', id: ref.id, patch: computation.patch };
if (computation.sendSchedulingMessages) action.sendSchedulingMessages = true;
return { kind: 'upload', actions: [action] };
}
function planAccepted(local: LocalEvent, server: CalendarEventWire, ctx: CalendarContext): AcceptedPlan {
refuseTask(server);
const target = pickCalendar(server, local, ctx);
if (!target) {
// Moved out of every synced calendar (a membership removal): the rows go.
const ops = purgeGroup(local);
return { ops, keepDirtyOps: ops };
}
const wasNew = local.split === 'clone' || (!local.shadow && isNewMaster(local));
let uploaded: UploadedUnits;
if (wasNew || !local.shadow) {
uploaded = { all: true, master: new Set(), exceptions: new Map(), removed: new Set(local.exceptions.filter(isRemovedInstance).map((x) => x.eventId)) };
} else {
try {
const c = computeUpdate(local, ctx);
uploaded = { all: false, master: c.masterUnits, exceptions: c.exceptionUnits, removed: c.removedExceptions };
} catch {
uploaded = { all: false, master: new Set(), exceptions: new Map(), removed: new Set() };
}
}
const keepDirtyOps = keepDirtyGroup(local, server, uploaded, ctx);
const image = eventImage(server, target.calendarRowId, target.calendarId, ctx);
if (!image) return { ops: keepDirtyOps, keepDirtyOps };
const ops = writeEvent(server, image, target, local, {
mode: 'overwrite',
ref: objectRef(ctx.jmapAccountId, server.id),
ctx,
base: local.shadow ?? server,
pending: null,
clearPoison: true,
}).ops;
return { ops, keepDirtyOps };
}
function planPairs(
deleted: LocalEvent[],
fresh: LocalEvent[],
ctx: CalendarContext,
): Array<{ deleted: LocalEvent; fresh: LocalEvent; actions: UploadAction<CalendarEventWire>[]; ops: OpGroup }> {
const pairs: Array<{ deleted: LocalEvent; fresh: LocalEvent; actions: UploadAction<CalendarEventWire>[]; ops: OpGroup }> = [];
const used = new Set<number>();
const inAccount = (row: LocalEventRow) => ctx.calendarIdOfRow(row.calendarRowId)?.jmapAccountId === ctx.jmapAccountId;
for (const d of deleted) {
const ref = parseObjectRef(d.syncId);
if (!ref || !d.shadow || !d.deleted || d.split || !inAccount(d)) continue;
refuseTask(d.shadow);
for (const f of fresh) {
if (used.has(f.eventId) || f.syncId !== null || f.pending || f.deleted || isExceptionRow(f.cells) || !inAccount(f)) continue;
const kind = pairKind(d, f, ctx);
if (!kind) continue;
const pair = pairPatch(d, f, kind, ctx);
if (!pair) continue;
const action: UploadAction<CalendarEventWire> = { kind: 'update', id: ref.id, patch: pair.patch };
if (pair.sendSchedulingMessages) action.sendSchedulingMessages = true;
pairs.push({ deleted: d, fresh: f, actions: [action], ops: pairOps(d, f, kind, pair.patch, ctx) });
used.add(f.eventId);
break;
}
}
return pairs;
}
// ─── Device zone and reminder owner ─────────────────────
function planZoneChange(local: LocalEvent, previousZone: string, ctx: CalendarContext): OpGroup | null {
const shadow = local.shadow;
if (!shadow || local.split || local.deleted || local.dirty || local.exceptions.some((x) => x.dirty || x.deleted)) return null;
refuseTask(shadow);
if (previousZone === ctx.deviceZone || !eventZone(shadow, ctx.deviceZone).floating) return null;
const target = pickCalendar(shadow, local, ctx);
if (!target) return null;
const image = eventImage(shadow, target.calendarRowId, target.calendarId, ctx);
if (!image) return null;
const ops = writeEvent(shadow, image, target, local, { mode: 'overwrite', ref: refOf(local), ctx, base: shadow }).ops;
return writesOf(ops) ? ops : null;
}
function planReminderOwnerChange(local: LocalEvent, ctx: CalendarContext): OpGroup | null {
const shadow = local.shadow;
if (!shadow || local.deleted || isExceptionRow(local.cells)) return null;
refuseTask(shadow);
const target = pickCalendar(shadow, local, ctx);
if (!target) return null;
const image = eventImage(shadow, target.calendarRowId, target.calendarId, { ...ctx, reminderOwner: 'device' });
if (!image) return null;
const rows: Array<{ row: LocalEventRow; isException: boolean; wanted: LocalEventRow['cells'][] }> = [];
const add = (row: LocalEventRow, isException: boolean, wanted: LocalEventRow['cells'][] | null) => {
if (!wanted) return;
// A reminder edit that has not reached the server yet is not overwritten.
if (row.dirty && row.baseline && remindersDiffer(sideOfRow(row), sideOfBaseline(row.baseline))) return;
rows.push({ row, isException, wanted: ctx.reminderOwner === 'device' ? wanted : [] });
};
add(local, false, image.master.reminders);
for (const x of local.exceptions) {
if (x.deleted || !x.recurrenceId) continue;
const xi = image.exceptions.get(x.recurrenceId);
add(x, true, xi ? xi.reminders : null);
}
const changing = rows.filter(({ row, wanted }) => {
const have = row.reminders.map((r) => r.cells);
return have.length !== wanted.length || remindersDiffer({ cells: {}, attendees: [], reminders: have }, { cells: {}, attendees: [], reminders: wanted });
});
if (!changing.length) return null;
const group = new GroupBuilder(refOf(local));
for (const { row, isException } of changing) assertRow(group, row, isException, true);
for (const { row, isException, wanted } of changing) {
for (const r of row.reminders) group.write({ op: 'delete', table: 'reminders', id: r.id, expectCount: 1 });
for (const w of wanted) group.write({ op: 'insert', table: 'reminders', values: { ...w, event_id: row.eventId } });
const baseline = row.baseline ?? makeBaseline(row.cells, isException ? EXCEPTION_CELLS : MASTER_CELLS, row.attendees.map((a) => a.cells), []);
group.write({
op: 'update',
table: 'events',
id: row.eventId,
values: { [Events.SYNC_DATA4]: JSON.stringify({ v: 1, ...baseline, reminders: wanted }) },
expectCount: 1,
});
}
return group.build();
}
export const calendarPlanner: CalendarPlanner = {
calendarColumns: CALENDAR_COLUMNS,
eventColumns: EVENT_COLUMNS,
attendeeColumns: ATTENDEE_COLUMNS,
reminderColumns: REMINDER_COLUMNS,
decodeCalendar,
decodeEvents,
planCalendars,
planDownload,
planLocalDelete,
planBaselineHeal,
planUpload,
planAccepted,
planPairs,
planZoneChange,
planReminderOwnerChange,
};
+153
View File
@@ -0,0 +1,153 @@
/**
* Alerts ↔ Reminders rows (docs/device-sync.md, "Other properties" and
* "Reminder owner").
*
* A Reminders row is "minutes before the start" plus a method, so only an
* offset alert relative to the start (or with no `relativeTo`) that fires at
* or before it is shown; alerts relative to the end, absolute alerts and
* alerts after the start stay on the server. The app's
* `offsetToMinutesBefore` rejects the valid `+PT15M` (and fractions), so
* offsets are read with this module's own duration parser.
*/
import type { Alert } from '../../api/types';
import { Reminders } from '../android-columns';
import type { CalendarLike } from '../wire';
import type { Row } from '../types';
import { minutesBeforeToOffset } from '../../lib/calendar-alerts';
import { durationSeconds, parseDuration } from './duration';
import { reminderActionFromDevice, reminderMethodToDevice } from './values';
export interface ReminderValue {
minutes: number;
/** Reminders.METHOD, normalised: EMAIL (2) or ALERT (1). */
method: number;
}
type Alerts = Record<string, Alert> | null | undefined;
/**
* The alerts that are in force: with `useDefaultAlerts` the calendar's
* defaults for timed or all-day events (and the event's own `alerts` are
* ignored, as the server and the app's scheduler do), else the event's own.
*/
export function effectiveAlerts(
event: { useDefaultAlerts?: boolean | null; alerts?: Alerts; showWithoutTime?: boolean | null },
calendar: CalendarLike | undefined,
): Record<string, Alert> {
if (event.useDefaultAlerts) {
const defaults = event.showWithoutTime ? calendar?.defaultAlertsWithoutTime : calendar?.defaultAlertsWithTime;
return { ...(defaults ?? {}) };
}
return { ...(event.alerts ?? {}) };
}
/** The reminder an alert shows as, or null when a Reminders row cannot hold it. */
export function alertReminder(alert: Alert | null | undefined): ReminderValue | null {
const trigger = alert?.trigger;
if (!trigger || typeof trigger !== 'object') return null;
const type = trigger['@type'];
if (type !== undefined && type !== 'OffsetTrigger') return null;
if (trigger.offset === undefined || trigger.when !== undefined) return null;
if (trigger.relativeTo !== undefined && trigger.relativeTo !== null && trigger.relativeTo !== 'start') return null;
const d = parseDuration(trigger.offset);
if (!d) return null;
const seconds = durationSeconds(d);
if (seconds > 0) return null;
return { minutes: Math.round(-seconds / 60), method: reminderMethodToDevice(alert.action) };
}
export function isRepresentableAlert(alert: Alert | null | undefined): boolean {
return alertReminder(alert) !== null;
}
export function reminderRow(value: ReminderValue): Row {
return { [Reminders.MINUTES]: value.minutes, [Reminders.METHOD]: value.method };
}
/** Reminders rows for the alerts that have one, in the alerts' order. */
export function reminderRows(alerts: Alerts): Row[] {
return Object.values(alerts ?? {})
.map(alertReminder)
.filter((r): r is ReminderValue => r !== null)
.map(reminderRow);
}
/**
* A device row as a reminder, or null for one that means nothing to the
* server (MINUTES = -1 is "the app's default").
*/
export function rowReminder(row: Row): ReminderValue | null {
const minutes = Number(row[Reminders.MINUTES]);
if (!Number.isInteger(minutes) || minutes < 0) return null;
return { minutes, method: reminderMethodToDevice(reminderActionFromDevice(row[Reminders.METHOD])) };
}
const keyOf = (r: ReminderValue) => `${r.minutes}/${r.method}`;
/** Two reminder lists as multisets of (minutes, method). */
export function sameReminders(a: Row[], b: Row[]): boolean {
const count = (rows: Row[]) => {
const m = new Map<string, number>();
for (const r of rows) {
const v = rowReminder(r);
if (v) m.set(keyOf(v), (m.get(keyOf(v)) ?? 0) + 1);
}
return m;
};
const ca = count(a);
const cb = count(b);
if (ca.size !== cb.size) return false;
for (const [k, n] of ca) if (cb.get(k) !== n) return false;
return true;
}
/** A new alert for a device reminder, shaped like the app's reminder picker writes them. */
export function reminderAlert(value: ReminderValue): Alert {
return {
'@type': 'Alert',
trigger: { '@type': 'OffsetTrigger', offset: minutesBeforeToOffset(value.minutes), relativeTo: 'start' },
action: reminderActionFromDevice(value.method),
};
}
/**
* The alerts after the device's reminders replaced the representable ones:
* existing alerts whose (minutes, method) a reminder still has keep their
* key and value, the others go, unmatched reminders are added under minted
* keys, and alerts a Reminders row can't hold stay as they are.
*/
export function remindersToAlerts(
current: Record<string, Alert>,
reminders: Row[],
mintKey: (taken: Iterable<string>) => string,
): { alerts: Record<string, Alert>; removed: string[]; added: Record<string, Alert> } {
const wanted = reminders.map(rowReminder).filter((r): r is ReminderValue => r !== null);
const pool = new Map<string, number>();
for (const r of wanted) pool.set(keyOf(r), (pool.get(keyOf(r)) ?? 0) + 1);
const alerts: Record<string, Alert> = {};
const removed: string[] = [];
for (const [key, alert] of Object.entries(current)) {
const r = alertReminder(alert);
if (!r) {
alerts[key] = alert;
continue;
}
const left = pool.get(keyOf(r)) ?? 0;
if (left > 0) {
pool.set(keyOf(r), left - 1);
alerts[key] = alert;
} else {
removed.push(key);
}
}
const added: Record<string, Alert> = {};
for (const r of wanted) {
const left = pool.get(keyOf(r)) ?? 0;
if (left <= 0) continue;
pool.set(keyOf(r), left - 1);
const key = mintKey([...Object.keys(current), ...Object.keys(added)]);
added[key] = reminderAlert(r);
alerts[key] = added[key];
}
return { alerts, removed, added };
}
+390
View File
@@ -0,0 +1,390 @@
/**
* Writing event rows: the op group builder and the translation of "this row
* should hold that" into the fewest provider ops (docs/device-sync.md,
* "Merge rules" and "Uploads").
*
* A group is atomic and its asserts come first, so the builder refuses an
* assert after the first write; `refs` are indexes into the group. Rows are
* addressed by id with `expectCount: 1`, exception rows are deleted by id
* only (a sync-adapter delete never cascades to them), and a write that
* would not change a cell is left out, so an echo writes nothing.
*/
import { Attendees, Events, Reminders } from '../android-columns';
import type { EventBaseline, LocalEventRow, OpGroup, PendingCreate, PoisonMarker } from '../planner';
import type { ProviderOp, Row, WriteRow } from '../types';
import type { CalendarEventWire } from '../wire';
import { deepEqual } from '../common/json';
import { MAX_OPS_PER_YIELD, estimateBatchBytes } from '../engine/batch';
import { DEFAULT_TUNING } from '../engine/deps';
import { ATTENDEE_CELLS, MASTER_CELLS, EXCEPTION_CELLS, REMINDER_CELLS, encodeBaseline, sameCell } from './columns';
import { rowReminder } from './reminders';
export class GroupBuilder {
private readonly asserts: ProviderOp[] = [];
private readonly writes: ProviderOp[] = [];
constructor(readonly ref: string) {}
assert(op: Extract<ProviderOp, { op: 'assert' }>): void {
if (this.writes.length) throw new Error('calendar planner: an assert after a write');
this.asserts.push(op);
}
/** Adds a write and returns its index in the group (for `refs`). */
write(op: ProviderOp): number {
this.writes.push(op);
return this.asserts.length + this.writes.length - 1;
}
get writeCount(): number {
return this.writes.length;
}
build(): OpGroup {
return { ref: this.ref, ops: [...this.asserts, ...this.writes] };
}
}
/** Columns asserted for a row: sync state plus its projection. */
const ASSERT_EXTRA = [Events.DIRTY, Events.DELETED, Events._SYNC_ID] as const;
export function projectionColumns(isException: boolean): readonly string[] {
return isException ? EXCEPTION_CELLS : MASTER_CELLS;
}
/**
* Asserts that a row still holds what was read: DIRTY, DELETED, `_SYNC_ID`
* and its mapped cells, and with `children` its attendees and reminders
* (each row's cells plus their number, so an added one fails too). DIRTY
* alone guards a clean row against app edits (every app write to an event,
* its attendees or reminders sets it); a dirty row needs the whole
* projection.
*/
export function assertRow(group: GroupBuilder, row: LocalEventRow, isException: boolean, children: boolean): void {
const values: Row = {};
// As read: a row a sync adapter inserted without DIRTY holds NULL there, not 0.
values[Events.DIRTY] = row.cells[Events.DIRTY] ?? null;
values[Events.DELETED] = row.cells[Events.DELETED] ?? null;
values[Events._SYNC_ID] = row.syncId;
for (const c of projectionColumns(isException)) values[c] = row.cells[c] ?? null;
for (const c of ASSERT_EXTRA) if (!(c in values)) values[c] = row.cells[c] ?? null;
group.assert({ op: 'assert', table: 'events', id: row.eventId, values, expectCount: 1 });
if (!children) return;
for (const a of row.attendees) {
group.assert({ op: 'assert', table: 'attendees', id: a.id, values: pick(a.cells, ATTENDEE_CELLS), expectCount: 1 });
}
group.assert({ op: 'assert', table: 'attendees', where: `${Attendees.EVENT_ID} = ?`, args: [row.eventId], expectCount: row.attendees.length });
for (const r of row.reminders) {
group.assert({ op: 'assert', table: 'reminders', id: r.id, values: pick(r.cells, REMINDER_CELLS), expectCount: 1 });
}
group.assert({ op: 'assert', table: 'reminders', where: `${Reminders.EVENT_ID} = ?`, args: [row.eventId], expectCount: row.reminders.length });
}
/** Asserts the number of exception rows of a master, so one an app adds meanwhile fails the group. */
export function assertExceptionCount(group: GroupBuilder, masterId: number, count: number): void {
group.assert({ op: 'assert', table: 'events', where: `${Events.ORIGINAL_ID} = ?`, args: [masterId], expectCount: count });
}
function pick(row: Row, columns: readonly string[]): Row {
const out: Row = {};
for (const c of columns) out[c] = row[c] ?? null;
return out;
}
/** What a row should hold after the group: content, sync columns, children. */
export interface RowState {
isException: boolean;
/** Mapped cells (the projection). */
cells: Row;
attendees: Row[];
/** Null: Reminders rows are left as they are. */
reminders: Row[] | null;
baseline: EventBaseline;
syncId: string | null;
uid: string | null;
/** Masters: SYNC_DATA1; undefined leaves it. */
shadow?: CalendarEventWire | null;
/** Exceptions: SYNC_DATA2 (the recurrence id). */
recurrenceKey?: string | null;
/** SYNC_DATA3; undefined leaves it. */
pending?: PendingCreate | null;
/** SYNC_DATA5; undefined leaves it. */
poison?: PoisonMarker | null;
/** Exceptions: ORIGINAL_SYNC_ID. */
originalSyncId?: string | null;
/** Written as DIRTY=0 when set; DIRTY is otherwise left to the provider. */
clearDirty?: boolean;
/** ORGANIZER is written on insert only when the event has participants (the provider fills the owner otherwise). */
hasParticipants: boolean;
}
function syncValues(state: RowState): WriteRow {
const values: WriteRow = {};
values[Events._SYNC_ID] = state.syncId;
values[Events.UID_2445] = state.uid;
if (state.shadow !== undefined) values[Events.SYNC_DATA1] = state.shadow ? JSON.stringify(state.shadow) : null;
if (state.recurrenceKey !== undefined) values[Events.SYNC_DATA2] = state.recurrenceKey;
if (state.pending !== undefined) values[Events.SYNC_DATA3] = state.pending ? JSON.stringify(state.pending) : null;
values[Events.SYNC_DATA4] = encodeBaseline(state.baseline);
if (state.poison !== undefined) values[Events.SYNC_DATA5] = state.poison ? JSON.stringify(state.poison) : null;
if (state.originalSyncId !== undefined) values[Events.ORIGINAL_SYNC_ID] = state.originalSyncId;
return values;
}
/**
* Inserts a row and its attendees and reminders. `originalId` links an
* exception to its master: an id, or the index of the master's insert in
* this group.
*/
export function insertRow(group: GroupBuilder, state: RowState, originalId?: { id: number } | { ref: number }): number {
// DIRTY is NULL after a sync-adapter insert unless written, and later asserts expect 0.
const values: WriteRow = { ...syncValues(state), [Events.HAS_ATTENDEE_DATA]: 1, [Events.DIRTY]: 0 };
for (const [c, v] of Object.entries(state.cells)) {
if (c === Events.ORGANIZER && !state.hasParticipants) continue;
values[c] = v;
}
const refs: Record<string, number> = {};
if (originalId && 'id' in originalId) values[Events.ORIGINAL_ID] = originalId.id;
if (originalId && 'ref' in originalId) refs[Events.ORIGINAL_ID] = originalId.ref;
const index = group.write({ op: 'insert', table: 'events', values, ...(Object.keys(refs).length ? { refs } : {}) });
for (const a of state.attendees) group.write({ op: 'insert', table: 'attendees', values: { ...a }, refs: { [Attendees.EVENT_ID]: index } });
for (const r of state.reminders ?? []) group.write({ op: 'insert', table: 'reminders', values: { ...r }, refs: { [Reminders.EVENT_ID]: index } });
return index;
}
export function deleteRow(group: GroupBuilder, eventId: number): void {
group.write({ op: 'delete', table: 'events', id: eventId, expectCount: 1 });
}
const sameJson = (a: unknown, b: unknown) => deepEqual(a ?? null, b ?? null);
/**
* Updates an existing row to `state`, writing only cells and sync columns
* that differ, then its attendees (matched by address) and reminders
* (matched by minutes and method). Returns the number of write ops.
*/
export function updateRow(group: GroupBuilder, current: LocalEventRow & { shadow?: CalendarEventWire | null }, state: RowState): number {
const before = group.writeCount;
const values: WriteRow = {};
for (const [c, v] of Object.entries(state.cells)) {
const have = current.cells[c] ?? null;
const want = v ?? null;
// Same meaning (`''` and NULL, `P3600S` and `PT1H`) is no reason to write.
if (have === want || sameCell(c, have, want)) continue;
values[c] = want;
}
// Timing columns go together: the provider keeps DTEND and DURATION apart by what is written.
const timing = [Events.DTSTART, Events.DTEND, Events.DURATION, Events.EVENT_TIMEZONE, Events.ALL_DAY, Events.RRULE];
if (timing.some((c) => c in values)) for (const c of timing) if (c in state.cells) values[c] = state.cells[c];
if ((current.syncId ?? null) !== state.syncId) values[Events._SYNC_ID] = state.syncId;
if ((current.cells[Events.UID_2445] ?? null) !== state.uid) values[Events.UID_2445] = state.uid;
if (state.shadow !== undefined && !sameJson(current.shadow, state.shadow)) {
values[Events.SYNC_DATA1] = state.shadow ? JSON.stringify(state.shadow) : null;
}
if (state.recurrenceKey !== undefined && (current.cells[Events.SYNC_DATA2] ?? null) !== state.recurrenceKey) {
values[Events.SYNC_DATA2] = state.recurrenceKey;
}
if (state.pending !== undefined && !sameJson(current.pending, state.pending)) {
values[Events.SYNC_DATA3] = state.pending ? JSON.stringify(state.pending) : null;
}
if (!sameJson(current.baseline, state.baseline)) values[Events.SYNC_DATA4] = encodeBaseline(state.baseline);
if (state.poison !== undefined && !sameJson(current.poison, state.poison)) {
values[Events.SYNC_DATA5] = state.poison ? JSON.stringify(state.poison) : null;
}
if (state.originalSyncId !== undefined && (current.cells[Events.ORIGINAL_SYNC_ID] ?? null) !== state.originalSyncId) {
values[Events.ORIGINAL_SYNC_ID] = state.originalSyncId;
}
if (state.clearDirty && current.dirty) values[Events.DIRTY] = 0;
if (Object.keys(values).length) group.write({ op: 'update', table: 'events', id: current.eventId, values, expectCount: 1 });
writeAttendees(group, current, state.attendees);
if (state.reminders !== null) writeReminders(group, current, state.reminders);
return group.writeCount - before;
}
const emailKey = (row: Row) => String(row[Attendees.ATTENDEE_EMAIL] ?? '').trim().toLowerCase();
function writeAttendees(group: GroupBuilder, current: LocalEventRow, wanted: Row[]): void {
const unmatched = [...current.attendees];
const inserts: Row[] = [];
const updates: Array<{ id: number; values: Row }> = [];
for (const want of wanted) {
const i = unmatched.findIndex((a) => emailKey(a.cells) === emailKey(want));
if (i < 0) {
inserts.push(want);
continue;
}
const [have] = unmatched.splice(i, 1);
const values: Row = {};
for (const c of ATTENDEE_CELLS) {
const v = want[c] ?? null;
const h = have.cells[c] ?? null;
if (h === v) continue;
// The email's case matters: Android finds "me" by comparing it with OWNER_ACCOUNT exactly.
if (c !== Attendees.ATTENDEE_EMAIL && sameCell(c, h, v)) continue;
values[c] = v;
}
if (Object.keys(values).length) updates.push({ id: have.id, values });
}
for (const gone of unmatched) group.write({ op: 'delete', table: 'attendees', id: gone.id, expectCount: 1 });
for (const u of updates) group.write({ op: 'update', table: 'attendees', id: u.id, values: u.values, expectCount: 1 });
for (const add of inserts) group.write({ op: 'insert', table: 'attendees', values: { ...add, [Attendees.EVENT_ID]: current.eventId } });
}
function writeReminders(group: GroupBuilder, current: LocalEventRow, wanted: Row[]): void {
const key = (row: Row) => {
const r = rowReminder(row);
return r ? `${r.minutes}/${r.method}` : `raw:${String(row[Reminders.MINUTES])}/${String(row[Reminders.METHOD])}`;
};
const unmatched = [...current.reminders];
const inserts: Row[] = [];
for (const want of wanted) {
const i = unmatched.findIndex((r) => key(r.cells) === key(want));
if (i < 0) inserts.push(want);
else unmatched.splice(i, 1);
}
for (const gone of unmatched) group.write({ op: 'delete', table: 'reminders', id: gone.id, expectCount: 1 });
for (const add of inserts) group.write({ op: 'insert', table: 'reminders', values: { ...add, [Reminders.EVENT_ID]: current.eventId } });
}
/** Counts the provider writes of a group and the groups after it (asserts are not writes). */
export function writesOf(group: OpGroup): number {
return [group, ...(group.next ?? [])].reduce(
(n, g) => n + g.ops.filter((op) => op.op === 'insert' || op.op === 'update' || op.op === 'delete').length,
0,
);
}
// ─── Items too big for one group ────────────────────────
/**
* What one group may hold: the engine's batch budget as `estimateBatchBytes`
* weighs it, and one op less than a provider yield window (room for the
* engine's insert guard). A bigger event is written as a chain of groups
* (`OpGroup.next`); a master's shadow is one column and may take a group of
* its own beyond the budget (under one transaction for any event that syncs).
*/
const GROUP_BYTES = DEFAULT_TUNING.maxBatchBytes;
const GROUP_OPS = MAX_OPS_PER_YIELD - 1;
const weight = (ops: readonly ProviderOp[]) => (ops.length ? estimateBatchBytes(ops) : 0);
/** One group from asserts and write blocks whose `refs` index into their own block, asserts first. */
function assemble(ref: string, asserts: readonly ProviderOp[], blocks: ReadonlyArray<readonly ProviderOp[]>): OpGroup {
const ops: ProviderOp[] = [...asserts];
for (const block of blocks) {
const offset = ops.length;
for (const op of block) {
if (op.op !== 'insert' || !op.refs) ops.push(op);
else ops.push({ ...op, refs: Object.fromEntries(Object.entries(op.refs).map(([column, i]) => [column, i + offset])) });
}
}
return { ref, ops };
}
/** Writes of one row (`refs` index into them), and the asserts that guard that row (none for a new row). */
export interface RowWrite {
asserts: ProviderOp[];
writes: ProviderOp[];
}
/**
* Cuts row writes into groups of the budget, in order, a row never split:
* each group starts with `guard`, the first also with `first`.
*/
function cutRows(ref: string, rows: readonly RowWrite[], guard: readonly ProviderOp[], first: readonly ProviderOp[] = []): OpGroup[] {
const groups: OpGroup[] = [];
let asserts: ProviderOp[] = [...guard, ...first];
let blocks: ProviderOp[][] = [];
let ops = asserts.length;
let bytes = weight(asserts);
for (const row of rows) {
const n = row.asserts.length + row.writes.length;
const b = weight(row.asserts) + weight(row.writes);
if (blocks.length && (ops + n > GROUP_OPS || bytes + b > GROUP_BYTES)) {
groups.push(assemble(ref, asserts, blocks));
asserts = [...guard];
blocks = [];
ops = asserts.length;
bytes = weight(asserts);
}
asserts.push(...row.asserts);
blocks.push(row.writes);
ops += n;
bytes += b;
}
if (blocks.length) groups.push(assemble(ref, asserts, blocks));
return groups;
}
function chained(groups: readonly OpGroup[]): OpGroup {
const [first, ...next] = groups;
return next.length ? { ...first, next } : first;
}
/**
* The writes into an existing event as one group: asserts first (`master`,
* the exception rows' `rows` in order, `count`), then the writes (`deletes`,
* the master's `master`, `updates`, `inserts`). When that is more than a group
* may hold, a chain: the exception rows' writes in groups of the budget, each
* asserting the master row and the rows it writes, the first also the number
* of exception rows; then the master's write, with its new shadow, last,
* asserting the other rows. Cut off in between, the item is planned again
* from the old shadow, so no server change to a row not written yet is taken
* as done.
*/
export function eventWrite(
ref: string,
parts: {
master: ProviderOp[];
rows: Map<number, ProviderOp[]>;
count: ProviderOp[];
deletes: Array<RowWrite & { row: number }>;
masterWrite: ProviderOp[];
updates: Array<RowWrite & { row: number }>;
inserts: RowWrite[];
},
): OpGroup {
const rowAsserts = [...parts.rows.values()].flat();
const single = assemble(ref, [...parts.master, ...rowAsserts, ...parts.count], [
...parts.deletes.map((d) => d.writes),
parts.masterWrite,
...parts.updates.map((u) => u.writes),
...parts.inserts.map((i) => i.writes),
]);
if (single.ops.length <= GROUP_OPS && estimateBatchBytes(single.ops) <= GROUP_BYTES) return single;
const writing = [...parts.deletes, ...parts.updates].filter((w) => w.writes.length);
if (!writing.length && !parts.inserts.length) return single;
const written = new Set(writing.map((w) => w.row));
const rows: RowWrite[] = [
...writing.map((w) => ({ asserts: parts.rows.get(w.row) ?? [], writes: w.writes })),
...parts.inserts,
];
// The master row's own assert guards every group; its attendees and reminders go with its write.
const groups = cutRows(ref, rows, parts.master.slice(0, 1), parts.count);
const rest = [...parts.rows].filter(([row]) => !written.has(row)).flatMap(([, asserts]) => asserts);
groups.push(assemble(ref, [...parts.master, ...rest], [parts.masterWrite]));
return chained(groups);
}
/**
* A new event's rows as one group, or, when they are more than a group may
* hold, as a chain: `master` (the master's insert with its identity but
* without its shadow), its exception rows in groups of the budget, each
* asserting the master by `masterRef` and linked to it by ORIGINAL_SYNC_ID
* (the provider fills ORIGINAL_ID from it and CALENDAR_ID), then `shadow`.
* Cut off in between, the next download finds the master by its identity and
* writes what is missing; a master without a shadow merges like an adopted one.
*/
export function eventInsert(
ref: string,
single: OpGroup,
partsOf: () => { masterRef: string; master: ProviderOp[]; exceptions: RowWrite[]; shadow: CalendarEventWire },
): OpGroup {
if (single.ops.length <= GROUP_OPS && estimateBatchBytes(single.ops) <= GROUP_BYTES) return single;
const parts = partsOf();
const byIdentity = { table: 'events' as const, where: `${Events._SYNC_ID} = ?`, args: [parts.masterRef], expectCount: 1 };
const groups = [assemble(ref, [], [parts.master]), ...cutRows(ref, parts.exceptions, [{ op: 'assert', ...byIdentity }])];
groups.push({ ref, ops: [{ op: 'update', ...byIdentity, values: { [Events.SYNC_DATA1]: JSON.stringify(parts.shadow) } }] });
return chained(groups);
}
+245
View File
@@ -0,0 +1,245 @@
/**
* JSCalendar `recurrenceRule` ↔ the RRULE column (docs/device-sync.md,
* "Recurrence").
*
* AOSP's EventRecurrence rejects every part it does not know except `X-…`,
* so RSCALE and SKIP are never written (the #805 failure); a rule that needs
* them (a non-Gregorian `rscale`, a `skip` other than `omit`, a leap month
* such as `5L`) is written without them and reported as not representable.
* UNTIL is UTC (`…Z`) for timed events and a DATE for all-day ones, because
* AOSP reads a floating UNTIL as UTC anyway. Rules written by device apps
* are read tolerantly: any case, an `RRULE:` prefix, `X-` parts, UNTIL as a
* DATE, a floating or a UTC date-time (Etar writes UTC date-times even for
* all-day events), COUNT together with UNTIL.
*/
import type { RecurrenceRule } from '../../api/types';
import {
canonicalZone,
dateToUtcMidnight,
formatDate,
localToUtc,
parseLocalDateTime,
utcToLocal,
wallFromMs,
wallMs,
type WallClock,
} from './zoned-time';
export interface RuleZone {
allDay: boolean;
/** The event's zone for timed events (the device zone for floating ones). */
zone: string;
}
const FREQUENCIES = ['yearly', 'monthly', 'weekly', 'daily', 'hourly', 'minutely', 'secondly'];
const WEEKDAYS = ['mo', 'tu', 'we', 'th', 'fr', 'sa', 'su'];
/** Parts EventRecurrence knows, in the order they are written. */
const PART_ORDER = [
'FREQ', 'UNTIL', 'COUNT', 'INTERVAL', 'BYSECOND', 'BYMINUTE', 'BYHOUR', 'BYDAY', 'BYMONTHDAY',
'BYYEARDAY', 'BYWEEKNO', 'BYMONTH', 'BYSETPOS', 'WKST',
] as const;
const KNOWN_PARTS = new Set<string>(PART_ORDER);
/** Why a rule can only be shown approximately; empty when it is representable. */
export function unrepresentableParts(rule: RecurrenceRule | null | undefined): string[] {
if (!rule) return [];
const out: string[] = [];
if (rule.rscale && rule.rscale.toLowerCase() !== 'gregorian') out.push('rscale');
if (rule.skip && rule.skip.toLowerCase() !== 'omit') out.push('skip');
if ((rule.byMonth ?? []).some((m) => /l$/i.test(String(m)))) out.push('byMonth');
if (!FREQUENCIES.includes(String(rule.frequency ?? '').toLowerCase())) out.push('frequency');
return out;
}
export function isRuleRepresentable(rule: RecurrenceRule | null | undefined): boolean {
return unrepresentableParts(rule).length === 0;
}
const ints = (list: unknown): number[] =>
Array.isArray(list) ? list.filter((n): n is number => Number.isInteger(n)) : [];
/**
* The RRULE column for a rule, or null when it cannot be written at all (no
* known frequency). Unrepresentable parts are left out (see the header).
*/
export function ruleToRRule(rule: RecurrenceRule | null | undefined, ctx: RuleZone): string | null {
if (!rule) return null;
const freq = String(rule.frequency ?? '').toLowerCase();
if (!FREQUENCIES.includes(freq)) return null;
const parts: Array<[string, string]> = [['FREQ', freq.toUpperCase()]];
if (Number.isInteger(rule.count) && (rule.count as number) > 0) {
parts.push(['COUNT', String(rule.count)]);
} else if (rule.until) {
const until = formatUntil(rule.until, ctx);
if (until) parts.push(['UNTIL', until]);
}
if (Number.isInteger(rule.interval) && (rule.interval as number) > 1) parts.push(['INTERVAL', String(rule.interval)]);
const list = (name: string, values: Array<string | number>) => {
if (values.length) parts.push([name, values.join(',')]);
};
list('BYSECOND', ints(rule.bySecond));
list('BYMINUTE', ints(rule.byMinute));
list('BYHOUR', ints(rule.byHour));
list(
'BYDAY',
(rule.byDay ?? [])
.filter((d) => d && WEEKDAYS.includes(String(d.day).toLowerCase()))
.map((d) => `${Number.isInteger(d.nthOfPeriod) && d.nthOfPeriod !== 0 ? d.nthOfPeriod : ''}${String(d.day).toUpperCase()}`),
);
list('BYMONTHDAY', ints(rule.byMonthDay));
list('BYYEARDAY', ints(rule.byYearDay));
list('BYWEEKNO', ints(rule.byWeekNo));
list('BYMONTH', (rule.byMonth ?? []).map(String).filter((m) => /^\d{1,2}$/.test(m)).map(Number));
list('BYSETPOS', ints(rule.bySetPosition));
const wkst = String(rule.firstDayOfWeek ?? '').toLowerCase();
if (WEEKDAYS.includes(wkst) && wkst !== 'mo') parts.push(['WKST', wkst.toUpperCase()]);
return parts.map(([k, v]) => `${k}=${v}`).join(';');
}
const compact = (w: WallClock, withTime: boolean) => {
const date = formatDate(w).replace(/-/g, '');
if (!withTime) return date;
const p = (n: number) => String(n).padStart(2, '0');
return `${date}T${p(w.hour)}${p(w.minute)}${p(w.second)}`;
};
/** UNTIL: the date for all-day rules, else the instant of the LocalDateTime in the event's zone, in UTC. */
export function formatUntil(until: string, ctx: RuleZone): string | null {
const wall = parseLocalDateTime(until);
if (!wall) return null;
if (ctx.allDay) return compact(wall, false);
const utc = localToUtc(until, ctx.zone);
return utc === null ? null : `${compact(wallFromMs(utc), true)}Z`;
}
/**
* The instant an UNTIL value stands for, the way AOSP's RecurrenceProcessor
* reads it: a DATE is midnight UTC, a floating date-time is UTC as well.
*/
export function untilInstant(value: string): number | null {
const m = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})Z?)?$/i.exec(value.trim());
if (!m) return null;
return wallMs({
year: Number(m[1]),
month: Number(m[2]),
day: Number(m[3]),
hour: Number(m[4] ?? 0),
minute: Number(m[5] ?? 0),
second: Number(m[6] ?? 0),
});
}
/** UNTIL as JSCalendar's LocalDateTime: the UTC date for all-day rules, the instant in the event's zone otherwise. */
export function parseUntil(value: string, ctx: RuleZone): string | null {
const utc = untilInstant(value);
if (utc === null) return null;
if (ctx.allDay) return `${formatDate(wallFromMs(utc))}T00:00:00`;
return utcToLocal(utc, canonicalZone(ctx.zone) ?? 'UTC');
}
/**
* The parts of an RRULE column as a map (upper-case names and values), from
* its first rule, or null when there is no rule. `X-` parts are dropped and
* so are names EventRecurrence would reject.
*/
export function rruleParts(text: unknown): Map<string, string> | null {
if (typeof text !== 'string') return null;
const first = text.split(/\r?\n/).map((l) => l.trim()).find(Boolean);
if (!first) return null;
const parts = new Map<string, string>();
for (const raw of first.replace(/^RRULE:/i, '').split(';')) {
const eq = raw.indexOf('=');
if (eq <= 0) continue;
const name = raw.slice(0, eq).trim().toUpperCase();
if (name.startsWith('X-') || !KNOWN_PARTS.has(name) || parts.has(name)) continue;
parts.set(name, raw.slice(eq + 1).trim().toUpperCase());
}
return parts.has('FREQ') ? parts : null;
}
/**
* A comparable form of an RRULE column: parts in a fixed order, list values
* sorted, defaults (INTERVAL=1, WKST=MO) dropped. Two texts with the same
* meaning compare equal, so an app that rewrites a rule unchanged (Fossify
* rebuilds it on every save) is not an edit. Empty for no rule.
*/
export function canonicalRRule(text: unknown): string {
const parts = rruleParts(text);
if (!parts) return '';
if (parts.get('INTERVAL') === '1') parts.delete('INTERVAL');
if (parts.get('WKST') === 'MO') parts.delete('WKST');
return PART_ORDER.filter((p) => parts.has(p))
.map((p) => {
const v = parts.get(p)!;
return `${p}=${p.startsWith('BY') ? v.split(',').sort().join(',') : v}`;
})
.join(';');
}
const numberList = (v: string | undefined) =>
v === undefined ? [] : v.split(',').map((n) => Number(n)).filter((n) => Number.isInteger(n));
/**
* A rule from an RRULE column, or null when there is none or its FREQ is
* unknown. A COUNT wins over an UNTIL next to it (RFC 5545 forbids both;
* AOSP only warns).
*/
export function rruleToRule(text: unknown, ctx: RuleZone): RecurrenceRule | null {
const parts = rruleParts(text);
if (!parts) return null;
return partsToRule(parts, ctx);
}
export function partsToRule(parts: Map<string, string>, ctx: RuleZone): RecurrenceRule | null {
const frequency = (parts.get('FREQ') ?? '').toLowerCase();
if (!FREQUENCIES.includes(frequency)) return null;
const rule: RecurrenceRule = { frequency };
const interval = Number(parts.get('INTERVAL'));
if (Number.isInteger(interval) && interval > 1) rule.interval = interval;
const count = Number(parts.get('COUNT'));
if (parts.has('COUNT') && Number.isInteger(count) && count > 0) {
rule.count = count;
} else if (parts.has('UNTIL')) {
const until = parseUntil(parts.get('UNTIL')!, ctx);
if (until) rule.until = until;
}
const byDay = (parts.get('BYDAY') ?? '')
.split(',')
.map((d) => /^([+-]?\d{1,2})?(MO|TU|WE|TH|FR|SA|SU)$/.exec(d.trim()))
.filter((m): m is RegExpExecArray => !!m)
.map((m) => (m[1] && Number(m[1]) !== 0 ? { day: m[2].toLowerCase(), nthOfPeriod: Number(m[1]) } : { day: m[2].toLowerCase() }));
if (byDay.length) rule.byDay = byDay;
const lists: Array<[keyof RecurrenceRule, string]> = [
['byMonthDay', 'BYMONTHDAY'],
['byYearDay', 'BYYEARDAY'],
['byWeekNo', 'BYWEEKNO'],
['byHour', 'BYHOUR'],
['byMinute', 'BYMINUTE'],
['bySecond', 'BYSECOND'],
['bySetPosition', 'BYSETPOS'],
];
for (const [key, part] of lists) {
const values = numberList(parts.get(part));
if (values.length) (rule as unknown as Record<string, unknown>)[key] = values;
}
const byMonth = numberList(parts.get('BYMONTH')).filter((m) => m >= 1 && m <= 12).map(String);
if (byMonth.length) rule.byMonth = byMonth;
const wkst = (parts.get('WKST') ?? '').toLowerCase();
if (WEEKDAYS.includes(wkst) && wkst !== 'mo') rule.firstDayOfWeek = wkst;
return rule;
}
/**
* Where a rule stops, for pruning overrides after a "this and following"
* cut: the UNTIL instant, or null when the rule is unbounded or bounded by
* COUNT (the caller then asks CalendarProvider's LAST_DATE).
*/
export function untilUtc(rule: RecurrenceRule | null | undefined, ctx: RuleZone): number | null {
if (!rule?.until || rule.count) return null;
if (ctx.allDay) {
const midnight = dateToUtcMidnight(rule.until);
return midnight === null ? null : midnight + 86_399_000;
}
return localToUtc(rule.until, ctx.zone);
}
+50
View File
@@ -0,0 +1,50 @@
/**
* When a device change asks Stalwart to send iTIP messages
* (docs/device-sync.md, "Scheduling messages"). Stalwart schedules only when
* a `/set` says so, while CalDAV clients get implicit scheduling on every
* change; device sync follows CalDAV for the changes a calendar app would
* notify about:
*
* - the organizer, with others taking part: a change to the title,
* description, timing, rule, overrides, location, status or attendees, a
* create, and a destroy (CANCEL); reminders, colour, availability and
* privacy alone send nothing;
* - an attendee: their own RSVP, and a destroy (a declining REPLY); their
* other edits are stored for them only.
*
* Events in the past need no rule here: Stalwart sends nothing for them.
*/
import type { Participant } from '../../api/types';
import { hasOtherParticipants, userIsOrganizer, type SelfContext } from './attendees';
/** Master units whose change the organizer tells the attendees about. */
export const NOTIFYING_UNITS = ['title', 'description', 'timing', 'rule', 'location', 'status'] as const;
type Scheduled = { organizerCalendarAddress?: string | null; participants?: Record<string, Participant> | null };
export type SchedulingChange =
| {
kind: 'update';
/** A notifying unit, an override or an attendee changed. */
notifying: boolean;
/** The user's own participation status changed. */
rsvp: boolean;
/** Attendees were added (the event may have had no one else before). */
addsAttendees: boolean;
}
| { kind: 'create' }
| { kind: 'destroy' };
/** Whether the write carries `sendSchedulingMessages: true`, for `event` as it is before (or, for a create, as it is sent). */
export function sendsSchedulingMessages(event: Scheduled, change: SchedulingChange, ctx: SelfContext): boolean {
const others = hasOtherParticipants(event.participants, ctx);
switch (change.kind) {
case 'destroy':
return others;
case 'create':
return others && userIsOrganizer(event, ctx);
case 'update':
if (userIsOrganizer(event, ctx)) return (others || change.addsAttendees) && change.notifying;
return change.rsvp;
}
}
+191
View File
@@ -0,0 +1,191 @@
/**
* An event's timing ↔ DTSTART, DTEND, DURATION, EVENT_TIMEZONE and ALL_DAY
* (docs/device-sync.md, "Timing" and "DST edges").
*
* - Timed events are written at the instant of `start` in `timeZone`;
* floating events (no zone) in the device zone, which the row's
* EVENT_TIMEZONE then records.
* - All-day events are UTC midnight of the date with EVENT_TIMEZONE `UTC`,
* the only form CalendarProvider accepts.
* - Single events get DTEND (days nominal in the zone), recurring masters a
* DURATION (`P<n>D` all-day, `P<seconds>S` timed), exceptions DTEND.
*
* Uploads go the other way only for a changed timing: the start is read back
* in the event's zone (floating stays floating, all-day stays a date), and a
* start whose wall time repeats in the zone is flagged, because Stalwart
* drops such a start.
*/
import { Events } from '../android-columns';
import type { Row } from '../types';
import {
allDayDays,
addDuration,
durationBetween,
durationSeconds,
formatAospDuration,
formatJsDuration,
parseDuration,
type Duration,
} from './duration';
import {
DAY_MS,
canonicalZone,
classifyLocal,
dateToUtcMidnight,
fixedOffsetZone,
localToUtc,
offsetAt,
utcMidnightToDate,
utcToLocal,
} from './zoned-time';
export interface TimingSource {
start?: string | null;
duration?: string | null;
timeZone?: string | null;
showWithoutTime?: boolean | null;
}
export interface EventZone {
/** The zone timed values are computed in; `UTC` for all-day events. */
zone: string;
allDay: boolean;
/** No usable `timeZone`: written in the device zone. */
floating: boolean;
}
export function eventZone(event: TimingSource, deviceZone: string): EventZone {
if (event.showWithoutTime) return { zone: 'UTC', allDay: true, floating: false };
const zone = canonicalZone(event.timeZone);
if (zone) return { zone, allDay: false, floating: false };
return { zone: canonicalZone(deviceZone) ?? 'UTC', allDay: false, floating: true };
}
/** The timing cells of an event (or of one instance, `recurring` false). Null when `start` is unreadable. */
export function timingCells(event: TimingSource, recurring: boolean, deviceZone: string): Row | null {
const { zone, allDay } = eventZone(event, deviceZone);
const duration = parseDuration(event.duration ?? 'PT0S') ?? { negative: false, days: 0, seconds: 0 };
if (allDay) {
const dtstart = dateToUtcMidnight(event.start ?? '');
if (dtstart === null) return null;
const days = allDayDays(duration);
return {
[Events.DTSTART]: dtstart,
[Events.DTEND]: recurring ? null : dtstart + days * DAY_MS,
[Events.DURATION]: recurring ? `P${days}D` : null,
[Events.EVENT_TIMEZONE]: 'UTC',
[Events.ALL_DAY]: 1,
};
}
const dtstart = localToUtc(event.start ?? '', zone);
if (dtstart === null) return null;
const nonNegative: Duration = duration.negative ? { negative: false, days: 0, seconds: 0 } : duration;
return {
[Events.DTSTART]: dtstart,
[Events.DTEND]: recurring ? null : addDuration(dtstart, nonNegative, zone),
[Events.DURATION]: recurring ? formatAospDuration(durationSeconds(nonNegative), false) : null,
[Events.EVENT_TIMEZONE]: zone,
[Events.ALL_DAY]: 0,
};
}
/** The timing a row holds, as JSCalendar properties. */
export interface RowTiming {
start: string;
duration: string;
/** The zone the start is in; null for floating and for all-day events without one. */
timeZone: string | null;
showWithoutTime: boolean;
/** Whether `start` names a repeated wall time in `timeZone` (Stalwart drops those). */
ambiguous: boolean;
/** The instant of the start, for fixed-offset fallbacks. */
dtstart: number;
}
export interface RowTimingContext {
/** The event's timing on the server (the shadow), for what an edit left alone. */
server: TimingSource | null;
/** EVENT_TIMEZONE as last written by device sync (the baseline), to tell a zone change from none. */
baselineZone: string | null;
recurring: boolean;
deviceZone: string;
}
const truthy = (v: unknown) => Number(v ?? 0) === 1;
/**
* JSCalendar timing from a row: all-day rows give a date and whole days;
* timed rows give the start in the event's zone and the duration with whole
* days nominal. A floating event stays floating while its row keeps the
* zone it was written in; a zone the user picked (or an all-day event made
* timed) becomes the event's `timeZone`. An EVENT_TIMEZONE Intl doesn't know
* is read as Android reads it, as GMT.
*/
export function rowTiming(cells: Row, ctx: RowTimingContext): RowTiming | null {
const dtstart = Number(cells[Events.DTSTART]);
if (cells[Events.DTSTART] === null || cells[Events.DTSTART] === undefined || !Number.isFinite(dtstart)) return null;
const allDay = truthy(cells[Events.ALL_DAY]);
const lengthOf = (zone: string): Duration => {
if (ctx.recurring || cells[Events.DTEND] === null || cells[Events.DTEND] === undefined) {
const d = parseDuration(cells[Events.DURATION]);
if (!d || d.negative) return { negative: false, days: 0, seconds: 0 };
const s = durationSeconds(d);
return { negative: false, days: Math.floor(s / 86_400), seconds: s % 86_400 };
}
return durationBetween(dtstart, Number(cells[Events.DTEND]), zone);
};
if (allDay) {
const d = lengthOf('UTC');
const days = allDayDays(d);
const keepZone = ctx.server?.showWithoutTime ? (ctx.server.timeZone ?? null) : null;
return {
start: utcMidnightToDate(dtstart),
duration: formatJsDuration(days, 0),
timeZone: keepZone,
showWithoutTime: true,
ambiguous: false,
dtstart,
};
}
const rowZoneRaw = String(cells[Events.EVENT_TIMEZONE] ?? '');
const rowZone = canonicalZone(rowZoneRaw);
const zoneUnchanged = ctx.baselineZone !== null && rowZoneRaw === ctx.baselineZone;
const server = ctx.server;
const serverTimed = !!server && !server.showWithoutTime;
let timeZone: string | null;
let computeIn: string;
if (serverTimed && zoneUnchanged && !canonicalZone(server!.timeZone)) {
// Floating on the server (or a zone Intl doesn't know): shown in the zone it was written in.
timeZone = server!.timeZone ?? null;
computeIn = rowZone ?? 'UTC';
} else if (serverTimed && zoneUnchanged && server!.timeZone) {
timeZone = server!.timeZone;
computeIn = canonicalZone(server!.timeZone)!;
} else if (rowZone) {
timeZone = rowZone;
computeIn = rowZone;
} else {
timeZone = 'Etc/UTC';
computeIn = 'UTC';
}
const start = utcToLocal(dtstart, computeIn);
const d = lengthOf(computeIn);
return {
start,
duration: formatJsDuration(d.days, d.seconds),
timeZone,
showWithoutTime: false,
ambiguous: timeZone !== null && classifyLocal(start, computeIn) === 'overlap',
dtstart,
};
}
/**
* The same instant and wall time in a fixed-offset zone, for a start that is
* a repeated wall time in its own zone (non-recurring events only).
*/
export function fixedOffsetTiming(timing: RowTiming): RowTiming {
const zone = fixedOffsetZone(offsetAt(timing.dtstart, timing.timeZone ? canonicalZone(timing.timeZone) ?? 'UTC' : 'UTC'));
const computeIn = canonicalZone(zone) ?? 'UTC';
return { ...timing, timeZone: zone, start: utcToLocal(timing.dtstart, computeIn), ambiguous: false };
}
+114
View File
@@ -0,0 +1,114 @@
/**
* The units changes are detected, merged and uploaded in (docs/device-sync.md,
* "Units"): groups of related columns, each attendee (by address), the
* reminder set, and for a master each EXDATE entry. Exception rows are
* compared with the same units, recursively.
*/
import { Attendees, Events } from '../android-columns';
import type { EventBaseline, LocalEventRow } from '../planner';
import type { Row } from '../types';
import { addressKey, type SelfContext } from './attendees';
import { MASTER_CELLS, EXCEPTION_CELLS, makeBaseline, sameCell } from './columns';
import { exdateSet } from './exdate';
import { sameReminders } from './reminders';
import type { RowImage } from './image';
import { isUnsetStatus } from './values';
export const COLUMN_UNITS: Record<string, readonly string[]> = {
title: [Events.TITLE],
description: [Events.DESCRIPTION],
location: [Events.EVENT_LOCATION],
timing: [Events.DTSTART, Events.DTEND, Events.DURATION, Events.EVENT_TIMEZONE, Events.ALL_DAY],
rule: [Events.RRULE],
status: [Events.STATUS],
availability: [Events.AVAILABILITY],
privacy: [Events.ACCESS_LEVEL],
color: [Events.EVENT_COLOR],
calendar: [Events.CALENDAR_ID],
};
export const MASTER_COLUMN_UNITS = Object.keys(COLUMN_UNITS);
/** An exception has no rule of its own and lives in its master's calendar. */
export const EXCEPTION_COLUMN_UNITS = MASTER_COLUMN_UNITS.filter((u) => u !== 'rule' && u !== 'calendar');
/** Apps that rewrite what they don't model (docs/device-sync.md, "Lossy calendar editors"). */
const LOSSY_EDITORS = ['org.fossify.calendar', 'com.simplemobiletools.calendar.pro', 'com.simplemobiletools.calendar'];
/** Whether MUTATORS names an editor whose attendee types and rule parts can't be trusted. */
export function isLossyEditor(mutators: string | null | undefined): boolean {
if (!mutators) return false;
return mutators.split(',').some((m) => LOSSY_EDITORS.includes(m.trim()));
}
/** One side of a comparison: a row as read, a baseline, or an image. */
export interface Side {
cells: Row;
attendees: Row[];
/** Null: not managed (Bulwark owns reminders). */
reminders: Row[] | null;
}
export function sideOfRow(row: LocalEventRow): Side {
return { cells: row.cells, attendees: row.attendees.map((a) => a.cells), reminders: row.reminders.map((r) => r.cells) };
}
export function sideOfBaseline(b: EventBaseline): Side {
return { cells: b.cells, attendees: b.attendees, reminders: b.reminders };
}
export function sideOfImage(image: RowImage): Side {
return { cells: image.cells, attendees: image.attendees, reminders: image.reminders };
}
export function baselineOfImage(image: RowImage, isException: boolean): EventBaseline {
return makeBaseline(
image.cells,
isException ? EXCEPTION_CELLS : MASTER_CELLS,
image.attendees,
image.reminders ?? [],
image.truncatedDescription,
);
}
export function columnUnitDiffers(unit: string, a: Side, b: Side): boolean {
return (COLUMN_UNITS[unit] ?? []).some((c) => !sameCell(c, a.cells[c], b.cells[c]));
}
/**
* Whether the device changed a column unit against what it is measured by
* (the baseline, or the plain instance of a new exception row). A STATUS an
* app left NULL is no change: it sets no status (`statusFromDevice`).
*/
export function deviceChangedUnit(unit: string, current: Side, reference: Side): boolean {
if (unit === 'status' && isUnsetStatus(current.cells[Events.STATUS])) return false;
return columnUnitDiffers(unit, current, reference);
}
/** Attendee rows by address key (the user's aliases are one key). */
export function attendeesByKey(rows: Row[], ctx: SelfContext): Map<string, Row> {
const out = new Map<string, Row>();
for (const row of rows) {
const key = addressKey(row[Attendees.ATTENDEE_EMAIL], ctx);
if (key && !out.has(key)) out.set(key, row);
}
return out;
}
const ATTENDEE_COMPARED = [Attendees.ATTENDEE_NAME, Attendees.ATTENDEE_RELATIONSHIP, Attendees.ATTENDEE_TYPE, Attendees.ATTENDEE_STATUS];
/** What a lossy editor rewrites on every save and so never counts as an edit. */
const LOSSY_IGNORED = new Set<string>([Attendees.ATTENDEE_RELATIONSHIP, Attendees.ATTENDEE_TYPE]);
export function attendeeDiffers(a: Row | undefined, b: Row | undefined, lossy = false): boolean {
if (!a || !b) return !!a !== !!b;
return ATTENDEE_COMPARED.some((c) => !(lossy && LOSSY_IGNORED.has(c)) && !sameCell(c, a[c], b[c]));
}
export function remindersDiffer(a: Side, b: Side): boolean {
if (a.reminders === null || b.reminders === null) return false;
return !sameReminders(a.reminders, b.reminders);
}
/** EXDATE entries of a side, normalised (see `exdateSet`). */
export function exdateEntries(side: Side): Set<string> {
return exdateSet(side.cells[Events.EXDATE], Number(side.cells[Events.ALL_DAY] ?? 0) === 1);
}
+643
View File
@@ -0,0 +1,643 @@
/**
* Device changes → JMAP writes (docs/device-sync.md, "Uploads", "Device-side
* changes" and "Scheduling messages").
*
* A dirty event uploads a patch built per unit from what differs from its
* baseline, against the current shadow: pointers where Stalwart takes them,
* a whole map where it needs one (the first participant, alert or override).
* Exception rows upload only as overrides of their master: a new override
* carries the master's details (Stalwart stores a bare one as a separate
* event otherwise) without the properties Stalwart drops from overrides; an
* existing one is patched one level deep, never deeper, because a deeper
* pointer becomes a partial override that loses the other attendees.
*
* What Android can't represent is never sent: timing and rule of events
* whose rule needs RSCALE, SKIP or leap months; a truncated description.
* Stalwart drops local times it can't resolve, so a start in a repeated hour
* goes out in a fixed-offset zone (single events) or not at all (series:
* `dstAmbiguous`), and an UNTIL in a skipped or repeated hour moves an hour
* later.
*/
import type { RecurrenceRule } from '../../api/types';
import { EventStatus, Events } from '../android-columns';
import type { CalendarContext, LocalEvent, LocalEventRow, LocalException } from '../planner';
import type { Row } from '../types';
import type { CalendarEventWire } from '../wire';
import { clone } from '../common/json';
import { ptr, setInPatch, type PatchObject } from '../common/patch';
import { withNewOverrideDetails } from '../../lib/recurrence-instances';
import { userIsOrganizer } from './attendees';
import { isTruncatedBaseline } from './columns';
import { durationTextSeconds, sameDurationLength } from './duration';
import {
applyAttendeeEdits,
attendeeEdits,
descriptionValue,
locationChanges,
reminderChanges,
titleValue,
type PathChange,
} from './edits';
import { exdateEntryKey } from './exdate';
import {
FORBIDDEN_OVERRIDE_KEYS,
instanceOf,
isExcluded,
keyToInstanceTime,
overridesOf,
withoutForbiddenKeys,
type Override,
} from './exceptions';
import { eventImage, plainInstanceImage } from './image';
import { isRemovedInstance } from './merge';
import { reminderRows } from './reminders';
import { isRuleRepresentable, partsToRule, ruleToRRule, rruleParts, untilUtc, type RuleZone } from './rrule';
import { NOTIFYING_UNITS, sendsSchedulingMessages } from './scheduling';
import { fixedOffsetTiming, rowTiming, type RowTiming } from './timing';
import {
EXCEPTION_COLUMN_UNITS,
MASTER_COLUMN_UNITS,
baselineOfImage,
columnUnitDiffers,
deviceChangedUnit,
exdateEntries,
isLossyEditor,
remindersDiffer,
sideOfBaseline,
sideOfRow,
type Side,
} from './units';
import { argbToCss, availabilityFromDevice, privacyFromDevice, statusFromDevice } from './values';
import {
canonicalZone,
classifyLocal,
formatLocalDateTime,
parseLocalDateTime,
resolveWallClock,
sameLocalDateTime,
utcToLocal,
wallFromMs,
wallMs,
} from './zoned-time';
/** Why an item is not uploaded now (a skip), or why its change was put back (a revert). */
export const SKIP = {
dstAmbiguous: 'dstAmbiguous',
ruleNotRepresentable: 'ruleNotRepresentable',
instanceOnly: 'instanceOnly',
notOurCalendar: 'notOurCalendar',
readOnly: 'readOnly',
/** The user's answer, in a calendar that takes no answers either. */
rsvpRefused: 'rsvpRefused',
/** Moved in place to a calendar of another JMAP account: no patch can do that. */
crossAccountMove: 'crossAccountMove',
/** Every reminder removed from one occurrence of a series with alerts of its own: the occurrence would show those. */
remindersNotRepresentable: 'remindersNotRepresentable',
} as const;
export class SkipUpload extends Error {
constructor(readonly reason: string) {
super(reason);
}
}
const num = (v: unknown) => (v === null || v === undefined || v === '' ? null : Number.isFinite(Number(v)) ? Number(v) : null);
/** The zone and all-day flag the series' LocalDateTimes (start, keys, UNTIL) are in, as written to the rows. */
export function seriesZone(local: LocalEventRow, shadow: CalendarEventWire | null, deviceZone: string): RuleZone {
const allDay = shadow ? !!shadow.showWithoutTime : Number(local.cells[Events.ALL_DAY] ?? 0) === 1;
if (allDay) return { allDay: true, zone: 'UTC' };
const zone =
canonicalZone(shadow?.timeZone) ??
canonicalZone(local.baseline?.cells[Events.EVENT_TIMEZONE]) ??
canonicalZone(local.cells[Events.EVENT_TIMEZONE]) ??
canonicalZone(deviceZone) ??
'UTC';
return { allDay: false, zone };
}
/** A wall time Stalwart would drop (skipped or repeated) moved an hour later. */
function safeUntil(until: string, zone: RuleZone): string {
if (zone.allDay) return until;
const kind = classifyLocal(until, zone.zone);
if (kind !== 'gap' && kind !== 'overlap') return until;
const wall = parseLocalDateTime(until)!;
return formatLocalDateTime(wallFromMs(wallMs(wall) + 3_600_000));
}
/** Parts Fossify Calendar's rule editor models; others it drops, and they stay. */
const LOSSY_RULE_PARTS = ['FREQ', 'INTERVAL', 'COUNT', 'UNTIL', 'BYDAY', 'BYMONTHDAY', 'BYMONTH'];
/**
* The rule a device RRULE stands for. A lossy editor's rule is applied part
* by part onto the server's rule: parts it models and changed are taken,
* parts it dropped stay (docs/device-sync.md, "Lossy calendar editors").
*/
export function deviceRule(
rrule: unknown,
baselineRRule: unknown,
server: RecurrenceRule | null | undefined,
zone: RuleZone,
lossy: boolean,
): RecurrenceRule | null {
const parts = rruleParts(rrule);
if (!parts) return null;
let merged = parts;
if (lossy && server) {
const before = rruleParts(baselineRRule) ?? new Map<string, string>();
const serverParts = rruleParts(ruleText(server, zone)) ?? new Map<string, string>();
merged = new Map(serverParts);
for (const part of LOSSY_RULE_PARTS) {
const now = parts.get(part);
if (now !== undefined && now !== before.get(part)) {
merged.set(part, now);
if (part === 'COUNT') merged.delete('UNTIL');
if (part === 'UNTIL') merged.delete('COUNT');
}
}
}
const rule = partsToRule(merged, zone);
if (rule?.until) rule.until = safeUntil(rule.until, zone);
return rule;
}
/** The server's rule in RRULE parts, so both sides of a lossy merge compare in one dialect. */
function ruleText(rule: RecurrenceRule, zone: RuleZone): string {
return ruleToRRule(rule, zone) ?? '';
}
// ─── Patches for a dirty event ──────────────────────────
export interface UploadComputation {
patch: PatchObject;
/** Master units in the patch. */
masterUnits: Set<string>;
/** Exception rows (by id) whose changes are in the patch. */
exceptionUnits: Map<number, Set<string>>;
/** Exception rows whose instance is excluded by the patch, or already was. */
removedExceptions: Set<number>;
sendSchedulingMessages: boolean;
/** The patch holds nothing but the user's own answer, to the series or to occurrences (what `mayRSVP` allows). */
rsvpOnly: boolean;
/** Units changed on the device that can't be uploaded (reported when nothing else is). */
dropped: string[];
}
interface OverrideWork {
/** Whole override values (or null) by recurrence id. */
whole: Map<string, Override | null>;
/** One-level properties of existing overrides by recurrence id. */
props: Map<string, Record<string, unknown>>;
}
/** Sets a raw path in a patch: escaped once, never overlapping a pointer already there. */
export function put(patch: PatchObject, path: string[], value: unknown): void {
setInPatch(patch, ptr(...path), value);
}
function putAll(patch: PatchObject, root: string[], changes: PathChange[]): void {
for (const c of changes) put(patch, [...root, ...c.path], c.value);
}
/**
* The patch for a dirty event with identity (see the header). Throws
* `SkipUpload` for an item that must not be uploaded now.
*/
export function computeUpdate(local: LocalEvent, ctx: CalendarContext): UploadComputation {
const shadow = local.shadow!;
const calendar = ctx.calendarIdOfRow(local.calendarRowId);
const calendarId = calendar?.calendarId ?? Object.keys(shadow.calendarIds ?? {})[0] ?? '';
const image = eventImage(shadow, local.calendarRowId, calendarId, ctx);
const baseline = local.baseline ?? (image ? baselineOfImage(image.master, false) : null);
const zone = seriesZone(local, shadow, ctx.deviceZone);
const lossy = isLossyEditor(local.mutators);
const patch: PatchObject = {};
const masterUnits = new Set<string>();
const exceptionUnits = new Map<number, Set<string>>();
const removedExceptions = new Set<number>();
const dropped: string[] = [];
const overrides: OverrideWork = { whole: new Map(), props: new Map() };
const representable = isRuleRepresentable(shadow.recurrenceRule) && !shadow.recurrenceId;
const serverOverrides = overridesOf(shadow);
let rsvp = false;
let attendeeOthers = false;
let overridesChanged = false;
let ruleRemoved = false;
let rekey: number | null = null;
const cur = sideOfRow(local);
const bl: Side | null = baseline ? sideOfBaseline(baseline) : null;
const changed = (unit: string) => {
if (!bl) return false;
const forced = local.split === 'source' && unit === 'rule';
return (local.dirty || forced) && deviceChangedUnit(unit, cur, bl);
};
for (const unit of MASTER_COLUMN_UNITS) {
if (!changed(unit)) continue;
const cells = local.cells;
switch (unit) {
case 'title':
put(patch, ['title'], titleValue(cells));
break;
case 'description':
if (isTruncatedBaseline(baseline!.cells[Events.DESCRIPTION])) {
dropped.push(unit);
continue;
}
put(patch, ['description'], descriptionValue(cells));
if (String(shadow.descriptionContentType ?? '').toLowerCase().startsWith('text/html')) {
put(patch, ['descriptionContentType'], 'text/plain');
}
break;
case 'location':
putAll(patch, [], locationChanges(shadow, cells[Events.EVENT_LOCATION], ctx.mintKey).changes);
break;
case 'status':
put(patch, ['status'], statusFromDevice(cells[Events.STATUS]));
break;
case 'availability':
put(patch, ['freeBusyStatus'], availabilityFromDevice(cells[Events.AVAILABILITY]));
break;
case 'privacy':
put(patch, ['privacy'], privacyFromDevice(cells[Events.ACCESS_LEVEL]));
break;
case 'color':
put(patch, ['color'], argbToCss(cells[Events.EVENT_COLOR]));
break;
case 'calendar': {
const to = ctx.calendarIdOfRow(Number(cells[Events.CALENDAR_ID]));
const from = ctx.calendarIdOfRow(Number(baseline!.cells[Events.CALENDAR_ID]));
if (!to || !from || to.jmapAccountId !== ctx.jmapAccountId || from.jmapAccountId !== ctx.jmapAccountId) {
dropped.push(unit);
continue;
}
put(patch, ['calendarIds', from.calendarId], null);
put(patch, ['calendarIds', to.calendarId], true);
break;
}
case 'timing': {
if (!representable) {
dropped.push(unit);
continue;
}
const recurring = !!cells[Events.RRULE];
let t = rowTiming(cells, {
server: shadow,
baselineZone: baseline!.cells[Events.EVENT_TIMEZONE] === null ? null : String(baseline!.cells[Events.EVENT_TIMEZONE] ?? ''),
recurring,
deviceZone: ctx.deviceZone,
});
if (!t) {
dropped.push(unit);
continue;
}
if (t.ambiguous) {
if (recurring) throw new SkipUpload(SKIP.dstAmbiguous);
t = fixedOffsetTiming(t);
}
applyTiming(patch, [], t, shadow);
if (recurring && shadow.recurrenceRule && !sameLocalDateTime(t.start, shadow.start)) {
rekey = wallMs(parseLocalDateTime(t.start)!) - wallMs(parseLocalDateTime(shadow.start)!);
}
break;
}
case 'rule': {
if (!representable) {
dropped.push(unit);
continue;
}
const ruleZone: RuleZone = { allDay: Number(cells[Events.ALL_DAY] ?? 0) === 1, zone: zone.zone };
const rule = deviceRule(cells[Events.RRULE], baseline!.cells[Events.RRULE], shadow.recurrenceRule, ruleZone, lossy);
if (!rule) {
put(patch, ['recurrenceRule'], null);
if (Object.keys(serverOverrides).length) put(patch, ['recurrenceOverrides'], null);
ruleRemoved = true;
break;
}
put(patch, ['recurrenceRule'], rule);
// "This and following": overrides after the new end go.
const end = seriesEnd(rule, local, ruleZone);
if (end !== null) {
for (const key of Object.keys(serverOverrides)) {
const at = keyToInstanceTime(key, ruleZone.allDay, ruleZone.zone);
if (at !== null && at > end) {
overrides.whole.set(key, null);
overridesChanged = true;
}
}
}
break;
}
}
masterUnits.add(unit);
}
// EXDATE entries added or removed on the device.
if (bl && local.dirty && !ruleRemoved) {
const now = exdateEntries(cur);
const before = exdateEntries(bl);
for (const entry of new Set([...now, ...before])) {
if (now.has(entry) === before.has(entry)) continue;
const key = exdateEntryKey(entry, zone);
if (!key) continue;
if (!zone.allDay && classifyLocal(key, zone.zone) === 'overlap') throw new SkipUpload(SKIP.dstAmbiguous);
if (now.has(entry) && !isExcluded(serverOverrides[key])) overrides.whole.set(key, { excluded: true });
else if (!now.has(entry) && isExcluded(serverOverrides[key])) overrides.whole.set(key, null);
else continue;
overridesChanged = true;
masterUnits.add(`exdate:${entry}`);
}
}
// Attendees.
if (bl && local.dirty) {
const edits = attendeeEdits(cur.attendees, bl.attendees, ctx, lossy);
if (edits.length) {
const result = applyAttendeeEdits(shadow.participants, edits, ctx, ctx.mintKey, lossy);
if (result.changes.length) {
if (!Object.keys(shadow.participants ?? {}).length) put(patch, ['participants'], result.participants);
else putAll(patch, ['participants'], result.changes);
rsvp = rsvp || result.rsvp;
attendeeOthers = attendeeOthers || result.others;
for (const e of edits) masterUnits.add(`attendee:${e.key}`);
}
}
}
// Reminders, only when the calendar app owns them.
if (bl && local.dirty && ctx.reminderOwner === 'device' && remindersDiffer(cur, bl)) {
putAll(patch, [], reminderChanges(shadow, ctx.calendar(calendarId), cur.reminders!, ctx.mintKey).changes);
masterUnits.add('reminders');
}
// Exception rows.
let answersOnly = true;
if (!ruleRemoved && shadow.recurrenceRule) {
for (const x of local.exceptions) {
const units = exceptionChanges(x, local, shadow, calendarId, zone, overrides, removedExceptions, ctx, lossy);
if (units.dropped) dropped.push(...units.dropped);
if (units.units.size) {
exceptionUnits.set(x.eventId, units.units);
overridesChanged = true;
rsvp = rsvp || units.rsvp;
answersOnly &&= units.rsvp && !units.others && units.units.size === 1 && units.units.has('attendees');
}
}
}
if (!ruleRemoved) materializeOverrides(patch, shadow, overrides, rekey, zone);
const notifying = NOTIFYING_UNITS.some((u) => masterUnits.has(u)) || overridesChanged || attendeeOthers;
const sendSchedulingMessages = sendsSchedulingMessages(shadow, { kind: 'update', notifying, rsvp, addsAttendees: attendeeOthers }, ctx);
const rsvpOnly = rsvp && answersOnly && !attendeeOthers && [...masterUnits].every((u) => u.startsWith('attendee:'));
return { patch, masterUnits, exceptionUnits, removedExceptions, sendSchedulingMessages, dropped, rsvpOnly };
}
/** Timing properties that differ from the server's. */
function applyTiming(patch: PatchObject, root: string[], t: RowTiming, server: { start?: string; duration?: string; timeZone?: string | null; showWithoutTime?: boolean | null }): void {
if (!sameLocalDateTime(t.start, server.start)) put(patch, [...root, 'start'], t.start);
if (!sameDurationLength(t.duration, server.duration ?? 'PT0S')) put(patch, [...root, 'duration'], t.duration);
if (root.length === 0 && (t.timeZone ?? null) !== (server.timeZone ?? null)) put(patch, ['timeZone'], t.timeZone);
if (root.length === 0 && !!t.showWithoutTime !== !!server.showWithoutTime) put(patch, ['showWithoutTime'], t.showWithoutTime);
}
/**
* Where the capped series ends: the UNTIL instant, or for a COUNT the start
* of the last instance as CalendarProvider computed it (LAST_DATE minus the
* duration). Null when unknown or unbounded.
*/
function seriesEnd(rule: RecurrenceRule, local: LocalEventRow, zone: RuleZone): number | null {
if (rule.until && !rule.count) return untilUtc(rule, zone);
if (!rule.count) return null;
const lastDate = num(local.cells[Events.LAST_DATE]);
const seconds = durationTextSeconds(local.cells[Events.DURATION]);
if (lastDate === null || seconds === null) return null;
return lastDate - seconds * 1000;
}
function exceptionChanges(
x: LocalException,
master: LocalEvent,
shadow: CalendarEventWire,
calendarId: string,
zone: RuleZone,
work: OverrideWork,
removed: Set<number>,
ctx: CalendarContext,
lossy: boolean,
): { units: Set<string>; rsvp: boolean; others?: boolean; dropped?: string[] } {
const units = new Set<string>();
const key = x.recurrenceId;
if (!key) return { units, rsvp: false };
const ours = typeof x.cells[Events.SYNC_DATA2] === 'string' && !!x.cells[Events.SYNC_DATA2];
if (!ours && !zone.allDay && classifyLocal(key, zone.zone) === 'overlap') throw new SkipUpload(SKIP.dstAmbiguous);
const server = overridesOf(shadow)[key];
const cancelled = Number(x.cells[Events.STATUS]) === EventStatus.CANCELED;
if (isRemovedInstance(x) || (cancelled && isExcluded(server))) {
removed.add(x.eventId);
if (!isExcluded(server)) {
work.whole.set(key, { excluded: true });
units.add('removed');
}
return { units, rsvp: false };
}
if (!x.dirty) return { units, rsvp: false };
const plain = plainInstanceImage(shadow, key, master.calendarRowId, calendarId, ctx);
if (!plain) return { units, rsvp: false };
const existing = !!server && !isExcluded(server);
const instance = instanceOf(shadow, key, existing ? server : null);
// An existing override changes against what device sync last wrote; a new one against the plain instance.
const reference: Side = existing && x.baseline ? sideOfBaseline(x.baseline) : { cells: plain.cells, attendees: plain.attendees, reminders: plain.reminders };
const cur = sideOfRow(x);
const values: Record<string, unknown> = {};
let rsvp = false;
let others = false;
const organizer = userIsOrganizer(shadow, ctx);
for (const unit of EXCEPTION_COLUMN_UNITS) {
if (unit === 'privacy') continue; // Stalwart drops privacy from overrides.
// Etar answers one instance with STATUS_CONFIRMED on it; an attendee may not change an event's status.
if (unit === 'status' && !organizer) continue;
if (unit !== 'timing' && !deviceChangedUnit(unit, cur, reference)) continue;
// A new exception row holds what its app copied: an empty value there is one it did not model, not a deletion.
if (!existing && INHERITED_WHEN_EMPTY.has(unit) && isEmptyUnit(unit, x.cells)) continue;
const cells = x.cells;
switch (unit) {
case 'title':
values.title = titleValue(cells);
break;
case 'description':
if (x.baseline && isTruncatedBaseline(x.baseline.cells[Events.DESCRIPTION])) continue;
values.description = descriptionValue(cells) ?? clearedInOverride('description', instance, shadow);
break;
case 'location':
values.locations = locationChanges(instance, cells[Events.EVENT_LOCATION], ctx.mintKey).locations ?? clearedInOverride('location', instance, shadow);
break;
case 'status':
values.status = statusFromDevice(cells[Events.STATUS]);
break;
case 'availability':
values.freeBusyStatus = availabilityFromDevice(cells[Events.AVAILABILITY]);
break;
case 'color':
values.color = argbToCss(cells[Events.EVENT_COLOR]) ?? clearedInOverride('color', instance, shadow);
break;
case 'timing': {
const moved = columnUnitDiffers('timing', cur, reference);
if (existing && !moved) continue;
let t = rowTiming(cells, {
server: { ...instance, showWithoutTime: shadow.showWithoutTime, timeZone: instance.timeZone ?? shadow.timeZone },
baselineZone: String(reference.cells[Events.EVENT_TIMEZONE] ?? ''),
recurring: false,
deviceZone: ctx.deviceZone,
});
if (!t) continue;
if (t.ambiguous) throw new SkipUpload(SKIP.dstAmbiguous);
if (t.showWithoutTime !== !!shadow.showWithoutTime) t = { ...t, showWithoutTime: !!shadow.showWithoutTime };
if (existing) {
if (!sameLocalDateTime(t.start, instance.start)) values.start = t.start;
if (!sameDurationLength(t.duration, instance.duration ?? 'PT0S')) values.duration = t.duration;
if (!('start' in values) && !('duration' in values)) continue;
} else {
// A new override pins its start and length, as the app's own occurrence edits do.
values.start = t.start;
values.duration = t.duration;
if (!moved) continue;
}
break;
}
}
units.add(unit);
}
// A new exception row without attendees or reminders did not copy them: the series' apply.
const edits = existing || cur.attendees.length ? attendeeEdits(cur.attendees, reference.attendees, ctx, lossy) : [];
if (edits.length) {
const result = applyAttendeeEdits(instance.participants, edits, ctx, ctx.mintKey, lossy);
if (result.changes.length) {
values.participants = result.participants;
rsvp = result.rsvp;
others = result.others;
units.add('attendees');
}
}
let dropped: string[] | undefined;
if (ctx.reminderOwner === 'device' && (existing || cur.reminders!.length) && remindersDiffer(cur, reference)) {
const alerts = reminderChanges(instance, ctx.calendar(calendarId), cur.reminders!, ctx.mintKey).alerts;
if (!Object.keys(alerts).length && reminderRows(shadow.alerts).length) {
// An override is stored as a VEVENT of its own and an empty map writes no VALARM, so an override without
// alerts shows the series' again: none at all can't be stored while the series has alerts of its own.
dropped = ['reminders'];
} else {
values.alerts = alerts;
// An occurrence on the calendar's defaults ignores its own alerts: it leaves them, as a series does
// (Stalwart keeps the override's `useDefaultAlerts` as a JSPROP; the series stays on the defaults).
if (instance.useDefaultAlerts) values.useDefaultAlerts = false;
units.add('reminders');
}
}
if (existing) {
if (units.size) work.props.set(key, { ...(work.props.get(key) ?? {}), ...values });
return { units, rsvp, others, dropped };
}
// A new override: nothing but its pinned time is still an override (an app saving the instance unchanged).
if (!units.size) units.add('new');
work.whole.set(key, newOverride(shadow, key, values));
return { units, rsvp, others, dropped };
}
/** Units a new exception row inherits from its series when it leaves them empty. */
const INHERITED_WHEN_EMPTY = new Set(['title', 'description', 'location', 'color']);
function isEmptyUnit(unit: string, cells: Row): boolean {
const column = { title: Events.TITLE, description: Events.DESCRIPTION, location: Events.EVENT_LOCATION, color: Events.EVENT_COLOR }[unit];
const v = column ? cells[column] : null;
return v === null || v === undefined || v === '';
}
/**
* A description, location or colour cleared on an existing override. An
* override without one of its own shows the series' again, so while the
* series has one the override gets the empty value Stalwart keeps: the empty
* text or colour (an empty DESCRIPTION or COLOR; an empty colour is none, the
* calendar's, on the device), or a location without a name (an empty map
* writes no LOCATION line and reads back as none of its own). Null, a clean
* removal, when nothing would come back.
*/
function clearedInOverride(unit: 'description' | 'location' | 'color', instance: CalendarEventWire, master: CalendarEventWire): unknown {
if (unit === 'description') return typeof master.description === 'string' && master.description ? '' : null;
if (unit === 'color') return typeof master.color === 'string' && master.color ? '' : null;
const series = Object.keys(master.locations ?? {});
if (!series.length) return null;
const key = Object.keys(instance.locations ?? {})[0] ?? series[0];
return { [key]: { '@type': 'Location', name: '' } };
}
/**
* A new override: the changes plus the details Stalwart needs on it (title,
* locations, participants, sequence, …; `withNewOverrideDetails`), minus what
* Stalwart drops from overrides. The master's own alerts are not copied
* while it uses the calendar's defaults: they are not in force.
*/
export function newOverride(master: CalendarEventWire, key: string, values: Record<string, unknown>): Override {
const occurrence = {
...(master as unknown as Record<string, unknown>),
recurrenceId: key,
recurrenceOverrides: master.recurrenceOverrides ?? {},
start: undefined,
baseEventId: undefined,
};
const withDetails = withNewOverrideDetails(occurrence as never, values as never) as Record<string, unknown>;
if (master.useDefaultAlerts && !('alerts' in values)) delete withDetails.alerts;
const out = withoutForbiddenKeys(withDetails);
for (const name of FORBIDDEN_OVERRIDE_KEYS) delete out[name];
return out;
}
function materializeOverrides(patch: PatchObject, shadow: CalendarEventWire, work: OverrideWork, rekey: number | null, zone: RuleZone): void {
const server = overridesOf(shadow);
if (rekey !== null && Object.keys(server).length) {
// "All events" moved the series: every recurrence id moves by the same wall-clock offset.
const map: Record<string, Override> = clone(server);
for (const [key, value] of work.whole) {
if (value === null) delete map[key];
else map[key] = value;
}
for (const [key, props] of work.props) if (map[key]) map[key] = { ...map[key], ...props };
const shifted: Record<string, Override> = {};
for (const [key, value] of Object.entries(map)) {
const moved = shiftLocal(key, rekey, zone);
const next = clone(value);
if (!isExcluded(next) && typeof next.start === 'string' && sameLocalDateTime(next.start, key)) next.start = shiftLocal(next.start, rekey, zone);
shifted[moved] = next;
}
put(patch, ['recurrenceOverrides'], Object.keys(shifted).length ? shifted : null);
return;
}
if (!Object.keys(server).length) {
// The first override: a pointer below a missing map fails, so the whole map goes.
const whole: Record<string, Override> = {};
for (const [key, value] of work.whole) if (value) whole[key] = value;
if (Object.keys(whole).length) put(patch, ['recurrenceOverrides'], whole);
return;
}
for (const [key, value] of work.whole) put(patch, ['recurrenceOverrides', key], value);
for (const [key, props] of work.props) for (const [name, value] of Object.entries(props)) put(patch, ['recurrenceOverrides', key, name], value);
}
/** A LocalDateTime moved by wall-clock milliseconds; a result in a skipped hour moves forward like the instance does. */
function shiftLocal(value: string, deltaMs: number, zone: RuleZone): string {
const wall = parseLocalDateTime(value);
if (!wall) return value;
const moved = wallFromMs(wallMs(wall) + deltaMs);
const text = formatLocalDateTime(moved);
if (zone.allDay) return text;
const kind = classifyLocal(text, zone.zone);
if (kind === 'overlap') throw new SkipUpload(SKIP.dstAmbiguous);
if (kind === 'gap') return utcToLocal(resolveWallClock(moved, canonicalZone(zone.zone) ?? 'UTC').utc, zone.zone);
return text;
}
+203
View File
@@ -0,0 +1,203 @@
/**
* Value tables between JSCalendar and CalendarContract (docs/device-sync.md,
* "Other properties"): status, availability, privacy, colours and the
* attendee enums. Pure lookups, both directions.
*/
import {
AttendeeRelationship,
AttendeeStatus,
AttendeeType,
EventAccess,
EventAvailability,
EventStatus,
ReminderMethod,
} from '../android-columns';
// ─── Event status, availability, privacy ─────────────────
/** `status` → STATUS; absent (JSCalendar's default is confirmed) → CONFIRMED. Never null: the provider throws on it. */
export function statusToDevice(status: unknown): number {
switch (String(status ?? '').toLowerCase()) {
case 'tentative':
return EventStatus.TENTATIVE;
case 'cancelled':
return EventStatus.CANCELED;
default:
return EventStatus.CONFIRMED;
}
}
/**
* STATUS → `status`; null when the row has none. CalendarProvider has no
* default for STATUS, and apps that do not model it (Google Calendar) leave
* it NULL on the events and exceptions they insert: that sets no status.
*/
export function statusFromDevice(value: unknown): 'confirmed' | 'tentative' | 'cancelled' | null {
if (isUnsetStatus(value)) return null;
const n = Number(value);
if (n === EventStatus.TENTATIVE) return 'tentative';
if (n === EventStatus.CANCELED) return 'cancelled';
return 'confirmed';
}
/** A STATUS cell an app left NULL (see `statusFromDevice`). */
export function isUnsetStatus(value: unknown): boolean {
return value === null || value === undefined || value === '';
}
/** `freeBusyStatus` → AVAILABILITY; JSCalendar's default is busy. */
export function availabilityToDevice(freeBusy: unknown): number {
return String(freeBusy ?? '').toLowerCase() === 'free' ? EventAvailability.FREE : EventAvailability.BUSY;
}
/** AVAILABILITY → `freeBusyStatus`; TENTATIVE has no JSCalendar value and uploads as busy. */
export function availabilityFromDevice(value: unknown): 'free' | 'busy' {
return Number(value) === EventAvailability.FREE ? 'free' : 'busy';
}
/** `privacy` → ACCESS_LEVEL: absent DEFAULT, public PUBLIC, private PRIVATE, secret CONFIDENTIAL. */
export function privacyToDevice(privacy: unknown): number {
switch (String(privacy ?? '').toLowerCase()) {
case 'public':
return EventAccess.PUBLIC;
case 'private':
return EventAccess.PRIVATE;
case 'secret':
return EventAccess.CONFIDENTIAL;
default:
return EventAccess.DEFAULT;
}
}
/** ACCESS_LEVEL → `privacy`; DEFAULT removes the property. */
export function privacyFromDevice(value: unknown): 'public' | 'private' | 'secret' | null {
switch (Number(value)) {
case EventAccess.PUBLIC:
return 'public';
case EventAccess.PRIVATE:
return 'private';
case EventAccess.CONFIDENTIAL:
return 'secret';
default:
return null;
}
}
// ─── Attendees ──────────────────────────────────────────
/** participationStatus → ATTENDEE_STATUS: accepted 1, declined 2, needs-action (the default) 3, tentative 4, else NONE. */
export function participationToDevice(status: unknown): number {
switch (String(status ?? 'needs-action').toLowerCase()) {
case 'accepted':
return AttendeeStatus.ACCEPTED;
case 'declined':
return AttendeeStatus.DECLINED;
case 'needs-action':
return AttendeeStatus.INVITED;
case 'tentative':
return AttendeeStatus.TENTATIVE;
default:
return AttendeeStatus.NONE;
}
}
/** ATTENDEE_STATUS → participationStatus; NONE has no JSCalendar value. */
export function participationFromDevice(value: unknown): string | null {
switch (Number(value)) {
case AttendeeStatus.ACCEPTED:
return 'accepted';
case AttendeeStatus.DECLINED:
return 'declined';
case AttendeeStatus.INVITED:
return 'needs-action';
case AttendeeStatus.TENTATIVE:
return 'tentative';
default:
return null;
}
}
/** ATTENDEE_TYPE: the `optional` role → OPTIONAL, kind resource/location → RESOURCE, else REQUIRED. */
export function attendeeTypeToDevice(participant: { roles?: Record<string, boolean> | null; kind?: string | null }): number {
const kind = String(participant.kind ?? '').toLowerCase();
if (kind === 'resource' || kind === 'location') return AttendeeType.RESOURCE;
if (participant.roles?.optional) return AttendeeType.OPTIONAL;
return AttendeeType.REQUIRED;
}
export function relationshipToDevice(isOrganizer: boolean): number {
return isOrganizer ? AttendeeRelationship.ORGANIZER : AttendeeRelationship.ATTENDEE;
}
// ─── Reminders ──────────────────────────────────────────
/** Reminders.METHOD for an alert action: email → EMAIL, anything else → ALERT. */
export function reminderMethodToDevice(action: unknown): number {
return String(action ?? '').toLowerCase() === 'email' ? ReminderMethod.EMAIL : ReminderMethod.ALERT;
}
/** The alert action of a METHOD: EMAIL → email; DEFAULT, ALERT, ALARM and SMS all fire a notification. */
export function reminderActionFromDevice(method: unknown): 'email' | 'display' {
return Number(method) === ReminderMethod.EMAIL ? 'email' : 'display';
}
// ─── Colours ────────────────────────────────────────────
/** CSS named colours (CSS Color Module Level 4), as `name:rrggbb`. */
const NAMED_COLORS = Object.fromEntries(
(
'aliceblue:f0f8ff antiquewhite:faebd7 aqua:00ffff aquamarine:7fffd4 azure:f0ffff beige:f5f5dc bisque:ffe4c4 ' +
'black:000000 blanchedalmond:ffebcd blue:0000ff blueviolet:8a2be2 brown:a52a2a burlywood:deb887 ' +
'cadetblue:5f9ea0 chartreuse:7fff00 chocolate:d2691e coral:ff7f50 cornflowerblue:6495ed cornsilk:fff8dc ' +
'crimson:dc143c cyan:00ffff darkblue:00008b darkcyan:008b8b darkgoldenrod:b8860b darkgray:a9a9a9 ' +
'darkgreen:006400 darkgrey:a9a9a9 darkkhaki:bdb76b darkmagenta:8b008b darkolivegreen:556b2f ' +
'darkorange:ff8c00 darkorchid:9932cc darkred:8b0000 darksalmon:e9967a darkseagreen:8fbc8f ' +
'darkslateblue:483d8b darkslategray:2f4f4f darkslategrey:2f4f4f darkturquoise:00ced1 darkviolet:9400d3 ' +
'deeppink:ff1493 deepskyblue:00bfff dimgray:696969 dimgrey:696969 dodgerblue:1e90ff firebrick:b22222 ' +
'floralwhite:fffaf0 forestgreen:228b22 fuchsia:ff00ff gainsboro:dcdcdc ghostwhite:f8f8ff gold:ffd700 ' +
'goldenrod:daa520 gray:808080 green:008000 greenyellow:adff2f grey:808080 honeydew:f0fff0 hotpink:ff69b4 ' +
'indianred:cd5c5c indigo:4b0082 ivory:fffff0 khaki:f0e68c lavender:e6e6fa lavenderblush:fff0f5 ' +
'lawngreen:7cfc00 lemonchiffon:fffacd lightblue:add8e6 lightcoral:f08080 lightcyan:e0ffff ' +
'lightgoldenrodyellow:fafad2 lightgray:d3d3d3 lightgreen:90ee90 lightgrey:d3d3d3 lightpink:ffb6c1 ' +
'lightsalmon:ffa07a lightseagreen:20b2aa lightskyblue:87cefa lightslategray:778899 lightslategrey:778899 ' +
'lightsteelblue:b0c4de lightyellow:ffffe0 lime:00ff00 limegreen:32cd32 linen:faf0e6 magenta:ff00ff ' +
'maroon:800000 mediumaquamarine:66cdaa mediumblue:0000cd mediumorchid:ba55d3 mediumpurple:9370db ' +
'mediumseagreen:3cb371 mediumslateblue:7b68ee mediumspringgreen:00fa9a mediumturquoise:48d1cc ' +
'mediumvioletred:c71585 midnightblue:191970 mintcream:f5fffa mistyrose:ffe4e1 moccasin:ffe4b5 ' +
'navajowhite:ffdead navy:000080 oldlace:fdf5e6 olive:808000 olivedrab:6b8e23 orange:ffa500 ' +
'orangered:ff4500 orchid:da70d6 palegoldenrod:eee8aa palegreen:98fb98 paleturquoise:afeeee ' +
'palevioletred:db7093 papayawhip:ffefd5 peachpuff:ffdab9 peru:cd853f pink:ffc0cb plum:dda0dd ' +
'powderblue:b0e0e6 purple:800080 rebeccapurple:663399 red:ff0000 rosybrown:bc8f8f royalblue:4169e1 ' +
'saddlebrown:8b4513 salmon:fa8072 sandybrown:f4a460 seagreen:2e8b57 seashell:fff5ee sienna:a0522d ' +
'silver:c0c0c0 skyblue:87ceeb slateblue:6a5acd slategray:708090 slategrey:708090 snow:fffafa ' +
'springgreen:00ff7f steelblue:4682b4 tan:d2b48c teal:008080 thistle:d8bfd8 tomato:ff6347 ' +
'turquoise:40e0d0 violet:ee82ee wheat:f5deb3 white:ffffff whitesmoke:f5f5f5 yellow:ffff00 ' +
'yellowgreen:9acd32'
)
.split(' ')
.map((pair) => pair.split(':') as [string, string]),
);
/**
* A CSS colour (a name, `#rgb` or `#rrggbb`) as the signed 32-bit ARGB int
* the provider stores, fully opaque; null for anything else.
*/
export function cssColorToArgb(color: unknown): number | null {
if (typeof color !== 'string') return null;
const c = color.trim().toLowerCase();
let hex = NAMED_COLORS[c] ?? null;
if (!hex) {
const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/.exec(c);
if (!m) return null;
hex = m[1].length === 3 ? m[1].split('').map((ch) => ch + ch).join('') : m[1];
}
return (0xff000000 | parseInt(hex, 16)) | 0;
}
/** An ARGB int as `#rrggbb` (the alpha is dropped: JSCalendar colours are opaque). */
export function argbToCss(value: unknown): string | null {
if (value === null || value === undefined || value === '') return null;
const n = Number(value);
if (!Number.isFinite(n)) return null;
return `#${((n >>> 0) & 0xffffff).toString(16).padStart(6, '0')}`;
}
+252
View File
@@ -0,0 +1,252 @@
/**
* JSCalendar LocalDateTimes in an IANA zone ↔ UTC instants, for mapping
* events to CalendarProvider's millisecond columns (docs/device-sync.md,
* "Timing" and "DST edges"). Only `Intl` is used, so it behaves the same on
* Hermes and in vitest, whatever zone the process runs in.
*
* RFC 5545 §3.3.5 decides the transition hours: a wall time in a DST gap is
* read with the offset before the gap (it moves forward by the gap), and a
* wall time in an overlap is the earlier of its two instants. The app's
* `localDateTimeToInstant` (lib/calendar-timezone.ts) gets one of the two
* wrong depending on the sign of the zone's offset, and its module imports
* the settings store, so it is not reused here.
*/
export interface WallClock {
year: number;
month: number;
day: number;
hour: number;
minute: number;
second: number;
}
export type LocalTimeKind = 'ok' | 'gap' | 'overlap';
export const DAY_MS = 86_400_000;
const HOUR_MS = 3_600_000;
const LOCAL_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:[.,]\d+)?)?)?/;
/**
* The fields of a LocalDateTime (`2026-10-25T02:30:00`) or a date
* (`2026-10-25`, read as midnight). Like Stalwart, a fraction and a trailing
* `Z` or offset are ignored. Null for anything that is not a real date-time.
*/
export function parseLocalDateTime(value: unknown): WallClock | null {
if (typeof value !== 'string') return null;
const m = LOCAL_DATE_TIME.exec(value.trim());
if (!m) return null;
const wall: WallClock = {
year: Number(m[1]),
month: Number(m[2]),
day: Number(m[3]),
hour: Number(m[4] ?? 0),
minute: Number(m[5] ?? 0),
second: Number(m[6] ?? 0),
};
if (wall.hour > 23 || wall.minute > 59 || wall.second > 59) return null;
// Date arithmetic rolls 2026-02-30 over into March; a real date survives the round trip.
const back = wallFromMs(wallMs(wall));
return back.month === wall.month && back.day === wall.day ? wall : null;
}
const pad = (n: number, len = 2) => String(n).padStart(len, '0');
export function formatLocalDateTime(wall: WallClock): string {
return `${pad(wall.year, 4)}-${pad(wall.month)}-${pad(wall.day)}T${pad(wall.hour)}:${pad(wall.minute)}:${pad(wall.second)}`;
}
/** `YYYY-MM-DD` of a wall clock. */
export function formatDate(wall: WallClock): string {
return `${pad(wall.year, 4)}-${pad(wall.month)}-${pad(wall.day)}`;
}
/** The wall clock read as if it were UTC, in milliseconds (years below 100 included). */
export function wallMs(wall: WallClock): number {
const d = new Date(0);
d.setUTCFullYear(wall.year, wall.month - 1, wall.day);
d.setUTCHours(wall.hour, wall.minute, wall.second, 0);
return d.getTime();
}
export function wallFromMs(ms: number): WallClock {
const d = new Date(ms);
return {
year: d.getUTCFullYear(),
month: d.getUTCMonth() + 1,
day: d.getUTCDate(),
hour: d.getUTCHours(),
minute: d.getUTCMinutes(),
second: d.getUTCSeconds(),
};
}
/** Wall-clock arithmetic: calendar days, then an exact amount of wall time. */
export function addToWall(wall: WallClock, days: number, ms = 0): WallClock {
return wallFromMs(wallMs(wall) + days * DAY_MS + ms);
}
// ─── Zones ──────────────────────────────────────────────
const zoneCache = new Map<string, string | null>();
/**
* The zone as Intl names it (canonical case, aliases resolved: `US/Eastern`
* → `America/New_York`, `Etc/UTC` → `UTC`), or null when Intl does not know
* it. Android's TimeZone silently turns an unknown id into GMT and Stalwart
* turns one into a floating event, so every zone is checked before either
* write.
*/
export function canonicalZone(zone: unknown): string | null {
if (typeof zone !== 'string' || !zone) return null;
let canonical = zoneCache.get(zone);
if (canonical === undefined) {
try {
canonical = new Intl.DateTimeFormat('en-US', { timeZone: zone }).resolvedOptions().timeZone ?? zone;
} catch {
canonical = null;
}
zoneCache.set(zone, canonical);
}
return canonical;
}
export function isValidTimeZone(zone: unknown): zone is string {
return canonicalZone(zone) !== null;
}
const formatters = new Map<string, Intl.DateTimeFormat>();
function formatter(zone: string): Intl.DateTimeFormat {
let f = formatters.get(zone);
if (!f) {
f = new Intl.DateTimeFormat('en-US', {
timeZone: zone,
hourCycle: 'h23',
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
});
formatters.set(zone, f);
}
return f;
}
/** Wall-clock fields of an instant in `zone` (whole seconds). */
export function wallClockAt(utcMs: number, zone: string): WallClock {
const fields: Record<string, number> = {};
for (const part of formatter(zone).formatToParts(new Date(floorSecond(utcMs)))) {
if (part.type !== 'literal') fields[part.type] = Number(part.value);
}
return {
year: fields.year,
month: fields.month,
day: fields.day,
// Some engines still say "24" at midnight despite h23.
hour: fields.hour === 24 ? 0 : fields.hour,
minute: fields.minute,
second: fields.second,
};
}
function floorSecond(ms: number): number {
return Math.floor(ms / 1000) * 1000;
}
/** The zone's UTC offset at an instant in ms, east positive. */
export function offsetAt(utcMs: number, zone: string): number {
const at = floorSecond(utcMs);
return wallMs(wallClockAt(at, zone)) - at;
}
export interface ResolvedLocalTime {
/** The instant per RFC 5545: gap → the offset before it; overlap → the earlier one. */
utc: number;
kind: LocalTimeKind;
}
/**
* The instant of a wall clock in `zone`, and whether the wall time is
* normal, skipped (gap) or repeated (overlap). Assumes at most one offset
* change within a day of the wall time, which holds for every real zone.
*/
export function resolveWallClock(wall: WallClock, zone: string): ResolvedLocalTime {
const w = wallMs(wall);
const before = offsetAt(w - DAY_MS, zone);
const after = offsetAt(w + DAY_MS, zone);
const tBefore = w - before;
const tAfter = w - after;
const okBefore = offsetAt(tBefore, zone) === before;
const okAfter = offsetAt(tAfter, zone) === after;
if (okBefore && okAfter) {
return tBefore === tAfter ? { utc: tBefore, kind: 'ok' } : { utc: Math.min(tBefore, tAfter), kind: 'overlap' };
}
if (okBefore) return { utc: tBefore, kind: 'ok' };
if (okAfter) return { utc: tAfter, kind: 'ok' };
return { utc: tBefore, kind: 'gap' };
}
/** A LocalDateTime in `zone` as a UTC instant (RFC 5545 semantics), or null when unreadable. */
export function localToUtc(value: string, zone: string): number | null {
const wall = parseLocalDateTime(value);
const z = canonicalZone(zone);
if (!wall || !z) return null;
return resolveWallClock(wall, z).utc;
}
/** Whether a LocalDateTime is an ordinary, a skipped or a repeated wall time in `zone`. */
export function classifyLocal(value: string, zone: string): LocalTimeKind | null {
const wall = parseLocalDateTime(value);
const z = canonicalZone(zone);
if (!wall || !z) return null;
return resolveWallClock(wall, z).kind;
}
/** The instant as a LocalDateTime in `zone`. Both instants of an overlap give the same text. */
export function utcToLocal(utcMs: number, zone: string): string {
return formatLocalDateTime(wallClockAt(utcMs, canonicalZone(zone) ?? 'UTC'));
}
/** UTC midnight of a LocalDateTime's date: how CalendarProvider stores all-day times. */
export function dateToUtcMidnight(value: string): number | null {
const wall = parseLocalDateTime(value);
return wall ? wallMs({ ...wall, hour: 0, minute: 0, second: 0 }) : null;
}
/** The UTC date of an instant as a LocalDateTime at midnight (`2026-10-08T00:00:00`). */
export function utcMidnightToDate(utcMs: number): string {
return `${formatDate(wallFromMs(utcMs))}T00:00:00`;
}
/**
* A fixed-offset zone for an instant: `Etc/GMT-2` for UTC+2 (the Etc names
* flip the sign), `Etc/UTC` when the offset is zero or not a whole number of
* hours. Stalwart drops a start whose local time is repeated in its zone; the
* same instant and wall time in a fixed-offset zone survive (checked on the
* live server).
*/
export function fixedOffsetZone(offsetMs: number): string {
const hours = offsetMs / HOUR_MS;
if (!Number.isInteger(hours) || hours === 0 || hours < -12 || hours > 14) return 'Etc/UTC';
return `Etc/GMT${hours > 0 ? '-' : '+'}${Math.abs(hours)}`;
}
/** Compares two LocalDateTimes by their fields (a fraction or suffix is ignored). */
export function sameLocalDateTime(a: unknown, b: unknown): boolean {
const wa = parseLocalDateTime(a);
const wb = parseLocalDateTime(b);
if (!wa || !wb) return wa === wb && a === b;
return wallMs(wa) === wallMs(wb);
}
/** Orders LocalDateTimes (ISO text compares chronologically once normalised). */
export function compareLocal(a: string, b: string): number {
const wa = parseLocalDateTime(a);
const wb = parseLocalDateTime(b);
if (!wa || !wb) return a < b ? -1 : a > b ? 1 : 0;
return wallMs(wa) - wallMs(wb);
}
+78
View File
@@ -0,0 +1,78 @@
/**
* Identifiers device sync mints and parses: map keys for new JSContact /
* JSCalendar entries, uids for new objects, and the `<jmapAccountId>/<id>`
* identities kept in SOURCE_ID / _SYNC_ID (docs/device-sync.md, "Identity").
*/
/** A random source in [0, 1); injectable so tests are deterministic. */
export type RandomSource = () => number;
const BASE36 = '0123456789abcdefghijklmnopqrstuvwxyz';
function randomChars(random: RandomSource, n: number): string {
let s = '';
for (let i = 0; i < n; i++) s += BASE36[Math.floor(random() * 36) % 36];
return s;
}
/**
* Keys for new map entries: `b` + 8 base36 characters. Stalwart parses a
* numeric key as an array index and generates `k<n>` keys itself, so neither
* shape is ever minted.
*/
export function makeKeyMinter(random: RandomSource = Math.random): (taken?: Iterable<string>) => string {
let counter = 0;
return (taken) => {
const used = new Set(taken ?? []);
for (let attempt = 0; attempt < 16; attempt++) {
const key = `b${randomChars(random, 8)}`;
if (!used.has(key)) return key;
}
// A degenerate random source: count instead of looping forever.
for (;;) {
const key = `b${(counter++).toString(36).padStart(8, '0')}`;
if (!used.has(key)) return key;
}
};
}
/** An RFC 4122 v4 UUID from the random source. */
export function uuidFrom(random: RandomSource): string {
const hex = Array.from({ length: 32 }, () => Math.floor(random() * 16).toString(16));
hex[12] = '4';
hex[16] = ((parseInt(hex[16], 16) & 0x3) | 0x8).toString(16);
const h = hex.join('');
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
}
/** SOURCE_ID / _SYNC_ID of a server object. JMAP ids never contain `/`. */
export function objectRef(jmapAccountId: string, id: string): string {
return `${jmapAccountId}/${id}`;
}
/** The parts of an object ref, or null for anything else (a pending marker, another app's value). */
export function parseObjectRef(ref: unknown): { accountId: string; id: string } | null {
if (typeof ref !== 'string') return null;
const m = /^([A-Za-z0-9_-]+)\/([A-Za-z0-9_-]+)$/.exec(ref);
return m ? { accountId: m[1], id: m[2] } : null;
}
/** `_SYNC_ID` of a local event claimed for upload; `~` never occurs in JMAP ids. */
export const PENDING_PREFIX = '~pending/';
export function pendingRef(uid: string): string {
return `${PENDING_PREFIX}${uid}`;
}
export function pendingUidOf(ref: unknown): string | null {
return typeof ref === 'string' && ref.startsWith(PENDING_PREFIX) ? ref.slice(PENDING_PREFIX.length) : null;
}
/** `_SYNC_ID` of an exception row: the master's ref plus `#` and the recurrence id. */
export function exceptionRef(masterRef: string, recurrenceId: string): string {
return `${masterRef}#${recurrenceId}`;
}
/** Collection keys (`<jmapAccountId>/<collectionId>`), as in SYNC1 and the selections. */
export const collectionKey = objectRef;
export const parseCollectionKey = parseObjectRef;
+72
View File
@@ -0,0 +1,72 @@
/**
* JSON helpers for comparing server objects, shadows and baselines by value.
* Pure: device sync's mappers, merge rules and engine build on them.
*/
import { sha256Hex } from '../../lib/sha256';
type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
/** JSON with object keys sorted and `undefined` members dropped, so equal values give equal text. */
export function canonicalJson(value: unknown): string {
return JSON.stringify(canonicalize(value));
}
function canonicalize(value: unknown): Json {
if (value === null || value === undefined) return null;
if (Array.isArray(value)) return value.map((v) => (v === undefined ? null : canonicalize(v)));
if (typeof value === 'object') {
const out: { [key: string]: Json } = {};
for (const key of Object.keys(value as object).sort()) {
const v = (value as Record<string, unknown>)[key];
if (v !== undefined) out[key] = canonicalize(v);
}
return out;
}
if (typeof value === 'number' && !Number.isFinite(value)) return null;
return value as Json;
}
/** Structural equality of JSON values; key order and `undefined` members don't matter. */
export function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (a === undefined || b === undefined || a === null || b === null) {
return (a ?? null) === (b ?? null);
}
if (typeof a !== typeof b) return false;
if (Array.isArray(a)) {
if (!Array.isArray(b) || a.length !== b.length) return false;
return a.every((v, i) => deepEqual(v, b[i]));
}
if (typeof a === 'object') {
if (Array.isArray(b)) return false;
const ao = a as Record<string, unknown>;
const bo = b as Record<string, unknown>;
const keys = new Set([...Object.keys(ao), ...Object.keys(bo)]);
for (const key of keys) if (!deepEqual(ao[key], bo[key])) return false;
return true;
}
return false;
}
export function utf8(text: string): Uint8Array {
return new TextEncoder().encode(text);
}
/** SHA-256 (hex) of the canonical JSON of a value. */
export function jsonHash(value: unknown): string {
return sha256Hex(utf8(canonicalJson(value)));
}
/** Parses JSON a sync column holds; null for empty or unreadable text (a column another app wrote). */
export function parseJsonColumn<T>(text: unknown): T | null {
if (typeof text !== 'string' || !text) return null;
try {
return JSON.parse(text) as T;
} catch {
return null;
}
}
export function clone<T>(value: T): T {
return value === undefined ? value : (JSON.parse(JSON.stringify(value)) as T);
}
+88
View File
@@ -0,0 +1,88 @@
/**
* JMAP PatchObjects (RFC 8620 §5.3): keys are JSON Pointers (RFC 6901)
* relative to the object, values replace what they point at, `null` removes.
*
* Stalwart specifics the engine relies on (docs/device-sync.md, "Uploads"):
* a pointer whose parent does not exist fails the whole update ("Patch
* operation failed"), so the first entry of a map is sent as the whole map;
* and no pointer may be a prefix of another in one patch.
*/
import { pointerToken, pointerTokenValue } from '../../api/patch-pointer';
import { clone } from './json';
export type PatchObject = Record<string, unknown>;
/** Joins raw member names into a pointer, escaping `~` and `/` in each. */
export function ptr(...segments: string[]): string {
return segments.map(pointerToken).join('/');
}
/** The raw member names of a pointer. */
export function ptrSegments(pointer: string): string[] {
return pointer.split('/').map(pointerTokenValue);
}
/**
* Throws when one pointer of the patch is a prefix of another (RFC 8620
* §5.3 makes that an invalidPatch), or when a pointer is empty.
*/
export function assertPatchWellFormed(patch: PatchObject): void {
const keys = Object.keys(patch).sort();
for (let i = 0; i < keys.length; i++) {
if (!keys[i]) throw new Error('empty pointer in patch');
if (i > 0 && keys[i].startsWith(`${keys[i - 1]}/`)) {
throw new Error(`patch pointer ${keys[i - 1]} is a prefix of ${keys[i]}`);
}
}
}
/**
* Applies a PatchObject to a copy of `target` the way Stalwart does. Returns
* null when a pointer's parent does not exist (Stalwart rejects the update).
* Array elements cannot be addressed; the engine never tries.
*/
export function applyPatch<T extends object>(target: T, patch: PatchObject): T | null {
const out = clone(target) as Record<string, unknown>;
for (const [pointer, value] of Object.entries(patch)) {
const segments = ptrSegments(pointer);
let parent: Record<string, unknown> = out;
for (let i = 0; i < segments.length - 1; i++) {
const next = parent[segments[i]];
if (next === null || typeof next !== 'object' || Array.isArray(next)) return null;
parent = next as Record<string, unknown>;
}
const leaf = segments[segments.length - 1];
if (value === null) delete parent[leaf];
else parent[leaf] = clone(value);
}
return out as T;
}
/**
* Adds `value` at `pointer` to a patch, unless a shorter pointer of the patch
* already covers it (then the covering value is updated instead).
*/
export function setInPatch(patch: PatchObject, pointer: string, value: unknown): void {
for (const existing of Object.keys(patch)) {
if (pointer === existing) {
patch[existing] = value;
return;
}
if (pointer.startsWith(`${existing}/`)) {
const covered = patch[existing];
if (covered === null || typeof covered !== 'object') {
throw new Error(`cannot patch ${pointer} below ${existing}, which is set to a scalar`);
}
const rest = pointer.slice(existing.length + 1);
const applied = applyPatch(covered as object, { [rest]: value });
if (!applied) throw new Error(`cannot patch ${pointer}: missing parent below ${existing}`);
patch[existing] = applied;
return;
}
}
// A new covering pointer replaces the longer pointers it covers.
for (const existing of Object.keys(patch)) {
if (existing.startsWith(`${pointer}/`)) delete patch[existing];
}
patch[pointer] = value;
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Data-row cells as the contacts planner compares them. The bridge hands
* numbers back as numbers or text, "" and NULL mean the same (the design's
* rule for names, and what editors do to every field), and IS_PRIMARY 0 is
* the same as unset. Baselines (DATA_SYNC3) keep text over 1 KB as its hash.
*/
import { sha256Hex } from '../../lib/sha256';
import { Data } from '../android-columns';
import { parseJsonColumn, utf8 } from '../common/json';
import type { Cell, Row } from '../types';
const LONG_TEXT = 1024;
const HASH_PREFIX = '#sha256:';
/** Comparable text of a cell; null for NULL, "" and anything that is not a cell. */
export function cellText(value: unknown, column?: string): string | null {
let text: string | null = null;
if (typeof value === 'string') text = value === '' ? null : value;
else if (typeof value === 'number' && Number.isFinite(value)) text = String(value);
if (column === Data.IS_PRIMARY && text === '0') return null;
return text;
}
export function sameCell(a: unknown, b: unknown, column?: string): boolean {
return cellText(a, column) === cellText(b, column);
}
function baselineValue(value: unknown, column: string): string | null {
const text = cellText(value, column);
if (text !== null && text.length > LONG_TEXT) return `${HASH_PREFIX}${sha256Hex(utf8(text))}`;
return text;
}
/** DATA_SYNC3 of a row: its mapped cells, long text hashed. */
export function encodeBaseline(cells: Row, columns: readonly string[]): string {
const out: Record<string, string | null> = {};
for (const c of columns) if (c in cells) out[c] = baselineValue(cells[c], c);
return JSON.stringify(out);
}
/** A baseline read back from DATA_SYNC3; null when another app left something else there. */
export function decodeBaseline(text: unknown): Row | null {
const parsed = parseJsonColumn<unknown>(text);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
const out: Row = {};
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (v !== null && typeof v !== 'string' && typeof v !== 'number') return null;
out[k] = v as Cell;
}
return out;
}
/** Whether a cell still holds what the baseline recorded for it. */
function matchesBaseline(value: unknown, baseline: Row, column: string): boolean {
const recorded = cellText(baseline[column], column);
if (recorded !== null && recorded.startsWith(HASH_PREFIX)) return baselineValue(value, column) === recorded;
return cellText(value, column) === recorded;
}
/** Mapped columns that differ from the baseline; columns the baseline never recorded count as unchanged. */
export function changedFromBaseline(cells: Row, baseline: Row, columns: readonly string[]): string[] {
return columns.filter((c) => c in baseline && !matchesBaseline(cells[c], baseline, c));
}
/** The cell as text for building card values; null when blank. */
export function text(value: unknown): string | null {
const t = cellText(value);
return t === null || t.trim() === '' ? null : t;
}
export function num(value: unknown): number | null {
const t = cellText(value);
if (t === null) return null;
const n = Number(t);
return Number.isFinite(n) ? n : null;
}
+116
View File
@@ -0,0 +1,116 @@
/**
* The columns the engine queries for contacts, and the decoders that turn
* rows into `LocalContact`/`LocalGroup`. Sync columns are read defensively:
* any app holding WRITE_CONTACTS can write them, so what does not parse is
* treated as absent.
*/
import { Data, GroupMembership, Groups, RawContacts } from '../android-columns';
import { parseCollectionKey } from '../common/ids';
import { parseJsonColumn } from '../common/json';
import type { ContactCardWire, LocalContact, LocalDataRow, LocalGroup, PendingCreate, PoisonMarker } from '../planner';
import type { Row } from '../types';
import { cellText, decodeBaseline, num } from './cells';
const DATA_CELLS = [
Data.IS_PRIMARY, Data.DATA1, Data.DATA2, Data.DATA3, Data.DATA4, Data.DATA5, Data.DATA6, Data.DATA7,
Data.DATA8, Data.DATA9, Data.DATA10, Data.DATA11, Data.DATA12, Data.DATA13, Data.DATA14,
GroupMembership.GROUP_SOURCE_ID,
];
/**
* RAW_CONTACT_IS_READ_ONLY is not among them: the providers take it on
* writes but refuse it in every projection ("Invalid column").
*/
export const RAW_CONTACT_COLUMNS: readonly string[] = [
RawContacts._ID, RawContacts.SOURCE_ID, RawContacts.VERSION, RawContacts.DIRTY, RawContacts.DELETED,
RawContacts.SYNC1, RawContacts.SYNC2, RawContacts.SYNC3, RawContacts.SYNC4,
];
export const DATA_COLUMNS: readonly string[] = [
Data._ID, Data.RAW_CONTACT_ID, Data.MIMETYPE, ...DATA_CELLS, Data.DATA_SYNC1, Data.DATA_SYNC2, Data.DATA_SYNC3,
];
export const GROUP_COLUMNS: readonly string[] = [
Groups._ID, Groups.SOURCE_ID, Groups.VERSION, Groups.DIRTY, Groups.DELETED, Groups.TITLE,
Groups.SYNC2, Groups.SYNC3, Groups.SYNC4,
];
const flag = (v: unknown) => num(v) === 1;
function stringOrNull(v: unknown): string | null {
return typeof v === 'string' && v !== '' ? v : null;
}
export function decodeShadow(v: unknown): ContactCardWire | null {
const card = parseJsonColumn<unknown>(v);
return card && typeof card === 'object' && !Array.isArray(card) && typeof (card as { id?: unknown }).id === 'string'
? (card as ContactCardWire)
: null;
}
export function decodePending(v: unknown): PendingCreate | null {
const p = parseJsonColumn<Record<string, unknown>>(v);
if (!p || typeof p !== 'object' || typeof p.uid !== 'string' || !p.uid || typeof p.target !== 'string') return null;
return parseCollectionKey(p.target) ? { uid: p.uid, target: p.target } : null;
}
export function decodePoison(v: unknown): PoisonMarker | null {
const p = parseJsonColumn<Record<string, unknown>>(v);
if (!p || typeof p !== 'object' || typeof p.fp !== 'string' || typeof p.type !== 'string') return null;
if (typeof p.n !== 'number' || typeof p.until !== 'number') return null;
return {
fp: p.fp,
type: p.type,
n: p.n,
until: p.until,
...(typeof p.description === 'string' ? { description: p.description } : {}),
};
}
function decodeDataRow(row: Row): LocalDataRow {
const cells: Row = {};
for (const c of DATA_CELLS) if (c in row) cells[c] = row[c];
return {
id: Number(row[Data._ID]),
mimetype: String(row[Data.MIMETYPE] ?? ''),
cells,
key: stringOrNull(row[Data.DATA_SYNC1]),
photoHash: stringOrNull(row[Data.DATA_SYNC2]),
baseline: decodeBaseline(row[Data.DATA_SYNC3]),
};
}
export function decodeContact(rawContact: Row, data: Row[]): LocalContact {
const id = Number(rawContact[RawContacts._ID]);
const own = data.filter((d) => !(Data.RAW_CONTACT_ID in d) || Number(d[Data.RAW_CONTACT_ID]) === id);
const collections = (cellText(rawContact[RawContacts.SYNC1]) ?? '')
.split(',')
.map((s) => s.trim())
.filter((s) => parseCollectionKey(s));
return {
rawContactId: id,
sourceId: stringOrNull(rawContact[RawContacts.SOURCE_ID]),
version: num(rawContact[RawContacts.VERSION]) ?? 0,
dirty: flag(rawContact[RawContacts.DIRTY]),
deleted: flag(rawContact[RawContacts.DELETED]),
collections,
shadow: decodeShadow(rawContact[RawContacts.SYNC2]),
pending: decodePending(rawContact[RawContacts.SYNC3]),
poison: decodePoison(rawContact[RawContacts.SYNC4]),
rows: own.map(decodeDataRow),
};
}
export function decodeGroup(group: Row): LocalGroup {
return {
groupId: Number(group[Groups._ID]),
sourceId: stringOrNull(group[Groups.SOURCE_ID]),
version: num(group[Groups.VERSION]) ?? 0,
dirty: flag(group[Groups.DIRTY]),
deleted: flag(group[Groups.DELETED]),
title: typeof group[Groups.TITLE] === 'string' ? (group[Groups.TITLE] as string) : null,
shadow: decodeShadow(group[Groups.SYNC2]),
pending: decodePending(group[Groups.SYNC3]),
poison: decodePoison(group[Groups.SYNC4]),
};
}
+81
View File
@@ -0,0 +1,81 @@
/**
* What changed in a unit, on either side (docs/device-sync.md, "Row
* matching"). A row matched by its key is compared column by column with its
* baseline, so an emptied column is a real deletion. A row matched by value
* has no baseline: its values count only where they are non-empty and differ
* from the shadow's projection, TYPE and LABEL only for phones, emails,
* addresses and events, and IS_PRIMARY never.
*/
import { CommonKind, Data, MimeType, StructuredPostal as SP } from '../android-columns';
import type { Row } from '../types';
import { changedFromBaseline, sameCell, text } from './cells';
import type { Match } from './matching';
import { POSTAL_PART_COLUMNS, providerSplitPostal, resplitPostal } from './postal';
import { SPECS, type Unit } from './project';
const isPart = (column: string) => (POSTAL_PART_COLUMNS as readonly string[]).includes(column);
/** A postal row holding the provider's split of a formatted address an editor wrote alone. */
function isProviderSplit(m: Match): boolean {
const { row } = m;
if (row.mimetype !== MimeType.STRUCTURED_POSTAL || !providerSplitPostal(row.cells)) return false;
// A multi-part formatted address in STREET was the provider's split, not a typed street.
return m.how !== 'key' || !row.baseline || /[,\n]/.test(text(row.cells[SP.FORMATTED_ADDRESS]) ?? '');
}
/** The address as the device showed it before the edit: a keyed row's baseline, else the shadow's. */
function postalBefore(m: Match): Row {
return m.how === 'key' && m.row.baseline ? m.row.baseline : m.unit.cells;
}
/**
* The cells an upload takes a row's values from: its own, except for a
* provider split whose line changed. Its parts are read back from the new
* line along the old one (`resplitPostal`), else they stay what the device
* holds, the whole line as the street: `full` and the components never
* contradict each other on the server.
*/
export function uploadCells(m: Match): Row {
if (!isProviderSplit(m)) return m.row.cells;
const before = postalBefore(m);
const line = text(m.row.cells[SP.FORMATTED_ADDRESS]);
if (!line || sameCell(line, before[SP.FORMATTED_ADDRESS])) return m.row.cells;
return { ...m.row.cells, ...(resplitPostal(before, line) ?? {}) };
}
export function localChanges(m: Match): string[] {
const { unit } = m;
const changed = rawChanges(m).filter((c) => !unit.truncated?.includes(c));
if (!isProviderSplit(m)) return changed;
// The provider's split is no typed street: only a new line changes the parts, to what it says.
const others = changed.filter((c) => !isPart(c));
if (!others.includes(SP.FORMATTED_ADDRESS)) return others;
const before = postalBefore(m);
const after = uploadCells(m);
return [...others, ...POSTAL_PART_COLUMNS.filter((c) => !sameCell(after[c], before[c]))];
}
function rawChanges(m: Match): string[] {
const spec = SPECS[m.row.mimetype];
const { row, unit } = m;
if (m.how === 'key' && row.baseline) return changedFromBaseline(row.cells, row.baseline, spec.columns);
if (row.mimetype === MimeType.PHOTO) {
// A photo row without our baseline was put there by an editor: its picture counts as new.
return m.how === 'key' ? [] : [Data.DATA14];
}
const out = spec.valueColumns.filter((c) => text(row.cells[c]) !== null && !sameCell(row.cells[c], unit.cells[c], c));
if (spec.typed && text(row.cells[CommonKind.TYPE]) !== null && !sameCell(row.cells[CommonKind.TYPE], unit.cells[CommonKind.TYPE])) {
out.push(CommonKind.TYPE, CommonKind.LABEL);
} else if (spec.typed && text(row.cells[CommonKind.LABEL]) !== null && !sameCell(row.cells[CommonKind.LABEL], unit.cells[CommonKind.LABEL])) {
out.push(CommonKind.LABEL);
}
return out;
}
/** Whether two units show the same on the device (sub-fields Android can't hold don't count). */
export function sameUnit(a: Unit, b: Unit): boolean {
if (a.mimetype !== b.mimetype) return false;
// A photo whose bytes could not be had compares as unchanged.
if (a.mimetype === MimeType.PHOTO) return a.photoHash === undefined || b.photoHash === undefined || a.photoHash === b.photoHash;
return SPECS[a.mimetype].columns.every((c) => sameCell(a.cells[c], b.cells[c], c));
}
+384
View File
@@ -0,0 +1,384 @@
/**
* Server cards into rows (docs/device-sync.md, "Change detection and merge
* rules"): inserts, writes that make a contact's rows the card's (a clean
* contact, an accepted upload, a revert), and the per-unit merge of a dirty
* contact with a new server version.
*
* Rows are matched to the shadow's units after its keys were mapped to the
* new version's (Stalwart re-keys cards written over CardDAV). A clean write
* then touches only rows that differ, so an echo of our own upload writes
* nothing. A merge writes only units the server changed, and never gives a
* key to a row of a kind an editor rewrote (all its rows keyless): a keyed
* row there would later make the entries the editor could not show look
* deleted.
*/
import { Data, MimeType, RawContacts } from '../android-columns';
import { canonicalJson } from '../common/json';
import { objectRef, parseCollectionKey, parseObjectRef } from '../common/ids';
import type { ContactCardWire, ContactsContext, DownloadPlan, LocalContact, LocalDataRow } from '../planner';
import type { ProviderOp, WriteRow } from '../types';
import { changedFromBaseline, encodeBaseline, sameCell } from './cells';
import { localChanges, sameUnit } from './diff';
import { deletionsInferred, inPlaceEvidence, matchKind, reconcileKeys, type KindMatch } from './matching';
import { editableGroups, planMemberships, remoteMemberships, shadowMemberOf, withMemberOf, withoutMemberOf } from './members';
import { assertContact, deleteData, insertUnit, updateData, updateRawContact, updateUnit } from './ops';
import { isPhotoAbsent, shadowOf, withPhotoAbsent } from './photo';
import { KIND_ORDER, projectCard, rowMatchesUnit, SPECS, type Unit } from './project';
const ENTRY_KINDS = KIND_ORDER.filter((k) => k !== MimeType.GROUP_MEMBERSHIP);
type Ctx = ContactsContext;
export function cardUnits(card: ContactCardWire | null, ctx: Pick<Ctx, 'nameForUid' | 'photoBytes' | 'groupRowIdBySourceId'>): Unit[] {
return card ? projectCard(withoutMemberOf(card), ctx, { memberOf: null }) : [];
}
/** Raw contact columns that describe the server card: collections, shadow, read-only flag. */
function cardColumns(card: ContactCardWire, shadow: ContactCardWire, ctx: Ctx): WriteRow {
return {
[RawContacts.SYNC1]: ctx.selectedCollections(card).join(',') || null,
[RawContacts.SYNC2]: JSON.stringify(shadow),
[RawContacts.RAW_CONTACT_IS_READ_ONLY]: ctx.isReadOnly(card) ? 1 : 0,
};
}
/** Only the raw contact columns that differ from what the local item holds. */
function changedRawColumns(local: LocalContact, values: WriteRow): WriteRow {
const current: Record<string, unknown> = {
[RawContacts.SOURCE_ID]: local.sourceId,
[RawContacts.SYNC1]: local.collections.join(',') || null,
[RawContacts.SYNC2]: local.shadow ? JSON.stringify(local.shadow) : null,
[RawContacts.SYNC3]: local.pending ? JSON.stringify(local.pending) : null,
[RawContacts.SYNC4]: local.poison ? JSON.stringify(local.poison) : null,
[RawContacts.DIRTY]: local.dirty ? 1 : 0,
};
const out: WriteRow = {};
for (const [column, value] of Object.entries(values)) {
if (column === RawContacts.RAW_CONTACT_IS_READ_ONLY) continue;
if (column === RawContacts.SYNC2) {
const before = local.shadow ? canonicalJson(local.shadow) : null;
const after = typeof value === 'string' ? canonicalJson(JSON.parse(value)) : null;
if (before !== after) out[column] = value;
} else if (!sameCell(current[column], value as unknown)) {
out[column] = value;
}
}
// The providers never return the read-only flag, so it can't be compared:
// it goes along whenever the raw contact is written anyway.
if (Object.keys(out).length && RawContacts.RAW_CONTACT_IS_READ_ONLY in values) {
out[RawContacts.RAW_CONTACT_IS_READ_ONLY] = values[RawContacts.RAW_CONTACT_IS_READ_ONLY];
}
return out;
}
/** Row keys after the server re-keyed: mapped, or marked gone so they can't meet a new entry's key. */
function renameRows(rows: LocalDataRow[], base: Unit[], keyMap: Map<string, string>): LocalDataRow[] {
const baseKeys = new Set(base.map((u) => u.key));
return rows.map((r) => {
if (!r.key) return r;
const key = keyMap.get(r.key) ?? (baseKeys.has(r.key) ? `~gone:${r.key}` : r.key);
return key === r.key ? r : { ...r, key };
});
}
function renameUnits(base: Unit[], keyMap: Map<string, string>): Unit[] {
return base.map((u) => ({ ...u, key: keyMap.get(u.key) ?? `~gone:${u.key}` }));
}
interface RowPlan {
ops: ProviderOp[];
conflicts: number;
stillDirty: boolean;
evidence: boolean;
/** The card's photo is left without a row because it could not be written (the shadow's `~noPhoto`). */
photoAbsent: boolean;
}
/**
* Makes the rows of every entry kind the card's: rows matched to a unit are
* rewritten when they differ (or only get their key and baseline), rows no
* unit accounts for go, units without a row are inserted.
*/
function cleanRows(local: LocalContact, remote: Unit[], keyMap: Map<string, string>, base: Unit[], accepted: boolean): RowPlan {
const ops: ProviderOp[] = [];
let photoAbsent = false;
const rows = renameRows(local.rows, base, keyMap);
const kinds = ENTRY_KINDS.map((k) => matchKind(k, rows, remote));
for (const kind of kinds) {
for (const m of kind.matched) {
const original = local.rows.find((r) => r.id === m.row.id)!;
const { unit } = m;
const columns = SPECS[unit.mimetype].columns;
if (unit.mimetype === MimeType.PHOTO) {
if (accepted && unit.photoHash && original.photoHash !== unit.photoHash && localChanges({ ...m, row: original }).length) {
// The device's own photo just uploaded: record the server's hash, keep the picture.
// (A photo that could not be sent keeps its old baseline, so it still counts as changed.)
ops.push(updateData(m.row.id, {
[Data.DATA_SYNC1]: unit.key,
[Data.DATA_SYNC2]: unit.photoHash ?? null,
[Data.DATA_SYNC3]: encodeBaseline(m.row.cells, columns),
}));
} else if (original.photoHash !== unit.photoHash && unit.extra) {
ops.push(updateUnit(m.row.id, unit, true));
} else if (original.key !== unit.key) {
ops.push(updateData(m.row.id, { [Data.DATA_SYNC1]: unit.key }));
}
continue;
}
if (!rowMatchesUnit(unit, m.row.cells, sameCell)) ops.push(updateUnit(m.row.id, unit, true));
else if (original.key !== unit.key || !original.baseline || changedFromBaseline(m.row.cells, original.baseline, columns).length) {
// Same content: only the key and the baseline (what the provider holds now) are written.
ops.push(updateData(m.row.id, { [Data.DATA_SYNC1]: unit.key, [Data.DATA_SYNC3]: encodeBaseline(m.row.cells, columns) }));
}
}
for (const row of kind.fresh) ops.push(deleteData(row.id));
for (const unit of kind.missing) {
if (unit.mimetype === MimeType.PHOTO && !unit.extra) {
photoAbsent = true;
continue;
}
ops.push(insertUnit(local.rawContactId, unit, true));
}
}
return { ops, conflicts: 0, stillDirty: false, evidence: inPlaceEvidence(kinds), photoAbsent };
}
/** Per-unit merge of a dirty contact's rows with a new server version; the server wins real conflicts. */
function mergeRows(local: LocalContact, base: Unit[], remote: Unit[], keyMap: Map<string, string>): RowPlan {
const ops: ProviderOp[] = [];
let conflicts = 0;
let stillDirty = false;
let photoAbsent = false;
const rows = renameRows(local.rows, base, keyMap);
const renamedBase = renameUnits(base, keyMap);
const kinds: KindMatch[] = ENTRY_KINDS.map((k) => matchKind(k, rows, renamedBase));
const evidence = inPlaceEvidence(kinds);
const mapped = new Set(keyMap.values());
for (const kind of kinds) {
const start = ops.length;
const remoteOf = new Map(remote.filter((u) => u.mimetype === kind.mimetype).map((u) => [u.key, u]));
const hasKeyed = kind.matched.some((m) => m.how === 'key');
const hasKeyless = kind.fresh.length > 0 || kind.matched.some((m) => m.how !== 'key');
const keyed = hasKeyed || !hasKeyless;
// A photo the device never held (`~noPhoto`) was not deleted there.
const deletions = kind.mimetype !== MimeType.STRUCTURED_NAME && deletionsInferred(kind, evidence)
&& !(kind.mimetype === MimeType.PHOTO && isPhotoAbsent(local.shadow));
const write = (row: LocalDataRow, unit: Unit) => {
if (unit.mimetype === MimeType.PHOTO && !unit.extra) return;
ops.push(updateUnit(row.id, unit, keyed));
};
for (const m of kind.matched) {
const original = local.rows.find((r) => r.id === m.row.id)!;
const remoteUnit = remoteOf.get(m.unit.key);
const changedHere = localChanges({ ...m, row: original }).length > 0;
if (!remoteUnit) {
// Removed on the server: the delete wins, over a local edit too.
if (changedHere) conflicts++;
ops.push(deleteData(m.row.id));
} else if (sameUnit(m.unit, remoteUnit)) {
if (changedHere) stillDirty = true;
if (m.how === 'key' && original.key !== remoteUnit.key) ops.push(updateData(m.row.id, { [Data.DATA_SYNC1]: remoteUnit.key }));
} else if (!changedHere) {
write(m.row, remoteUnit);
} else if (remoteUnit.mimetype !== MimeType.PHOTO && rowMatchesUnit(remoteUnit, m.row.cells, sameCell)) {
// Both sides made the same change: converged, new baseline.
if (keyed) {
ops.push(updateData(m.row.id, { [Data.DATA_SYNC1]: remoteUnit.key, [Data.DATA_SYNC3]: encodeBaseline(m.row.cells, SPECS[kind.mimetype].columns) }));
}
} else {
conflicts++;
write(m.row, remoteUnit);
}
}
for (const unit of kind.missing) {
const remoteUnit = remoteOf.get(unit.key);
if (!deletions) continue;
if (!remoteUnit) continue;
if (sameUnit(unit, remoteUnit)) stillDirty = true;
else if (!(remoteUnit.mimetype === MimeType.PHOTO && !remoteUnit.extra)) {
conflicts++;
ops.push(insertUnit(local.rawContactId, remoteUnit, keyed));
}
}
// Entries the server added: a new local row with the same value converged with it.
const fresh = [...kind.fresh];
const spec = SPECS[kind.mimetype];
for (const unit of remoteOf.values()) {
if (mapped.has(unit.key)) continue;
const p = spec.primary(unit.cells);
const twin = p === null ? -1 : fresh.findIndex((r) => spec.primary(r.cells) === p);
if (twin >= 0) {
const [row] = fresh.splice(twin, 1);
if (keyed) ops.push(updateUnit(row.id, unit, true));
} else if (!(unit.mimetype === MimeType.PHOTO && !unit.extra)) {
ops.push(insertUnit(local.rawContactId, unit, keyed));
}
}
if (fresh.length) stillDirty = true;
if (kind.mimetype === MimeType.PHOTO) {
// The server's photo is left without a row it could not get, unless a local deletion of it waits for its upload.
const photo = [...remoteOf.values()][0];
const deletionWaits = !!photo && deletions && kind.missing.some((u) => sameUnit(u, photo));
photoAbsent = !!photo && !kind.rows.length && !ops.slice(start).some((o) => o.op === 'insert') && !deletionWaits;
}
}
return { ops, conflicts, stillDirty, evidence, photoAbsent };
}
export interface CleanWriteOptions {
/** DIRTY the assert expects; null asserts VERSION only (an accepted upload, a revert). */
assertDirty: boolean | null;
/** Clear DIRTY (unless membership changes still wait for the server). */
clearDirty: boolean;
/** Extra raw contact columns: identity, SYNC3/SYNC4 resets, DELETED. */
raw?: WriteRow;
/** The rows' changes were just uploaded (planAccepted): a changed photo is the server's now. */
accepted?: boolean;
/** The memberships the rows held at the last write, when there is no shadow to say: a new contact's are none. */
memberBase?: string[];
}
/** The JMAP account a local contact belongs to. */
export function accountOf(local: LocalContact, ctx: Ctx): string {
return parseObjectRef(local.sourceId)?.accountId ?? parseCollectionKey(local.pending?.target)?.accountId ?? ctx.jmapAccountId;
}
/**
* The ops that make a local contact's rows, shadow and raw columns the
* server card's, behind the VERSION assert. Membership changes that the
* server does not have yet stay (and keep the contact dirty), unless it can
* never take them: those are put back (`revertedMembers`).
*/
export function cleanWrite(local: LocalContact, card: ContactCardWire, ctx: Ctx, options: CleanWriteOptions) {
const server = withoutMemberOf(card);
const base = cardUnits(local.shadow, ctx);
const remote = cardUnits(server, ctx);
const keyMap = reconcileKeys(base, remote);
const rows = cleanRows(local, remote, keyMap, base, options.accepted ?? false);
const members = planMemberships({
rows: local.rows,
base: shadowMemberOf(local.shadow) ?? options.memberBase ?? null,
remote: remoteMemberships(server, ctx),
evidence: rows.evidence,
restore: true,
parent: local.rawContactId,
groupRowIdBySourceId: (g) => ctx.groupRowIdBySourceId(g),
editable: editableGroups(ctx, accountOf(local, ctx)),
});
const shadow = withPhotoAbsent(withMemberOf(shadowOf(server), members.memberOf), rows.photoAbsent);
const raw = changedRawColumns(local, {
...cardColumns(server, shadow, ctx),
...(options.raw ?? {}),
...(options.clearDirty && !members.pending ? { [RawContacts.DIRTY]: 0 } : {}),
});
const dataOps = [...rows.ops, ...members.ops];
const ops: ProviderOp[] = [];
if (Object.keys(raw).length) ops.push(updateRawContact(local.rawContactId, raw));
ops.push(...dataOps);
return {
ops: ops.length ? [assertContact(local, options.assertDirty), ...ops] : [],
writes: ops.length,
pendingMembers: members.pending,
revertedMembers: members.reverted,
};
}
function insertPlan(card: ContactCardWire, ctx: Ctx): DownloadPlan {
const identity = objectRef(ctx.jmapAccountId, card.id);
const units = cardUnits(card, ctx);
const remote = remoteMemberships(card, ctx);
const members = planMemberships({
rows: [],
base: remote === null ? null : [],
remote,
evidence: false,
restore: true,
parent: { ref: 0 },
groupRowIdBySourceId: (g) => ctx.groupRowIdBySourceId(g),
});
// A photo without bytes to write (too large, or not fetched) stays on the server only.
const shadow = withPhotoAbsent(withMemberOf(shadowOf(card), members.memberOf), units.some((u) => u.mimetype === MimeType.PHOTO && !u.extra));
const ops: ProviderOp[] = [
{ op: 'insert', table: 'raw_contacts', values: { [RawContacts.SOURCE_ID]: identity, [RawContacts.DIRTY]: 0, ...cardColumns(card, shadow, ctx) } },
...units.filter((u) => u.mimetype !== MimeType.PHOTO || u.extra).map((u) => insertUnit({ ref: 0 }, u, true)),
...members.ops,
];
return { ops: { ref: identity, ops }, conflicts: 0, stillDirty: false, effect: 'insert', writes: ops.length };
}
export function planContactDownload(card: ContactCardWire, local: LocalContact | null, ctx: Ctx): DownloadPlan {
const server = withoutMemberOf(card);
const identity = objectRef(ctx.jmapAccountId, server.id);
if (!local) return insertPlan(server, ctx);
const none = (stillDirty: boolean): DownloadPlan => ({ ops: { ref: identity, ops: [] }, conflicts: 0, stillDirty, effect: 'none', writes: 0 });
if (local.deleted) {
// Deleted on the device: the delete wins over a server edit, so the rows stay as they are.
// Only the shadow (and an identity never written) follows the card: the deletion uploads
// against the card as it is now, which may also sit in books this device does not sync.
const shadow = withMemberOf(shadowOf(server), shadowMemberOf(local.shadow));
const raw = changedRawColumns(local, {
...(local.sourceId ? {} : { [RawContacts.SOURCE_ID]: identity, [RawContacts.SYNC3]: null }),
[RawContacts.SYNC2]: JSON.stringify(shadow),
});
if (!Object.keys(raw).length) return none(true);
const ops = [assertContact(local, local.dirty), updateRawContact(local.rawContactId, raw)];
return { ops: { ref: identity, ops }, conflicts: 0, stillDirty: true, effect: 'update', writes: 1 };
}
if (!local.sourceId) {
// Our own create whose identity was never written: adopt it. The rows stay
// as they are and upload against the card as their new shadow.
const members = shadowMemberOf(local.shadow) ?? [];
const shadow = withMemberOf(shadowOf(server), remoteMemberships(server, ctx) === null ? null : members);
const ops = [
assertContact(local, local.dirty),
updateRawContact(local.rawContactId, { [RawContacts.SOURCE_ID]: identity, [RawContacts.SYNC3]: null, ...cardColumns(server, shadow, ctx) }),
];
return { ops: { ref: identity, ops }, conflicts: 0, stillDirty: true, effect: 'update', writes: 1 };
}
const uidChanged = typeof local.shadow?.uid === 'string' && typeof server.uid === 'string' && local.shadow!.uid !== server.uid;
if (!local.dirty || uidChanged) {
// A different object behind the same id is replaced, never merged into.
const target = uidChanged ? { ...local, shadow: null, rows: local.rows.map((r) => ({ ...r, key: null })) } : local;
const write = cleanWrite(target, server, ctx, { assertDirty: local.dirty, clearDirty: uidChanged });
return {
ops: { ref: identity, ops: write.ops },
conflicts: 0,
stillDirty: write.pendingMembers,
effect: write.writes ? 'update' : 'none',
writes: write.writes,
};
}
const base = cardUnits(local.shadow, ctx);
const remote = cardUnits(server, ctx);
const keyMap = reconcileKeys(base, remote);
const rows = mergeRows(local, base, remote, keyMap);
const members = planMemberships({
rows: local.rows,
base: shadowMemberOf(local.shadow),
remote: remoteMemberships(server, ctx),
evidence: rows.evidence,
restore: false,
parent: local.rawContactId,
groupRowIdBySourceId: (g) => ctx.groupRowIdBySourceId(g),
});
const shadow = withPhotoAbsent(withMemberOf(shadowOf(server), members.memberOf), rows.photoAbsent);
const raw = changedRawColumns(local, cardColumns(server, shadow, ctx));
const ops: ProviderOp[] = [];
if (Object.keys(raw).length) ops.push(updateRawContact(local.rawContactId, raw));
ops.push(...rows.ops, ...members.ops);
return {
ops: { ref: identity, ops: ops.length ? [assertContact(local, true), ...ops] : [] },
conflicts: rows.conflicts,
stillDirty: rows.stillDirty || members.pending,
effect: ops.length ? 'update' : 'none',
writes: ops.length,
};
}

Some files were not shown because too many files have changed in this diff Show More