test: add a fake device provider and jmap server for the sync tests #34

The device sync engine reaches Android's providers through a native
bridge and the server through a detached JMAP client, and neither runs
in vitest. This adds the stand-ins the engine and the mappers are tested
against, and the contracts they implement:
- `src/device-sync/types.ts`, `planner.ts`, `wire.ts`,
  `android-columns.ts`: the bridge, the provider op model, the JMAP
  port, and the interface between the engine and the contacts and
  calendar planners
- a fake ContactsProvider and CalendarProvider with the behaviour the
  design relies on: the DIRTY and VERSION rules, soft and hard deletes,
  exception linking, normalisation, asserts, the yield-point limit, and
  a "user" face that edits the way AOSP Contacts, Fossify and Etar do
- a fake Stalwart with `/changes` paging and compaction,
  `cannotCalculateChanges`, `ifInState`, uid checks behind a lagging
  search index, positional re-keying and fault injection (lost
  responses, 429, auth, SetErrors)
- shared pure helpers (canonical JSON, PatchObjects with Stalwart's
  missing-parent rule, key and uid minting) and a test that walks the
  import graph, so no mapper or engine module pulls in React Native
- `src/stores/device-sync-store.ts`: the per-account preferences and
  last run status the engine and the settings will share

Checked: `npx vitest run src/device-sync` (91 tests), the full suite
(2209 tests) and `npm run typecheck`.
This commit is contained in:
Linus Rath
2026-09-27 02:53:22 +02:00
parent f66669964a
commit 59fcf386b9
15 changed files with 6862 additions and 0 deletions
+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');
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,293 @@
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 { 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.toThrow(/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: 50_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(60_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] });
expect(rows.rows[0]).toEqual([photo!.fileId, null]);
});
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('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]).toBe(0);
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]).toBe(0);
});
});
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);
});
});
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;
};
}
+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);
});
});
+344
View File
@@ -0,0 +1,344 @@
/**
* 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',
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;
+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;
}
+357
View File
@@ -0,0 +1,357 @@
/**
* The contract between the sync engine (src/device-sync/engine, which owns all
* I/O: provider queries and batches, JMAP requests, retries, state) and the
* two mappers (src/device-sync/contacts and src/device-sync/calendar, which
* are pure planners). docs/device-sync.md describes the rules both follow.
*
* A planner never performs I/O. The engine queries rows with the planner's
* column lists, hands them to `decode*`, asks for a plan, and applies the
* plan's op groups. Every op group is applied atomically; asserts come first
* in a group, so a group whose rows changed since they were read fails as a
* whole and the engine re-reads and re-plans that item.
*
* `refs` inside a group's ops are indexes into THAT group; the engine rebases
* them when it packs several groups into one batch, and marks the first op of
* each group `yieldAllowed`.
*/
import type { AddressBookLike, CalendarLike, ContactCardWire, CalendarEventWire } from './wire';
import type { ProviderOp, ReminderOwner, Row } from './types';
import type { PatchObject } from './common/patch';
// ─── Shared ────────────────────────────────────────────
/** Ops for one item, applied atomically, asserts first. */
export interface OpGroup {
/** For logs and reports: SOURCE_ID/_SYNC_ID or `row:<_id>`. */
ref: string;
ops: ProviderOp[];
}
/** Why an item is left alone, with the fingerprint that lifts the back-off when the item changes. */
export interface PoisonMarker {
/** Fingerprint of what failed (the patch, or the row projection). */
fp: string;
/** SetError type or engine code (`dstAmbiguous`, `ruleNotRepresentable`, …). */
type: string;
description?: string;
/** Attempts so far. */
n: number;
/** Epoch ms before which the item is not retried unless `fp` changes. */
until: number;
}
/** A create claimed on the device: the uid and the collection it goes to, fixed at the claim. */
export interface PendingCreate {
uid: string;
/** `<jmapAccountId>/<collectionId>`. */
target: string;
}
export interface MapContext {
/** JMAP account of the object (the personal one or a shared one). */
jmapAccountId: string;
now: number;
/** New map keys: `b` + 8 base36 characters, never one of `taken`. */
mintKey(taken?: Iterable<string>): string;
/** A new uid for a device-created object. */
mintUid(): string;
}
/**
* One server write an upload plan asks for. The engine batches them into
* `/set` calls per JMAP account, adds `ifInState`, and reports each result
* back through `planAccepted` / the SetError handling in the engine.
*/
export type UploadAction<TObj> =
/** Create `object` (which carries `uid`) in `collectionId`, after looking the uid up. */
| { kind: 'create'; uid: string; collectionId: string; object: Partial<TObj>; sendSchedulingMessages?: boolean }
/** Patch the object; never empty. */
| { kind: 'update'; id: string; patch: PatchObject; sendSchedulingMessages?: boolean }
/** Destroy the object (`id`), or whatever carries `uid` when the create's outcome is unknown. */
| { kind: 'destroy'; id: string | null; uid: string | null; sendSchedulingMessages?: boolean };
/**
* What to do with a dirty, new or deleted local item.
* - `clean`: nothing mapped changed (only STARRED, say): apply `ops` (they clear DIRTY behind an assert).
* - `claim`: a new item without a pending uid: apply `ops` (they write the uid), then re-read and plan again.
* - `upload`: send `actions`; `local` is what they were computed from.
* - `purge`: a deleted item that never reached the server: apply `ops`.
* - `revert`: the item may not be changed on the server (read-only): apply `ops` (rewrite from the shadow).
* - `skip`: poisoned and backed off, or not representable; `reason` goes to the report.
*/
export type UploadPlan<TObj> =
| { kind: 'clean'; ops: OpGroup }
| { kind: 'claim'; ops: OpGroup }
| { kind: 'upload'; actions: UploadAction<TObj>[] }
| { kind: 'purge'; ops: OpGroup }
| { kind: 'revert'; ops: OpGroup; refetch?: boolean }
| { kind: 'skip'; reason: string };
export interface DownloadPlan {
ops: OpGroup;
/** Units where both sides changed differently; the server won each. */
conflicts: number;
/** Local changes that survived the merge and still need an upload. */
stillDirty: boolean;
/** `none` when rows already match (an echo): the engine then writes nothing. */
effect: 'insert' | 'update' | 'none';
/** Rows this plan inserts, updates or deletes; reported as progress only when applied. */
writes: number;
}
/**
* After the server accepted an upload (the engine re-fetched `server`):
* - `ops` write identity, shadow and baselines and clear DIRTY, behind the
* same assert the upload was read under (VERSION / projection);
* - `keepDirtyOps` are applied instead when that assert fails (edited again
* meanwhile): identity, shadow, and the baselines of the uploaded units set
* to the values that were uploaded; rows and DIRTY untouched.
*/
export interface AcceptedPlan {
ops: OpGroup;
keepDirtyOps: OpGroup;
}
// ─── Contacts ──────────────────────────────────────────
export interface LocalDataRow {
id: number;
mimetype: string;
/** The row's mapped cells (data1..data14, is_primary, group_sourceid), as read. */
cells: Row;
/** DATA_SYNC1: entry key(s), a hint only (Fossify drops it). */
key: string | null;
/** DATA_SYNC2: photo hash. */
photoHash: string | null;
/** DATA_SYNC3 decoded: the mapped cells as stored after our last write. */
baseline: Row | null;
}
export interface LocalContact {
rawContactId: number;
/** `<jmapAccountId>/<cardId>`, null until created on the server. */
sourceId: string | null;
version: number;
dirty: boolean;
deleted: boolean;
readOnly: boolean;
/** SYNC1: collection keys. */
collections: string[];
/** SYNC2: the last server card, photos reduced to hashes. */
shadow: ContactCardWire | null;
/** SYNC3. */
pending: PendingCreate | null;
/** SYNC4. */
poison: PoisonMarker | null;
rows: LocalDataRow[];
}
export interface LocalGroup {
groupId: number;
sourceId: string | null;
version: number;
dirty: boolean;
deleted: boolean;
title: string | null;
shadow: ContactCardWire | null;
pending: PendingCreate | null;
poison: PoisonMarker | null;
}
export interface ContactsContext extends MapContext {
/** Selected collection keys this card is in (`<jmapAccountId>/<addressBookId>`). */
selectedCollections(card: ContactCardWire): string[];
/** True when none of the card's address books grants `mayWrite`. */
isReadOnly(card: ContactCardWire): boolean;
/** Whether a collection key is selected for sync on this device. */
isSelected(collectionKey: string): boolean;
/**
* Where a contact created on the device goes, fixed at its claim: the chosen
* book when it is selected and writable, else the first selected writable
* book of the personal account; null when there is none (the item is poisoned).
*/
createTarget(): string | null;
/** Base64 bytes of a photo `media` entry; blob-backed ones are fetched by the engine beforehand. */
photoBytes(entry: { uri?: string; blobId?: string; mediaType?: string }): string | null;
/**
* The device's photo of a raw contact as a JPEG (longer side ≤ 512 px, base64), read by the engine
* beforehand for every dirty contact that has a photo row; null when there is none.
*/
devicePhoto(rawContactId: number): string | null;
/** Display name of a card with this uid among the synced ones (Relation names). */
nameForUid(uid: string): string | null;
/** Group rows by SOURCE_ID, for memberships. */
groupRowIdBySourceId(sourceId: string): number | null;
}
export interface ContactsPlanner {
readonly rawContactColumns: readonly string[];
readonly dataColumns: readonly string[];
readonly groupColumns: readonly string[];
decodeContact(rawContact: Row, data: Row[]): LocalContact;
decodeGroup(group: Row): LocalGroup;
/**
* A server individual/org card into a new, clean or dirty local contact. A
* local item without identity whose pending uid is the card's uid (our own
* create whose identity was never written) is adopted: identity and shadow
* are written, DIRTY stays, local changes are merged.
*/
planDownload(card: ContactCardWire, local: LocalContact | null, ctx: ContactsContext): DownloadPlan;
/** The server destroyed the card, or it left every selected address book. */
planLocalDelete(local: LocalContact): OpGroup;
/** A clean contact whose rows drifted from their baselines (provider normalisation): rewrite the baselines. */
planBaselineHeal(local: LocalContact): OpGroup | null;
planUpload(local: LocalContact, ctx: ContactsContext): UploadPlan<ContactCardWire>;
planAccepted(local: LocalContact, server: ContactCardWire, ctx: ContactsContext): AcceptedPlan;
/** Group cards (`kind: "group"`) ↔ Groups rows. */
planGroupDownload(card: ContactCardWire, local: LocalGroup | null, ctx: ContactsContext): DownloadPlan;
planGroupLocalDelete(local: LocalGroup): OpGroup;
planGroupUpload(local: LocalGroup, ctx: ContactsContext): UploadPlan<ContactCardWire>;
planGroupAccepted(local: LocalGroup, server: ContactCardWire, ctx: ContactsContext): AcceptedPlan;
/**
* Membership edits made on the device (GroupMembership rows of dirty
* contacts against the group cards' `members`) as patches of group cards.
* Called after contacts were uploaded, so new contacts have their uid.
*/
planMembershipUploads(contacts: LocalContact[], groups: LocalGroup[], ctx: ContactsContext): UploadAction<ContactCardWire>[];
}
// ─── Calendar ──────────────────────────────────────────
export interface LocalAttendee {
id: number;
cells: Row;
}
export interface LocalReminder {
id: number;
cells: Row;
}
/** Baseline of an event row: its mapped columns, attendees and reminders as stored (SYNC_DATA4). */
export interface EventBaseline {
cells: Row;
attendees: Row[];
reminders: Row[];
}
export interface LocalEventRow {
eventId: number;
calendarRowId: number;
/** `_SYNC_ID`: `<acct>/<id>`, `<acct>/<id>#<rid>` (exception), `~pending/<uid>`, or null. */
syncId: string | null;
dirty: boolean;
deleted: boolean;
/** The mapped event columns as read (title, dtstart, rrule, status, …). */
cells: Row;
attendees: LocalAttendee[];
reminders: LocalReminder[];
baseline: EventBaseline | null;
/** SYNC_DATA3. */
pending: PendingCreate | null;
/** SYNC_DATA5. */
poison: PoisonMarker | null;
/** Events.MUTATORS: packages that changed the row since our last write (lossy editors, see the design). */
mutators: string | null;
}
export interface LocalException extends LocalEventRow {
/** SYNC_DATA2 (our rows), else derived from ORIGINAL_INSTANCE_TIME by the planner. */
recurrenceId: string | null;
originalInstanceTime: number | null;
}
export interface LocalEvent extends LocalEventRow {
/** SYNC_DATA1: the last server event, with its overrides. */
shadow: CalendarEventWire | null;
/** Rows whose ORIGINAL_SYNC_ID / ORIGINAL_ID point at this master. */
exceptions: LocalException[];
/**
* Set by `decodeEvents` when another master shares this `_SYNC_ID` (a
* CONTENT_EXCEPTION_URI split): `clone` on the newer row, which uploads as a
* new event; `source` on the older one, whose rule uploads as it stands.
*/
split?: 'clone' | 'source';
}
export interface LocalCalendar {
calendarRowId: number;
/** `<acct>/<calendarId>`. */
syncId: string | null;
cells: Row;
/** CAL_SYNC2 (CAL_SYNC1 stays unused: CalendarProvider sends it as a sync extra). */
shadow: CalendarLike | null;
/** CAL_SYNC3. */
flags: { readOnly?: 'rights' | 'subscription'; taskOnly?: boolean } | null;
}
export interface CalendarContext extends MapContext {
/** The device zone floating events are written in. */
deviceZone: string;
/** `OWNER_ACCOUNT` of our calendars: the user's main calendar address, lowercase, no `mailto:`. */
ownerAccount: string;
/** Every address of the user ("me"), lowercase, no `mailto:`. */
selfAddresses: string[];
reminderOwner: ReminderOwner;
/** Server calendars by id (for default alerts and rights). */
calendar(calendarId: string): CalendarLike | undefined;
/** Android calendar row of a server calendar of `jmapAccountId`, if synced. */
calendarRowId(calendarId: string): number | null;
/** Server calendar id of an Android calendar row, if it is one of ours. */
calendarIdOfRow(calendarRowId: number): { jmapAccountId: string; calendarId: string } | null;
/** Read-only on the device: no write rights, or a subscribed iCal feed. */
isReadOnly(calendarId: string): boolean;
/** Whether a collection key is selected for sync on this device. */
isSelected(collectionKey: string): boolean;
}
export interface CalendarPlanner {
readonly calendarColumns: readonly string[];
readonly eventColumns: readonly string[];
readonly attendeeColumns: readonly string[];
readonly reminderColumns: readonly string[];
decodeCalendar(row: Row): LocalCalendar;
/** Groups exception rows under their masters; orphan exceptions come back as masters with `deleted` untouched. */
decodeEvents(events: Row[], attendees: Row[], reminders: Row[]): LocalEvent[];
/**
* Calendar rows for the selected server calendars: inserts, updates (name,
* colour, access, reminders allowed), deletes for deselected ones. The
* engine uploads dirty events of calendars to be deleted first.
*/
planCalendars(
selected: Array<{ jmapAccountId: string; calendar: CalendarLike; readOnly: boolean; accountName: string }>,
local: LocalCalendar[],
ctx: Omit<CalendarContext, 'jmapAccountId'>,
): { groups: OpGroup[]; deleteCalendarRows: number[] };
/** A server event (never a Task) into a new, clean or dirty local event with its exceptions; adopts a pending local event by uid like the contacts planner. */
planDownload(event: CalendarEventWire, local: LocalEvent | null, ctx: CalendarContext): DownloadPlan;
/** Deletes the master and its exception rows (a sync-adapter delete does not cascade). */
planLocalDelete(local: LocalEvent): OpGroup;
planBaselineHeal(local: LocalEvent): OpGroup | null;
/** May return several actions: a "this and following" split is a rule patch plus a create. */
planUpload(local: LocalEvent, ctx: CalendarContext): UploadPlan<CalendarEventWire>;
planAccepted(local: LocalEvent, server: CalendarEventWire, ctx: CalendarContext): AcceptedPlan;
/**
* Deleted and new masters of one run that are one edit (Etar moves an event
* to another calendar, or turns a series into a single event, by deleting
* and re-inserting it): each pair uploads as a patch of the existing object;
* `ops` move the identity and exceptions to the new row and purge the old one
* once the patch is accepted.
*/
planPairs(
deleted: LocalEvent[],
fresh: LocalEvent[],
ctx: CalendarContext,
): Array<{ deleted: LocalEvent; fresh: LocalEvent; actions: UploadAction<CalendarEventWire>[]; ops: OpGroup }>;
/** Floating events of a clean master re-written after the device zone changed. */
planZoneChange(local: LocalEvent, previousZone: string, ctx: CalendarContext): OpGroup | null;
/** Reminder rows re-written after the reminder owner changed (no server writes). */
planReminderOwnerChange(local: LocalEvent, ctx: CalendarContext): OpGroup | null;
}
/** Re-exported so engine code imports one module. */
export type { AddressBookLike, CalendarLike, ContactCardWire, CalendarEventWire };
+65
View File
@@ -322,6 +322,12 @@ export interface DeviceSyncNativeModule {
/** Hands the report to the waiting adapter. Resolves false for an unknown or finished run. */
finishRun(runId: string, reportJson: string): Promise<boolean>;
isRunCancelled(runId: string): Promise<boolean>;
/**
* Routes for the native push router: `{ [jmapAccountId]: [{ accountName,
* authorities }] }`, JSON. A push whose `changed` map names one of these
* accounts with a contact or calendar type requests those syncs.
*/
setPushRoutes(routesJson: string): Promise<void>;
/** `ProviderQuery` JSON in, `ProviderRows` JSON out. */
query(name: string, authority: Authority, queryJson: string): Promise<string>;
@@ -336,6 +342,65 @@ export interface DeviceSyncNativeModule {
removeListeners(count: number): void;
}
// ─── The JMAP side ─────────────────────────────────────
/** A method call or response as on the wire: `[name, arguments, callId]`. */
export type JmapInvocation = [name: string, args: Record<string, unknown>, callId: string];
export interface JmapResponse {
methodResponses: JmapInvocation[];
sessionState?: string;
}
export interface JmapAccountView {
name: string;
isPersonal: boolean;
isReadOnly: boolean;
accountCapabilities: Record<string, unknown>;
}
export interface JmapSessionView {
username: string;
accounts: Record<string, JmapAccountView>;
primaryAccounts: Record<string, string>;
capabilities: Record<string, unknown>;
}
/** The core capability's limits the engine sizes its requests by. */
export interface JmapCoreLimits {
maxObjectsInGet: number;
maxObjectsInSet: number;
maxCallsInRequest: number;
maxConcurrentRequests: number;
maxSizeRequest: number;
}
/**
* The engine's view of one registry account's JMAP server: a detached
* JMAPClient on the device (src/device-sync/jmap/client-port.ts), the fake
* server in tests. Transport failures throw an Error whose `name` is
* `AuthenticationError`, `NetworkError`, `RequestTimeoutError` or
* `RateLimitError` (with `retryAfterMs`), as jmap-client.ts does; after a
* `NetworkError` or `RequestTimeoutError` the server may have applied the
* request. Any other thrown Error (jmap-client.ts throws plain ones for a
* non-OK HTTP status, e.g. `JMAP request failed: 503 - …`, and for a body that
* is not JSON) means no usable response and counts as `io`; a 400 for a limit
* is an engine bug. Method-level errors come back as
* `['error', { type }, callId]` responses.
*/
export interface JmapPort {
session(): JmapSessionView;
/** The session's core limits, with the RFC 8620 fallbacks where the server gives none. */
limits(): JmapCoreLimits;
request(calls: JmapInvocation[], using: string[]): Promise<JmapResponse>;
/** Bytes of a blob-backed `media` entry (other servers; Stalwart keeps photos as `data:` URIs). */
downloadBlob(accountId: string, blobId: string, type?: string): Promise<Uint8Array>;
}
export const JMAP_CORE = 'urn:ietf:params:jmap:core';
export const JMAP_CONTACTS = 'urn:ietf:params:jmap:contacts';
export const JMAP_CALENDARS = 'urn:ietf:params:jmap:calendars';
// ─── App-side preferences the engine reads ─────────────
/** Collections are keyed `<jmapAccountId>/<collectionId>` everywhere. */
+83
View File
@@ -0,0 +1,83 @@
/**
* JMAP objects as the device sync engine sees them: exactly as Stalwart sends
* and accepts them, without the client-only fields the app's stores add.
* Stalwart speaks JSCalendar 2.0, so `recurrenceRule` is singular on the wire
* (plural `recurrenceRules` and `excludedRecurrenceRule(s)` are rejected), and
* override values are PatchObjects.
*/
import type {
AddressBook,
Alert,
Calendar,
CalendarEvent,
ContactCard,
RecurrenceRule,
} from '../api/types';
type ContactClientOnly = 'accountId' | 'accountName' | 'isShared' | 'originalId';
type EventClientOnly =
| 'recurrenceRules'
| 'excludedRecurrenceRules'
| 'recurrenceOverrides'
| 'originalId'
| 'originalCalendarIds'
| 'localAccountId'
| 'accountId'
| 'isShared'
| 'baseEventId'
| 'utcStart'
| 'utcEnd';
type CalendarClientOnly = 'accountId' | 'originalId' | 'isShared' | 'colorIsLocalOverride';
/** A ContactCard (RFC 9553) on the wire. Unknown properties are kept as they came. */
export type ContactCardWire = Omit<ContactCard, ContactClientOnly> & {
'@type'?: string;
version?: string;
[property: string]: unknown;
};
/** A CalendarEvent (JSCalendar 2.0 as Stalwart speaks it) on the wire. */
export type CalendarEventWire = Omit<CalendarEvent, EventClientOnly> & {
recurrenceRule?: RecurrenceRule | null;
/** Keys are LocalDateTimes in the event's zone; values are PatchObjects against the master. */
recurrenceOverrides?: Record<string, Record<string, unknown>> | null;
[property: string]: unknown;
};
export type AddressBookLike = Omit<AddressBook, ContactClientOnly>;
export type CalendarLike = Omit<Calendar, CalendarClientOnly> & {
defaultAlertsWithTime?: Record<string, Alert> | null;
defaultAlertsWithoutTime?: Record<string, Alert> | null;
timeZone?: string | null;
includeInAvailability?: string;
};
/** Every ContactCard property the engine asks `/get` for (explicit, see docs/device-sync.md). */
export const CONTACT_CARD_PROPERTIES = [
'id', 'uid', 'addressBookIds', 'kind', 'language', 'name', 'nicknames', 'emails', 'phones',
'onlineServices', 'preferredLanguages', 'organizations', 'titles', 'addresses', 'anniversaries',
'personalInfo', 'notes', 'media', 'relatedTo', 'links', 'cryptoKeys', 'directories', 'keywords',
'members', 'speakToAs', 'calendars', 'schedulingAddresses', 'localizations', 'prodId', 'created',
'updated', '@type', 'version',
] as const;
/** Every CalendarEvent property the engine asks `/get` for; `properties: null` would drop `useDefaultAlerts`. */
export const CALENDAR_EVENT_PROPERTIES = [
'id', 'uid', '@type', 'calendarIds', 'isDraft', 'title', 'description', 'descriptionContentType',
'start', 'duration', 'timeZone', 'endTimeZone', 'showWithoutTime', 'status', 'freeBusyStatus',
'privacy', 'color', 'keywords', 'categories', 'priority', 'locale', 'sequence', 'method',
'locations', 'mainLocationId', 'virtualLocations', 'links', 'relatedTo', 'participants',
'organizerCalendarAddress', 'replyTo', 'sentBy', 'recurrenceRule', 'recurrenceOverrides',
'recurrenceId', 'recurrenceIdTimeZone', 'alerts', 'useDefaultAlerts', 'created', 'updated',
'prodId', 'progress', 'due', 'estimatedDuration', 'percentComplete',
] as const;
export const ADDRESS_BOOK_PROPERTIES = [
'id', 'name', 'description', 'sortOrder', 'isDefault', 'isSubscribed', 'myRights',
] as const;
export const CALENDAR_PROPERTIES = [
'id', 'name', 'description', 'color', 'sortOrder', 'isSubscribed', 'isVisible', 'isDefault',
'includeInAvailability', 'defaultAlertsWithTime', 'defaultAlertsWithoutTime', 'timeZone', 'myRights',
] as const;
+139
View File
@@ -0,0 +1,139 @@
/**
* Device sync preferences and status per app account (Android only, #34;
* docs/device-sync.md, "App integration"). Keyed by `AccountEntry.id`.
*
* Whether sync is on is not stored here: Android's sync settings
* (`getSyncAutomatically` per authority) are the source of truth, so the
* toggles in Settings → Accounts and the app always agree.
*
* Persisted under `device-sync:v1` without the write delay the caches use: a
* headless sync run may be the only thing alive, and its status must survive
* the process. Not part of the settings export. A headless run reads and
* writes it only after `waitForDeviceSyncHydration()`: zustand 5 persists on
* every `set()`, even before hydration, and would overwrite the stored state.
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { createPersistStorage } from './persist-storage';
import type {
AccountSyncPrefs,
Authority,
CollectionKey,
ReminderOwner,
RunStatus,
} from '../device-sync/types';
/** Periodic sync interval when the user has not picked one: push covers freshness. */
export const DEFAULT_INTERVAL_SECONDS = 3600;
export interface AccountDeviceSync {
/** Explicit choices only; unlisted collections follow the default (personal on, shared off). */
contactsSelection: Record<CollectionKey, boolean>;
calendarSelection: Record<CollectionKey, boolean>;
/** Where contacts created on the device go; unset = the server's default address book. */
newContactsAddressBook?: CollectionKey;
/** Who reminds the user of synced events; null until asked. */
reminderOwner: ReminderOwner | null;
/** Periodic sync per authority in seconds; 0 = manual only. */
intervalSeconds: Partial<Record<Authority, number>>;
lastRun: Partial<Record<Authority, RunStatus>>;
/** The Android account name this app account uses. */
androidAccountName?: string;
/** Removed in Android Settings: the app shows sync as off and never recreates the account on its own. */
removedInAndroidSettings?: boolean;
/**
* Latest item states the engine synced, per JMAP account and type
* (`ContactCard`, `CalendarEvent`, …). A StateChange naming the same state is
* our own echo and triggers no sync.
*/
knownStates?: Record<string, Record<string, string>>;
}
export function emptyAccountDeviceSync(): AccountDeviceSync {
return {
contactsSelection: {},
calendarSelection: {},
reminderOwner: null,
intervalSeconds: {},
lastRun: {},
};
}
interface DeviceSyncState {
accounts: Record<string, AccountDeviceSync>;
update: (registryId: string, patch: Partial<AccountDeviceSync>) => void;
recordRunStatus: (registryId: string, authority: Authority, status: RunStatus) => void;
recordKnownState: (registryId: string, jmapAccountId: string, type: string, state: string) => void;
forget: (registryId: string) => void;
}
export const useDeviceSyncStore = create<DeviceSyncState>()(
persist(
(set) => ({
accounts: {},
update: (registryId, patch) =>
set((s) => ({
accounts: {
...s.accounts,
[registryId]: { ...(s.accounts[registryId] ?? emptyAccountDeviceSync()), ...patch },
},
})),
recordRunStatus: (registryId, authority, status) =>
set((s) => {
const current = s.accounts[registryId] ?? emptyAccountDeviceSync();
return {
accounts: {
...s.accounts,
[registryId]: { ...current, lastRun: { ...current.lastRun, [authority]: status } },
},
};
}),
recordKnownState: (registryId, jmapAccountId, type, state) =>
set((s) => {
const current = s.accounts[registryId] ?? emptyAccountDeviceSync();
const known = current.knownStates ?? {};
return {
accounts: {
...s.accounts,
[registryId]: {
...current,
knownStates: { ...known, [jmapAccountId]: { ...(known[jmapAccountId] ?? {}), [type]: state } },
},
},
};
}),
forget: (registryId) =>
set((s) => {
const { [registryId]: _gone, ...rest } = s.accounts;
return { accounts: rest };
}),
}),
{
name: 'device-sync:v1',
storage: createPersistStorage({ writeDelayMs: 0 }),
partialize: (state) => ({ accounts: state.accounts }),
},
),
);
/** Resolves once the persisted state has been read (at once when it already was). */
export function waitForDeviceSyncHydration(): Promise<void> {
if (useDeviceSyncStore.persist.hasHydrated()) return Promise.resolve();
return new Promise((resolve) => {
const unsubscribe = useDeviceSyncStore.persist.onFinishHydration(() => {
unsubscribe();
resolve();
});
});
}
/** The engine's view of one account's preferences. */
export function accountSyncPrefs(registryId: string): AccountSyncPrefs {
const a = useDeviceSyncStore.getState().accounts[registryId] ?? emptyAccountDeviceSync();
return {
contactsSelection: a.contactsSelection,
calendarSelection: a.calendarSelection,
newContactsAddressBook: a.newContactsAddressBook,
reminderOwner: a.reminderOwner ?? 'device',
};
}