mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
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:
@@ -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
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user