diff --git a/src/device-sync/__tests__/common.test.ts b/src/device-sync/__tests__/common.test.ts new file mode 100644 index 00000000..43d10b6b --- /dev/null +++ b/src/device-sync/__tests__/common.test.ts @@ -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 = { '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'); + }); +}); diff --git a/src/device-sync/__tests__/fake-jmap-server.test.ts b/src/device-sync/__tests__/fake-jmap-server.test.ts new file mode 100644 index 00000000..ceae4551 --- /dev/null +++ b/src/device-sync/__tests__/fake-jmap-server.test.ts @@ -0,0 +1,1155 @@ +// Tests for the fake JMAP server the device-sync engine tests run against: +// it has to answer like Stalwart v0.16.23 wherever the engine depends on it, +// so its own behaviour is pinned here. + +import { describe, expect, it } from 'vitest'; +import { JMAP_CALENDARS, JMAP_CONTACTS, JMAP_CORE, type JmapInvocation, type JmapPort } from '../types'; +import { FakeJmapServer, uuid5 } from './fakes/fake-jmap-server'; + +const USING = [JMAP_CORE, JMAP_CONTACTS, JMAP_CALENDARS]; + +type Args = Record; + +/** One method call in its own request: the response name and arguments. */ +async function call(port: JmapPort, name: string, args: Args): Promise<[string, Args]> { + const { methodResponses } = await port.request([[name, args, '0']], USING); + return [methodResponses[0][0], methodResponses[0][1] as Args]; +} + +function setup() { + const server = new FakeJmapServer(); + server.addAccount('a', { name: 'alice@example.com' }); + const book = server.addAddressBook('a', { name: 'Personal' }); + const cal = server.addCalendar('a', { name: 'Calendar' }); + return { server, port: server.port(), book, cal }; +} + +describe('FakeJmapServer', () => { + describe('session and ids', () => { + it('lists the accounts with their capabilities and the primary accounts', () => { + const server = new FakeJmapServer(); + server.addAccount('a', { name: 'alice@example.com' }); + server.addAccount('team', { name: 'Team', isPersonal: false, capabilities: ['contacts'] }); + + const session = server.port().session(); + + expect(session.username).toBe('alice@example.com'); + expect(session.primaryAccounts).toEqual({ [JMAP_CONTACTS]: 'a', [JMAP_CALENDARS]: 'a' }); + expect(session.accounts.team).toMatchObject({ name: 'Team', isPersonal: false, isReadOnly: false }); + expect(Object.keys(session.accounts.team.accountCapabilities)).toEqual([JMAP_CONTACTS]); + expect(Object.keys(session.accounts.a.accountCapabilities)).toEqual([JMAP_CONTACTS, JMAP_CALENDARS]); + expect(session.capabilities[JMAP_CORE]).toMatchObject({ + maxObjectsInGet: 500, + maxObjectsInSet: 500, + maxCallsInRequest: 16, + }); + expect(server.port('alice').session().username).toBe('alice'); + }); + + it('mints short ids per account and type and never reuses one', async () => { + const server = new FakeJmapServer(); + server.addAccount('a', { name: 'alice@example.com' }); + server.addAccount('b', { name: 'bob@example.com' }); + + expect(server.addAddressBook('a', { name: 'A' })).toBe('ab1'); + expect(server.addAddressBook('b', { name: 'B' })).toBe('ab1'); + expect(server.addCard('a', { uid: 'u1' })).toBe('c1'); + expect(server.addCard('b', { uid: 'u1' })).toBe('c1'); + const [, set] = await call(server.port(), 'ContactCard/set', { + accountId: 'a', + create: { n: { addressBookIds: { ab1: true } } }, + }); + expect(set.created).toEqual({ n: { id: 'c2' } }); + + server.serverDestroy('ContactCard', 'a', 'c2'); + expect(() => server.addCard('a', { id: 'c2' })).toThrow(/never reused/); + }); + + it('stamps with an injected clock', () => { + const t = Date.UTC(2026, 8, 27, 12, 0, 0); + const server = new FakeJmapServer({ now: () => t }); + server.addAccount('a', { name: 'alice@example.com' }); + server.addCalendar('a', { name: 'C' }); + + const id = server.addEvent('a', { title: 'x' }); + + expect(server.get('CalendarEvent', 'a', id)?.updated).toBe('2026-09-27T12:00:00Z'); + expect(() => server.setTime(0)).toThrow(); + }); + }); + + describe('AddressBook and Calendar', () => { + it('returns containers with Stalwart defaults and a computed isDefault', async () => { + const { server, port, book, cal } = setup(); + const shared = server.addAddressBook('a', { + name: 'Shared', + myRights: { mayRead: true, mayWrite: false, mayShare: false, mayDelete: false }, + }); + + const [, books] = await call(port, 'AddressBook/get', { accountId: 'a', ids: null }); + const [, cals] = await call(port, 'Calendar/get', { + accountId: 'a', + ids: [cal], + properties: ['name', 'defaultAlertsWithTime', 'nope'], + }); + + expect(books.list).toEqual([ + { + id: book, + name: 'Personal', + description: null, + sortOrder: 0, + isDefault: true, + isSubscribed: true, + shareWith: null, + myRights: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true }, + }, + expect.objectContaining({ id: shared, isDefault: false }), + ]); + expect(cals.list).toEqual([{ id: cal, name: 'Calendar', defaultAlertsWithTime: {}, nope: null }]); + }); + + it('forbids writes into an address book without mayWrite', async () => { + const { server, port } = setup(); + const shared = server.addAddressBook('a', { name: 'Shared', myRights: { mayRead: true, mayWrite: false } }); + const inShared = server.addCard('a', { uid: 'u', addressBookIds: { [shared]: true } }); + + const [, set] = await call(port, 'ContactCard/set', { + accountId: 'a', + create: { n: { addressBookIds: { [shared]: true } } }, + update: { [inShared]: { kind: 'org' } }, + }); + + expect(set.notCreated.n).toEqual({ + type: 'forbidden', + description: `You are not allowed to add contacts to address book ${shared}.`, + }); + expect(set.notUpdated[inShared]).toEqual({ + type: 'forbidden', + description: `You are not allowed to modify address book ${shared}.`, + }); + }); + }); + + describe('ContactCard', () => { + it('answers a create with only the id and returns the card normalised like Stalwart', async () => { + const { port, book } = setup(); + + const [, set] = await call(port, 'ContactCard/set', { + accountId: 'a', + create: { + new1: { + addressBookIds: { [book]: true }, + uid: 'urn:uuid:1', + name: { components: [{ kind: 'given', value: 'Jane' }, { kind: 'surname', value: 'Doe' }] }, + addresses: { home: { components: [{ kind: 'locality', value: 'Berlin' }], contexts: { private: true } } }, + titles: { t1: { name: 'CEO' } }, + anniversaries: { b: { kind: 'birth', date: { year: 1990, month: 5, day: 1 } } }, + media: { p: { kind: 'photo', uri: 'data:image/jpeg;base64,AAAA' } }, + }, + }, + }); + const [, get] = await call(port, 'ContactCard/get', { accountId: 'a', ids: ['c1'] }); + const [, update] = await call(port, 'ContactCard/set', { + accountId: 'a', + update: { c1: { 'name/full': 'J. Doe' } }, + }); + + expect(set.created).toEqual({ new1: { id: 'c1' } }); + expect(get.list).toEqual([ + { + id: 'c1', + addressBookIds: { [book]: true }, + '@type': 'Card', + version: '1.0', + uid: 'urn:uuid:1', + name: { + components: [{ kind: 'given', value: 'Jane' }, { kind: 'surname', value: 'Doe' }], + isOrdered: true, + full: 'Jane Doe', + }, + addresses: { + home: { components: [{ kind: 'locality', value: 'Berlin' }], contexts: { private: true }, isOrdered: true }, + }, + titles: { t1: { name: 'CEO', kind: 'title' } }, + anniversaries: { b: { kind: 'birth', date: { '@type': 'PartialDate', year: 1990, month: 5, day: 1 } } }, + media: { p: { kind: 'photo', uri: 'data:image/jpeg;base64,AAAA' } }, + }, + ]); + expect(update).toMatchObject({ updated: { c1: null } }); + expect(update).not.toHaveProperty('created'); + }); + + it('returns only the listed properties, plus the id', async () => { + const { server, port, book } = setup(); + const id = server.addCard('a', { uid: 'u1', kind: 'individual' }); + + const [, get] = await call(port, 'ContactCard/get', { + accountId: 'a', + ids: [id, 'missing'], + properties: ['uid', 'addressBookIds'], + }); + + expect(get.list).toEqual([{ id, uid: 'u1', addressBookIds: { [book]: true } }]); + expect(get.notFound).toEqual(['missing']); + expect(get.state).toBe(server.state('ContactCard', 'a')); + }); + + it('keeps map keys as written, slashes included, and resolves ~1 and ~0 in patch paths', async () => { + const { server, port } = setup(); + const id = server.addCard('a', { + emails: { + 'x/y': { address: 'a@example.com' }, + 'm~n': { address: 'b@example.com' }, + e3: { address: 'c@example.com' }, + }, + }); + + const [, set] = await call(port, 'ContactCard/set', { + accountId: 'a', + update: { [id]: { 'emails/x~1y/label': 'slash', 'emails/m~0n/label': 'tilde' } }, + }); + + expect(set.updated).toEqual({ [id]: null }); + expect(server.get('ContactCard', 'a', id)?.emails).toEqual({ + 'x/y': { address: 'a@example.com', label: 'slash' }, + 'm~n': { address: 'b@example.com', label: 'tilde' }, + e3: { address: 'c@example.com' }, + }); + }); + + it('fails a patch below a missing parent, also once the last entry of a map is gone', async () => { + const { server, port } = setup(); + const id = server.addCard('a', { uid: 'u1' }); + const update = async (patch: Args) => + (await call(port, 'ContactCard/set', { accountId: 'a', update: { [id]: patch } }))[1]; + + const first = await update({ 'emails/e1': { address: 'a@example.com' } }); + await update({ emails: { e1: { address: 'a@example.com' } } }); + await update({ emails: {} }); + const cardWithoutEmails = server.get('ContactCard', 'a', id); + const again = await update({ 'emails/e2/address': 'b@example.com' }); + const wildcard = await update({ 'emails/*/label': 'x' }); + + expect(first.notUpdated).toEqual({ + [id]: { type: 'invalidProperties', properties: ['emails/e1'], description: 'Patch operation failed.' }, + }); + expect(cardWithoutEmails).not.toHaveProperty('emails'); + expect(again.notUpdated[id]).toMatchObject({ properties: ['emails/e2/address'] }); + expect(wildcard.notUpdated[id]).toMatchObject({ description: 'Patch operation failed.' }); + }); + + it('reads an all-digit segment as an index that can replace a member but never add one', async () => { + const { server, port } = setup(); + const id = server.addCard('a', { + name: { components: [{ kind: 'given', value: 'Jane' }], full: 'Jane' }, + emails: { '7': { address: 'seven@example.com' } }, + }); + const update = async (patch: Args) => + (await call(port, 'ContactCard/set', { accountId: 'a', update: { [id]: patch } }))[1]; + + const replaced = await update({ 'name/components/0/value': 'Janet', 'emails/7/label': 'lucky' }); + const added = await update({ 'emails/8': { address: 'eight@example.com' } }); + const escaped = await update({ 'emails/a\\/b': { address: 'ab@example.com' } }); + + expect(replaced.updated).toEqual({ [id]: null }); + expect(added.notUpdated[id]).toMatchObject({ properties: ['emails/8'], description: 'Patch operation failed.' }); + expect(escaped.updated).toEqual({ [id]: null }); + expect(server.get('ContactCard', 'a', id)).toMatchObject({ + name: { components: [{ kind: 'given', value: 'Janet' }] }, + emails: { '7': { address: 'seven@example.com', label: 'lucky' }, 'a/b': { address: 'ab@example.com' } }, + }); + }); + + it('deletes a map entry or a property set to null', async () => { + const { server, port } = setup(); + const id = server.addCard('a', { + emails: { e1: { address: 'a@example.com' }, e2: { address: 'b@example.com' } }, + notes: { n1: { note: 'hi' } }, + }); + + await call(port, 'ContactCard/set', { accountId: 'a', update: { [id]: { 'emails/e1': null, notes: null } } }); + + const card = server.get('ContactCard', 'a', id); + expect(card?.emails).toEqual({ e2: { address: 'b@example.com' } }); + expect(card).not.toHaveProperty('notes'); + }); + + it('derives name.full once and keeps it through component patches until full is nulled', async () => { + const { server, port } = setup(); + const id = server.addCard('a', { + name: { components: [{ kind: 'given', value: 'Jane' }, { kind: 'surname', value: 'Doe' }] }, + }); + const separated = server.addCard('a', { + name: { + components: [ + { kind: 'surname', value: 'Doe' }, + { kind: 'separator', value: ', ' }, + { kind: 'given', value: 'Jane' }, + ], + }, + }); + const renamed = [{ kind: 'given', value: 'Janet' }, { kind: 'surname', value: 'Doe' }]; + const name = () => server.get('ContactCard', 'a', id)?.name; + + const derived = name(); + await call(port, 'ContactCard/set', { accountId: 'a', update: { [id]: { 'name/components': renamed } } }); + const stale = name(); + await call(port, 'ContactCard/set', { accountId: 'a', update: { [id]: { 'name/full': null } } }); + + expect(derived).toMatchObject({ full: 'Jane Doe', isOrdered: true }); + expect(stale).toMatchObject({ full: 'Jane Doe', components: renamed }); + expect(name()).toMatchObject({ full: 'Janet Doe' }); + expect(server.get('ContactCard', 'a', separated)?.name).toMatchObject({ full: 'Doe, Jane' }); + }); + + it('rejects an unknown top-level property and names it', async () => { + const { port, book } = setup(); + + const [, set] = await call(port, 'ContactCard/set', { + accountId: 'a', + create: { n: { addressBookIds: { [book]: true }, 'example.com:foo': 1 } }, + }); + + expect(set.notCreated.n).toEqual({ + type: 'invalidProperties', + properties: ['example.com:foo'], + description: 'Invalid property.', + }); + }); + + it('refuses a duplicate uid in the same address book and names the existing card', async () => { + const { server, port, book } = setup(); + const work = server.addAddressBook('a', { name: 'Work' }); + const existing = server.addCard('a', { uid: 'u1' }); + + const [, set] = await call(port, 'ContactCard/set', { + accountId: 'a', + create: { + same: { addressBookIds: { [book]: true }, uid: 'u1' }, + elsewhere: { addressBookIds: { [work]: true }, uid: 'u1' }, + }, + }); + + expect(set.notCreated).toEqual({ + same: { + type: 'invalidProperties', + properties: ['uid'], + description: `Contact with UID u1 already exists with id ${existing}.`, + }, + }); + expect(Object.keys(set.created)).toEqual(['elsewhere']); + }); + + it('checks uids against what was committed before the call, as Stalwart does', async () => { + const { port, book } = setup(); + + const [, set] = await call(port, 'ContactCard/set', { + accountId: 'a', + create: { + one: { addressBookIds: { [book]: true }, uid: 'u1' }, + two: { addressBookIds: { [book]: true }, uid: 'u1' }, + }, + }); + + expect(Object.keys(set.created)).toEqual(['one', 'two']); + }); + + it('lets a uid be stripped but not changed or added', async () => { + const { server, port } = setup(); + const withUid = server.addCard('a', { uid: 'u1' }); + const without = server.addCard('a', {}); + + const [, refused] = await call(port, 'ContactCard/set', { + accountId: 'a', + update: { [withUid]: { uid: 'u2' }, [without]: { uid: 'u3' } }, + }); + const [, stripped] = await call(port, 'ContactCard/set', { accountId: 'a', update: { [withUid]: { uid: null } } }); + + expect(refused.notUpdated[withUid]).toEqual({ + type: 'invalidProperties', + properties: ['uid'], + description: 'You cannot change the UID of a contact.', + }); + expect(refused.notUpdated[without]).toMatchObject({ properties: ['uid'] }); + expect(stripped.updated).toEqual({ [withUid]: null }); + expect(server.get('ContactCard', 'a', withUid)).not.toHaveProperty('uid'); + }); + }); + + describe('CalendarEvent', () => { + it('stamps updated on every write, inside overrides too', async () => { + const { server, port, cal } = setup(); + server.setTime(Date.UTC(2026, 8, 27, 10, 0, 0)); + + const [, created] = await call(port, 'CalendarEvent/set', { + accountId: 'a', + create: { + e: { + calendarIds: { [cal]: true }, + uid: 'ev-1', + title: 'Standup', + start: '2026-09-28T09:00:00', + timeZone: 'Europe/Berlin', + duration: 'PT15M', + recurrenceRule: { frequency: 'daily' }, + updated: '2000-01-01T00:00:00Z', + }, + }, + }); + const id = created.created.e.id; + const afterCreate = server.get('CalendarEvent', 'a', id); + server.advanceTime(60_000); + await call(port, 'CalendarEvent/set', { + accountId: 'a', + update: { + [id]: { + recurrenceOverrides: { + '2026-09-29T09:00:00': { title: 'Moved' }, + '2026-09-30T09:00:00': { excluded: true, title: 'ignored' }, + }, + }, + }, + }); + const afterOverride = server.get('CalendarEvent', 'a', id); + server.advanceTime(60_000); + await call(port, 'CalendarEvent/set', { accountId: 'a', update: { [id]: {} } }); + + expect(created.created.e).toEqual({ id }); + expect(afterCreate).toMatchObject({ + '@type': 'Event', + recurrenceRule: { frequency: 'daily' }, + updated: '2026-09-27T10:00:00Z', + }); + expect(afterOverride?.updated).toBe('2026-09-27T10:01:00Z'); + expect(afterOverride?.recurrenceOverrides).toEqual({ + '2026-09-29T09:00:00': { title: 'Moved', updated: '2026-09-27T10:01:00Z' }, + '2026-09-30T09:00:00': { excluded: true }, + }); + expect(server.get('CalendarEvent', 'a', id)?.updated).toBe('2026-09-27T10:02:00Z'); + }); + + it('leaves useDefaultAlerts out of properties: null and returns it when listed', async () => { + const { server, port, cal } = setup(); + const id = server.addEvent('a', { title: 'x', useDefaultAlerts: true, calendarIds: { [cal]: true } }); + + const [, all] = await call(port, 'CalendarEvent/get', { accountId: 'a', ids: [id], properties: null }); + const [, listed] = await call(port, 'CalendarEvent/get', { + accountId: 'a', + ids: [id], + properties: ['title', 'useDefaultAlerts'], + }); + + expect(all.list[0]).not.toHaveProperty('useDefaultAlerts'); + expect(all.list[0]).toMatchObject({ id, calendarIds: { [cal]: true }, isDraft: false, isOrigin: true, title: 'x' }); + expect(listed.list).toEqual([{ id, title: 'x', useDefaultAlerts: true }]); + }); + + it.each([ + ['recurrenceRules', [{ frequency: 'daily' }]], + ['excludedRecurrenceRule', { frequency: 'daily' }], + ['excludedRecurrenceRules', [{ frequency: 'daily' }]], + ['timeZones', {}], + ['progressUpdated', '2026-01-01T00:00:00Z'], + ['isOrigin', true], + ])('rejects %s on create and update', async (property, value) => { + const { server, port, cal } = setup(); + const id = server.addEvent('a', { title: 'x' }); + + const [, set] = await call(port, 'CalendarEvent/set', { + accountId: 'a', + create: { n: { calendarIds: { [cal]: true }, title: 'y', [property]: value } }, + update: { [id]: { [property]: value } }, + }); + + expect(set.notCreated.n).toMatchObject({ type: 'invalidProperties', properties: [property] }); + expect(set.notUpdated[id]).toMatchObject({ type: 'invalidProperties', properties: [property] }); + }); + + it('fails a recurrenceOverrides/ patch until the event has overrides', async () => { + const { server, port } = setup(); + const id = server.addEvent('a', { + title: 'x', + start: '2026-10-01T09:00:00', + recurrenceRule: { frequency: 'weekly' }, + }); + const pointer = 'recurrenceOverrides/2026-10-08T09:00:00'; + const update = async (patch: Args) => + (await call(port, 'CalendarEvent/set', { accountId: 'a', update: { [id]: patch } }))[1]; + + const refused = await update({ [pointer]: { excluded: true } }); + await update({ recurrenceOverrides: { '2026-10-08T09:00:00': { excluded: true } } }); + const accepted = await update({ 'recurrenceOverrides/2026-10-15T09:00:00': { excluded: true } }); + + expect(refused.notUpdated[id]).toEqual({ + type: 'invalidProperties', + properties: [pointer], + description: 'Patch operation failed.', + }); + expect(accepted.updated).toEqual({ [id]: null }); + expect(Object.keys(server.get('CalendarEvent', 'a', id)?.recurrenceOverrides as Args)).toEqual([ + '2026-10-08T09:00:00', + '2026-10-15T09:00:00', + ]); + }); + + it('refuses a duplicate uid anywhere in the account without naming the event', async () => { + const { server, port } = setup(); + const other = server.addCalendar('a', { name: 'Other' }); + server.addEvent('a', { uid: 'ev-1', title: 'x' }); + + const [, set] = await call(port, 'CalendarEvent/set', { + accountId: 'a', + create: { n: { calendarIds: { [other]: true }, uid: 'ev-1', title: 'y' } }, + }); + + expect(set.notCreated.n).toEqual({ + type: 'invalidProperties', + properties: ['uid'], + description: 'An event with UID ev-1 already exists.', + }); + }); + + it('generates a uid when a create has none', async () => { + const { server, port, cal } = setup(); + + const [, set] = await call(port, 'CalendarEvent/set', { + accountId: 'a', + create: { n: { calendarIds: { [cal]: true }, title: 'x' } }, + }); + + expect(server.get('CalendarEvent', 'a', set.created.n.id)?.uid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4/); + }); + + it('returns tasks with @type Task', async () => { + const { server, port } = setup(); + const id = server.addEvent('a', { '@type': 'Task', title: 'Pay rent', due: '2026-10-01T00:00:00' }); + + const [, get] = await call(port, 'CalendarEvent/get', { accountId: 'a', ids: [id], properties: ['@type', 'title'] }); + + expect(get.list).toEqual([{ id, '@type': 'Task', title: 'Pay rent' }]); + }); + + it('records sendSchedulingMessages without sending anything', async () => { + const { server, port, cal } = setup(); + const id = server.addEvent('a', { title: 'x' }); + + await call(port, 'CalendarEvent/set', { + accountId: 'a', + sendSchedulingMessages: true, + create: { n: { calendarIds: { [cal]: true }, title: 'y' } }, + update: { [id]: { title: 'z' } }, + }); + await call(port, 'CalendarEvent/set', { accountId: 'a', destroy: [id] }); + + expect(server.scheduling.map(({ op }) => op)).toEqual(['create', 'update']); + }); + + it('makes the account the organizer of an event created with attendees', async () => { + const { server, port, cal } = setup(); + + const [, set] = await call(port, 'CalendarEvent/set', { + accountId: 'a', + create: { + n: { + calendarIds: { [cal]: true }, + title: 'Meet', + participants: { p1: { calendarAddress: 'mailto:bob@example.com', roles: { attendee: true } } }, + }, + }, + }); + + const event = server.get('CalendarEvent', 'a', set.created.n.id); + expect(event?.organizerCalendarAddress).toBe('mailto:alice@example.com'); + expect(event?.participants).toEqual({ + p1: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.com', roles: { attendee: true } }, + [uuid5('mailto:alice@example.com')]: { + '@type': 'Participant', + calendarAddress: 'mailto:alice@example.com', + roles: { owner: true }, + }, + }); + }); + + it('copies the default alerts of the calendar into an event created with useDefaultAlerts', async () => { + const { server, port } = setup(); + const cal = server.addCalendar('a', { + name: 'With alerts', + defaultAlertsWithTime: { d1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } }, + }); + + const [, set] = await call(port, 'CalendarEvent/set', { + accountId: 'a', + create: { + n: { calendarIds: { [cal]: true }, title: 'x', useDefaultAlerts: true, alerts: { mine: { trigger: { offset: '-PT5M' } } } }, + }, + }); + + expect(server.get('CalendarEvent', 'a', set.created.n.id)?.alerts).toEqual({ + mine: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT5M' } }, + k2: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } }, + }); + }); + + it('returns at most maxObjectsInGet objects for ids: null', async () => { + const { server, port } = setup(); + for (let i = 0; i < 501; i++) server.addEvent('a', { title: `e${i}` }); + + const [, get] = await call(port, 'CalendarEvent/get', { accountId: 'a', ids: null, properties: ['title'] }); + + expect(get.list).toHaveLength(500); + expect(get.list[0]).toEqual({ id: 'ev1', title: 'e0' }); + }); + }); + + describe('/changes', () => { + it('compacts a window like Stalwart and reports a no-op patch as an update', async () => { + const { server, port } = setup(); + const kept = server.addCard('a', { uid: 'kept' }); + const doomed = server.addCard('a', { uid: 'doomed' }); + const since = server.state('ContactCard', 'a'); + + const created = server.addCard('a', { uid: 'created' }); + server.serverUpdate('ContactCard', 'a', created, { kind: 'individual' }); + const flash = server.addCard('a', { uid: 'flash' }); + await call(port, 'ContactCard/set', { accountId: 'a', destroy: [flash] }); + server.serverUpdate('ContactCard', 'a', doomed, { kind: 'individual' }); + await call(port, 'ContactCard/set', { accountId: 'a', destroy: [doomed] }); + await call(port, 'ContactCard/set', { accountId: 'a', update: { [kept]: {} } }); + const [, changes] = await call(port, 'ContactCard/changes', { accountId: 'a', sinceState: since }); + + expect(changes).toEqual({ + accountId: 'a', + oldState: since, + newState: server.state('ContactCard', 'a'), + hasMoreChanges: false, + created: [created], + updated: [kept], + destroyed: [doomed], + }); + }); + + it('reports a membership-only change as an update', async () => { + const { server, port, book } = setup(); + const work = server.addAddressBook('a', { name: 'Work' }); + const id = server.addCard('a', { uid: 'u' }); + const since = server.state('ContactCard', 'a'); + + await call(port, 'ContactCard/set', { accountId: 'a', update: { [id]: { [`addressBookIds/${work}`]: true } } }); + const [, changes] = await call(port, 'ContactCard/changes', { accountId: 'a', sinceState: since }); + + expect(changes.updated).toEqual([id]); + expect(server.get('ContactCard', 'a', id)?.addressBookIds).toEqual({ [book]: true, [work]: true }); + }); + + it('keeps address books and cards in one log but reports each type on its own', async () => { + const { server, port } = setup(); + const cardsSince = server.state('ContactCard', 'a'); + const booksSince = server.state('AddressBook', 'a'); + + const book2 = server.addAddressBook('a', { name: 'Second' }); + const card = server.addCard('a', { uid: 'u' }); + const [, books] = await call(port, 'AddressBook/changes', { accountId: 'a', sinceState: booksSince }); + const [, cards] = await call(port, 'ContactCard/changes', { accountId: 'a', sinceState: cardsSince }); + + expect(cardsSince).toBe('n'); + expect(booksSince).toMatch(/^s/); + expect(books).toMatchObject({ created: [book2], updated: [], destroyed: [], newState: server.state('AddressBook', 'a') }); + expect(cards).toMatchObject({ created: [card], updated: [], destroyed: [], newState: server.state('ContactCard', 'a') }); + expect(books.newState).not.toBe(cards.newState); + expect(server.state('CalendarEvent', 'a')).toBe('n'); + }); + + it('pages with maxChanges through intermediate states', async () => { + const { server, port } = setup(); + const ids = [1, 2, 3, 4, 5].map((n) => server.addCard('a', { uid: `u${n}` })); + + const pages: Args[] = []; + let state = 'n'; + let late = ''; + for (;;) { + const [, page] = await call(port, 'ContactCard/changes', { accountId: 'a', sinceState: state, maxChanges: 2 }); + pages.push(page); + state = page.newState; + if (!late) late = server.addCard('a', { uid: 'late' }); + if (!page.hasMoreChanges) break; + } + const [, rest] = await call(port, 'ContactCard/changes', { accountId: 'a', sinceState: state, maxChanges: 2 }); + + expect(pages.map((p) => p.created)).toEqual([ids.slice(0, 2), ids.slice(2, 4), ids.slice(4)]); + expect(pages.map((p) => p.hasMoreChanges)).toEqual([true, true, false]); + expect(pages[0].newState).toMatch(/^r/); + expect(pages[1].newState).toMatch(/^r/); + expect(pages[2].newState).toMatch(/^s/); + expect(rest).toMatchObject({ created: [late], hasMoreChanges: false, newState: server.state('ContactCard', 'a') }); + }); + + it('answers cannotCalculateChanges from a state older than the truncated log', async () => { + const { server, port } = setup(); + server.addCard('a', { uid: 'u1' }); + const old = server.state('ContactCard', 'a'); + server.addCard('a', { uid: 'u2' }); + const recent = server.state('ContactCard', 'a'); + const newest = server.addCard('a', { uid: 'u3' }); + const changesSince = async (sinceState: string) => + call(port, 'ContactCard/changes', { accountId: 'a', sinceState }); + + server.truncateChangeLog('a', 'contacts', 1); + const fromOld = await changesSince(old); + const fromRecent = await changesSince(recent); + server.truncateChangeLog('a', 'ContactCard'); + const fromRecentAgain = await changesSince(recent); + const current = server.state('ContactCard', 'a'); + const fromCurrent = await changesSince(current); + + expect(fromOld).toEqual(['error', { type: 'cannotCalculateChanges', description: 'Change log is truncated' }]); + expect(fromRecent[1].created).toEqual([newest]); + expect(fromRecentAgain[1].type).toBe('cannotCalculateChanges'); + expect(fromCurrent[1]).toMatchObject({ created: [], updated: [], destroyed: [], newState: current }); + }); + + it('answers cannotCalculateChanges for a state the log never reached', async () => { + const busy = new FakeJmapServer(); + busy.addAccount('a', { name: 'alice@example.com' }); + busy.addAddressBook('a', { name: 'Personal' }); + for (let i = 0; i < 10; i++) busy.addCard('a', { uid: `u${i}` }); + const { server, port } = setup(); + server.addCard('a', { uid: 'u' }); + + const [, error] = await call(port, 'ContactCard/changes', { + accountId: 'a', + sinceState: busy.state('ContactCard', 'a'), + }); + + expect(error).toEqual({ type: 'cannotCalculateChanges', description: 'Since state is invalid' }); + }); + }); + + describe('ifInState', () => { + it('refuses a /set unless ifInState is the current state', async () => { + const { server, port } = setup(); + const id = server.addCard('a', { uid: 'u' }); + const stale = server.state('ContactCard', 'a'); + server.serverUpdate('ContactCard', 'a', id, { kind: 'individual' }); + const current = server.state('ContactCard', 'a'); + + const refused = await call(port, 'ContactCard/set', { accountId: 'a', ifInState: stale, update: { [id]: { kind: 'org' } } }); + const unchanged = server.get('ContactCard', 'a', id)?.kind; + const [, accepted] = await call(port, 'ContactCard/set', { + accountId: 'a', + ifInState: current, + update: { [id]: { kind: 'org' } }, + }); + + expect(refused).toEqual(['error', { type: 'stateMismatch' }]); + expect(unchanged).toBe('individual'); + expect(accepted).toMatchObject({ oldState: current, updated: { [id]: null } }); + expect(accepted.newState).not.toBe(current); + expect(accepted.newState).toBe(server.state('ContactCard', 'a')); + }); + + it('sees a concurrent change made while the request is in flight', async () => { + const { server, port } = setup(); + const id = server.addCard('a', { uid: 'u' }); + const state = server.state('ContactCard', 'a'); + const stop = server.onBeforeRequest((request) => { + if (request.methods.includes('ContactCard/set')) server.serverUpdate('ContactCard', 'a', id, { kind: 'org' }); + }); + + const [, error] = await call(port, 'ContactCard/set', { + accountId: 'a', + ifInState: state, + update: { [id]: { kind: 'individual' } }, + }); + stop(); + + expect(error).toEqual({ type: 'stateMismatch' }); + }); + }); + + describe('uid index', () => { + it('finds a new object by uid only after the configured lag', async () => { + const { server, port, book } = setup(); + server.setUidIndexLag(1); + const [, set] = await call(port, 'ContactCard/set', { + accountId: 'a', + create: { n: { addressBookIds: { [book]: true }, uid: 'u1' } }, + }); + const id = set.created.n.id; + const byUid = { accountId: 'a', filter: { uid: 'u1', inAddressBook: book } }; + + const { methodResponses: next } = await port.request( + [ + ['ContactCard/query', byUid, 'uid'], + ['ContactCard/query', { accountId: 'a', filter: { inAddressBook: book } }, 'book'], + ], + USING, + ); + const [, later] = await call(port, 'ContactCard/query', byUid); + + expect(next[0][1].ids).toEqual([]); + expect(next[1][1].ids).toEqual([id]); + expect(later.ids).toEqual([id]); + }); + + it('finds objects by uid at once without a lag', async () => { + const { server, port, cal } = setup(); + const id = server.addEvent('a', { uid: 'ev-1', title: 'x' }); + + const [, query] = await call(port, 'CalendarEvent/query', { accountId: 'a', filter: { uid: 'ev-1', inCalendar: cal } }); + + expect(query.ids).toEqual([id]); + }); + }); + + describe('fault injection', () => { + it.each([ + ['network', 'NetworkError'], + ['timeout', 'RequestTimeoutError'], + ['auth', 'AuthenticationError'], + ['rateLimit', 'RateLimitError'], + ] as const)('fails the next request with %s before anything runs', async (kind, name) => { + const { server, port, book } = setup(); + server.failNextRequest(kind, { retryAfterMs: 7000 }); + const create: JmapInvocation[] = [ + ['ContactCard/set', { accountId: 'a', create: { n: { addressBookIds: { [book]: true } } } }, '0'], + ]; + + const error = await port.request(create, USING).catch((e: Error & { retryAfterMs?: number }) => e); + const cardsAfterFailure = server.all('ContactCard', 'a'); + const record = server.requests.at(-1); + await port.request(create, USING); + + expect(error).toBeInstanceOf(Error); + expect(error).toMatchObject({ name }); + if (kind === 'rateLimit') expect(error).toMatchObject({ retryAfterMs: 7000 }); + expect(cardsAfterFailure).toEqual([]); + expect(record).toMatchObject({ outcome: 'failed', fault: kind, responses: null }); + expect(server.all('ContactCard', 'a')).toHaveLength(1); + }); + + it('applies a request and then loses the response', async () => { + const { server, port, book } = setup(); + server.setUidIndexLag(2); + server.applyThenLoseResponse(); + const create: JmapInvocation[] = [ + ['ContactCard/set', { accountId: 'a', create: { n: { addressBookIds: { [book]: true }, uid: 'u1' } } }, '0'], + ]; + + await expect(port.request(create, USING)).rejects.toMatchObject({ name: 'NetworkError' }); + const [card] = server.all('ContactCard', 'a'); + const record = server.requests.at(-1); + // The retry protocol: the uid query can miss the card, the duplicate check cannot. + const [, query] = await call(port, 'ContactCard/query', { accountId: 'a', filter: { uid: 'u1', inAddressBook: book } }); + const { methodResponses } = await port.request(create, USING); + + expect(card).toMatchObject({ uid: 'u1' }); + expect(record).toMatchObject({ outcome: 'lost', fault: 'network' }); + expect(record?.responses?.[0][1]).toMatchObject({ created: { n: { id: card.id } } }); + expect(query.ids).toEqual([]); + expect((methodResponses[0][1] as Args).notCreated.n.description).toBe( + `Contact with UID u1 already exists with id ${card.id}.`, + ); + }); + + it('fails only a request that matches', async () => { + const { server, port, book } = setup(); + server.failNextRequest('network', { match: 'ContactCard/set' }); + + const get = await call(port, 'ContactCard/get', { accountId: 'a', ids: [] }); + const set = call(port, 'ContactCard/set', { accountId: 'a', create: { n: { addressBookIds: { [book]: true } } } }); + + expect(get[0]).toBe('ContactCard/get'); + await expect(set).rejects.toMatchObject({ name: 'NetworkError' }); + }); + + it('answers the next /set touching an object with an injected SetError, once', async () => { + const { server, port, book } = setup(); + const id = server.addCard('a', { uid: 'u1' }); + server.setErrorFor('ContactCard', 'a', id, { type: 'forbidden', description: 'read-only' }); + server.setErrorFor('ContactCard', 'a', 'u2', { type: 'overQuota' }); + const args = { + accountId: 'a', + update: { [id]: { kind: 'org' } }, + create: { n: { addressBookIds: { [book]: true }, uid: 'u2' } }, + }; + + const [, first] = await call(port, 'ContactCard/set', args); + const untouched = server.get('ContactCard', 'a', id); + const [, second] = await call(port, 'ContactCard/set', args); + + expect(first.notUpdated).toEqual({ [id]: { type: 'forbidden', description: 'read-only' } }); + expect(first.notCreated).toEqual({ n: { type: 'overQuota' } }); + expect(first.newState).toBe(first.oldState); + expect(untouched).not.toHaveProperty('kind'); + expect(second.updated).toEqual({ [id]: null }); + expect(Object.keys(second.created)).toEqual(['n']); + }); + + it('answers the next call of a method with an injected method error', async () => { + const { server, port } = setup(); + server.failNextMethod('ContactCard/set', { type: 'serverUnavailable' }); + + const first = await call(port, 'ContactCard/set', { accountId: 'a', update: {} }); + const second = await call(port, 'ContactCard/set', { accountId: 'a', update: {} }); + + expect(first).toEqual(['error', { type: 'serverUnavailable' }]); + expect(second[0]).toBe('ContactCard/set'); + }); + + it('serves blobs and can fail a download', async () => { + const { server, port } = setup(); + const blobId = server.addBlob('a', 'photo-bytes'); + server.failNextDownload('timeout'); + + await expect(port.downloadBlob('a', blobId)).rejects.toMatchObject({ name: 'RequestTimeoutError' }); + expect(new TextDecoder().decode(await port.downloadBlob('a', blobId))).toBe('photo-bytes'); + await expect(port.downloadBlob('a', 'missing')).rejects.toThrow('404'); + }); + }); + + describe('positional re-keying', () => { + it('gives card entries positional keys, as Stalwart does for a card written over CardDAV', async () => { + const { server, port } = setup(); + const id = server.addCard('a', { + emails: { work: { address: 'w@example.com' }, home: { address: 'h@example.com' } }, + organizations: { acme: { name: 'Acme' } }, + titles: { boss: { name: 'CEO', organizationId: 'acme' } }, + relatedTo: { 'urn:uuid:x': { relation: { friend: true } } }, + }); + const since = server.state('ContactCard', 'a'); + + server.rekeyPositionally('a', id); + const [, changes] = await call(port, 'ContactCard/changes', { accountId: 'a', sinceState: since }); + + expect(server.get('ContactCard', 'a', id)).toMatchObject({ + emails: { k1: { address: 'w@example.com' }, k2: { address: 'h@example.com' } }, + organizations: { k1: { name: 'Acme' } }, + titles: { k1: { name: 'CEO', organizationId: 'k1', kind: 'title' } }, + relatedTo: { 'urn:uuid:x': { relation: { friend: true } } }, + }); + expect(Object.keys(server.get('ContactCard', 'a', id)?.emails as Args)).toEqual(['k1', 'k2']); + expect(changes.updated).toEqual([id]); + }); + + it('keys event alerts by position and participants, locations and links by uuid5', () => { + const { server } = setup(); + const id = server.addEvent('a', { + title: 'x', + alerts: { a1: { trigger: { offset: '-PT5M' } }, a2: { trigger: { offset: '-PT1H' } } }, + participants: { p1: { calendarAddress: 'mailto:foo@example.com', locationId: 'l1' } }, + locations: { l1: { name: 'Room 1' } }, + mainLocationId: 'l1', + links: { x: { href: 'https://example.com/foo.pdf' } }, + }); + + server.rekeyPositionally('a', id); + + const event = server.get('CalendarEvent', 'a', id) as Args; + const room = uuid5('Room 1'); + expect(Object.keys(event.alerts)).toEqual(['k1', 'k2']); + expect(event.participants).toEqual({ + '59eb121c-e8f2-558a-9049-ef750a5976bd': { + '@type': 'Participant', + calendarAddress: 'mailto:foo@example.com', + locationId: room, + }, + }); + expect(Object.keys(event.locations)).toEqual([room]); + expect(event.mainLocationId).toBe(room); + expect(Object.keys(event.links)).toEqual(['245708bf-8e07-5d3b-a5da-2974a63c3b91']); + }); + + it('computes uuid5 keys as calcard does', () => { + expect(uuid5('mailto:hcabot@example.com')).toBe('0b235cc4-f04d-5fc4-98a3-c066650b3fbf'); + expect(uuid5('mailto:organizer@example.com')).toBe('251d3e9f-d83f-534c-8c45-c2896c75670c'); + expect(uuid5('mailto:bar@example.com')).toBe('5b6f4fa0-3695-53a7-904b-d0a6f8bc326f'); + }); + }); + + describe('result references', () => { + it('resolves #ids from /query and /changes results', async () => { + const { server, port, book } = setup(); + const c0 = server.addCard('a', { uid: 'u0' }); + const since = server.state('ContactCard', 'a'); + server.serverUpdate('ContactCard', 'a', c0, { kind: 'org' }); + const c1 = server.addCard('a', { uid: 'u1' }); + + const { methodResponses } = await port.request( + [ + ['ContactCard/query', { accountId: 'a', filter: { inAddressBook: book } }, 'q'], + ['ContactCard/get', { accountId: 'a', '#ids': { resultOf: 'q', name: 'ContactCard/query', path: '/ids' }, properties: ['uid'] }, 'g'], + ['ContactCard/changes', { accountId: 'a', sinceState: since }, 'c'], + ['ContactCard/get', { accountId: 'a', '#ids': { resultOf: 'c', name: 'ContactCard/changes', path: '/created' }, properties: ['uid'] }, 'gc'], + ['ContactCard/get', { accountId: 'a', '#ids': { resultOf: 'c', name: 'ContactCard/changes', path: '/updated' }, properties: ['uid'] }, 'gu'], + ], + USING, + ); + + expect(methodResponses[1][1].list).toEqual([{ id: c0, uid: 'u0' }, { id: c1, uid: 'u1' }]); + expect(methodResponses[3][1].list).toEqual([{ id: c1, uid: 'u1' }]); + expect(methodResponses[4][1].list).toEqual([{ id: c0, uid: 'u0' }]); + }); + + it('refuses a reference to a /set result or to a call that is not there', async () => { + const { port, book } = setup(); + + const { methodResponses } = await port.request( + [ + ['ContactCard/set', { accountId: 'a', create: { n: { addressBookIds: { [book]: true } } } }, 's'], + ['ContactCard/get', { accountId: 'a', '#ids': { resultOf: 's', name: 'ContactCard/set', path: '/created/n/id' } }, 'g'], + ['ContactCard/get', { accountId: 'a', '#ids': { resultOf: 'missing', name: 'ContactCard/query', path: '/ids' } }, 'm'], + ], + USING, + ); + + expect(methodResponses[0][0]).toBe('ContactCard/set'); + expect(methodResponses[1]).toEqual([ + 'error', + { type: 'invalidResultReference', description: 'Result reference to s#ContactCard/set not found.' }, + 'g', + ]); + expect(methodResponses[2][1]).toMatchObject({ type: 'invalidResultReference' }); + }); + }); + + describe('limits and method errors', () => { + it('rejects a request with too many calls before running any of them', async () => { + const { server, port, book } = setup(); + server.setLimits({ maxCallsInRequest: 2 }); + const calls: JmapInvocation[] = [ + ['ContactCard/set', { accountId: 'a', create: { n: { addressBookIds: { [book]: true } } } }, '0'], + ['Core/echo', {}, '1'], + ['Core/echo', {}, '2'], + ]; + + await expect(port.request(calls, USING)).rejects.toThrow(/maxCallsInRequest/); + + expect(port.session().capabilities[JMAP_CORE]).toMatchObject({ maxCallsInRequest: 2 }); + expect(server.all('ContactCard', 'a')).toEqual([]); + expect(server.requests.at(-1)?.outcome).toBe('rejected'); + }); + + it('counts how many requests were in flight at once', async () => { + const { server, port } = setup(); + + await call(port, 'Core/echo', {}); + await call(port, 'Core/echo', {}); + const sequential = server.maxInFlight; + await Promise.all([call(port, 'Core/echo', {}), call(port, 'Core/echo', {})]); + + expect(sequential).toBe(1); + expect(server.maxInFlight).toBe(2); + }); + + it('answers requestTooLarge for too many ids in /get or objects in /set', async () => { + const { server, port, book } = setup(); + server.setLimits({ maxObjectsInGet: 2, maxObjectsInSet: 1 }); + for (let i = 0; i < 3; i++) server.addCard('a', { uid: `u${i}` }); + + const get = await call(port, 'ContactCard/get', { accountId: 'a', ids: ['x', 'y', 'z'] }); + const set = await call(port, 'ContactCard/set', { + accountId: 'a', + create: { one: { addressBookIds: { [book]: true } }, two: { addressBookIds: { [book]: true } } }, + }); + const [, all] = await call(port, 'ContactCard/get', { accountId: 'a', ids: null }); + + expect(get).toEqual(['error', { type: 'requestTooLarge' }]); + expect(set).toEqual(['error', { type: 'requestTooLarge' }]); + expect(all.list).toHaveLength(2); + }); + + it('answers unknownMethod for unknown methods and for capabilities missing from using', async () => { + const { port } = setup(); + + const { methodResponses } = await port.request( + [ + ['Foo/get', {}, 'f'], + ['ContactCard/get', { accountId: 'a', ids: [] }, 'c'], + ['Core/echo', { hello: 1 }, 'e'], + ['CalendarEvent/get', { accountId: 'nobody', ids: [] }, 'x'], + ], + [JMAP_CORE, JMAP_CALENDARS], + ); + + expect(methodResponses[0]).toEqual(['error', expect.objectContaining({ type: 'unknownMethod' }), 'f']); + expect(methodResponses[1][1]).toMatchObject({ type: 'unknownMethod' }); + expect(methodResponses[2]).toEqual(['Core/echo', { hello: 1 }, 'e']); + expect(methodResponses[3]).toEqual(['error', { type: 'accountNotFound' }, 'x']); + }); + }); + + describe('/query', () => { + it('filters by address book and pages by position and limit, in id order', async () => { + const { server, port, book } = setup(); + const other = server.addAddressBook('a', { name: 'Other' }); + const ids = Array.from({ length: 7 }, (_, i) => server.addCard('a', { uid: `u${i}` })); + server.addCard('a', { uid: 'elsewhere', addressBookIds: { [other]: true } }); + const page = async (position: number) => + ( + await call(port, 'ContactCard/query', { + accountId: 'a', + filter: { inAddressBook: book }, + position, + limit: 3, + calculateTotal: true, + }) + )[1]; + + const first = await page(0); + const second = await page(3); + const third = await page(6); + server.setLimits({ maxQueryResults: 4 }); + const [, capped] = await call(port, 'ContactCard/query', { accountId: 'a', limit: 10 }); + + expect(first).toMatchObject({ ids: ids.slice(0, 3), position: 0, total: 7, limit: 3 }); + expect(first.queryState).toBe(server.state('ContactCard', 'a')); + expect(second.ids).toEqual(ids.slice(3, 6)); + expect(third.ids).toEqual(ids.slice(6)); + expect(capped).toMatchObject({ ids: ids.slice(0, 4), limit: 4 }); + }); + + it('refuses a filter it does not emulate', async () => { + const { port } = setup(); + + const [, error] = await call(port, 'ContactCard/query', { accountId: 'a', filter: { text: 'x' } }); + + expect(error.type).toBe('unsupportedFilter'); + }); + }); + + describe('edits by another client', () => { + it('destroys an address book with the cards only it holds', async () => { + const { server, port, book } = setup(); + const other = server.addAddressBook('a', { name: 'Other' }); + const only = server.addCard('a', { uid: 'only' }); + const both = server.addCard('a', { uid: 'both', addressBookIds: { [book]: true, [other]: true } }); + const cardsSince = server.state('ContactCard', 'a'); + const booksSince = server.state('AddressBook', 'a'); + + server.serverDestroy('AddressBook', 'a', book); + const [, cards] = await call(port, 'ContactCard/changes', { accountId: 'a', sinceState: cardsSince }); + const [, books] = await call(port, 'AddressBook/changes', { accountId: 'a', sinceState: booksSince }); + + expect(cards).toMatchObject({ created: [], updated: [both], destroyed: [only] }); + expect(books).toMatchObject({ created: [], updated: [], destroyed: [book] }); + expect(server.get('ContactCard', 'a', both)?.addressBookIds).toEqual({ [other]: true }); + expect(server.get('AddressBook', 'a', other)?.isDefault).toBe(true); + }); + + it('patches with JSON Pointers and stamps events unless the patch sets updated', () => { + const { server } = setup(); + server.setTime(Date.UTC(2026, 0, 2)); + const id = server.addEvent('a', { title: 'x', locations: { l1: { name: 'Old' } } }); + + server.advanceTime(1000); + server.serverUpdate('CalendarEvent', 'a', id, { 'locations/l1/name': 'New' }); + const stamped = server.get('CalendarEvent', 'a', id); + server.serverUpdate('CalendarEvent', 'a', id, { title: 'y', updated: '2020-01-01T00:00:00Z' }); + + expect(stamped).toMatchObject({ locations: { l1: { name: 'New' } }, updated: '2026-01-02T00:00:01Z' }); + expect(server.get('CalendarEvent', 'a', id)?.updated).toBe('2020-01-01T00:00:00Z'); + expect(() => server.serverUpdate('CalendarEvent', 'a', id, { 'alerts/k1': {} })).toThrow(/Patch operation failed/); + }); + }); +}); diff --git a/src/device-sync/__tests__/fake-provider.test.ts b/src/device-sync/__tests__/fake-provider.test.ts new file mode 100644 index 00000000..0a83ab5b --- /dev/null +++ b/src/device-sync/__tests__/fake-provider.test.ts @@ -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) => (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); + }); +}); diff --git a/src/device-sync/__tests__/fakes/fake-jmap-server.ts b/src/device-sync/__tests__/fakes/fake-jmap-server.ts new file mode 100644 index 00000000..015a161d --- /dev/null +++ b/src/device-sync/__tests__/fakes/fake-jmap-server.ts @@ -0,0 +1,2510 @@ +/** + * An in-memory JMAP server for the device-sync engine tests (#34), standing + * in for Stalwart v0.16.23 behind a `JmapPort`. It holds several accounts (a + * personal one plus shared ones), each with address books, contact cards, + * calendars and calendar events, and answers the method calls the engine + * makes the way Stalwart does: per-type states over one change log per + * collection, `/changes` compaction and paging with intermediate states, JSON + * Pointer patches, the uid rules, `ifInState`, result references, limits and + * the normalisation Stalwart applies on write. docs/device-sync.md describes + * the engine; each Stalwart rule below names where it comes from ("verified": + * checked against the live server; otherwise read from the Stalwart and + * calcard sources). + * + * Tests also drive it from the outside: seed helpers, edits by another + * client, positional re-keying (an object written over DAV without PROP-IDs), + * and fault injection (transport errors, a lost response, a truncated change + * log, a lagging uid index, per-object SetErrors, limits). + * + * Not emulated: ghost entries left by `map/key: null` (deleted cleanly + * here), partial overrides from deep pointers, per-instance (synthetic) ids, + * `utcStart`/`utcEnd`, `expandRecurrences`, query anchors and text filters, + * DST gaps and unknown time zones, duration rewriting, object sizes and + * quotas (inject them with `setErrorFor`), and the state jump after a cache + * rebuild. Method names Stalwart knows but the fake does not implement + * answer `unknownMethod` with a description saying so. + * + * Pure TypeScript: no React Native, expo or node imports. + */ + +import type { + JmapAccountView, + JmapInvocation, + JmapPort, + JmapResponse, + JmapSessionView, +} from '../../types'; +import { JMAP_CALENDARS, JMAP_CONTACTS, JMAP_CORE } from '../../types'; + +// ─── Public types ────────────────────────────────────── + +export type DataType = 'AddressBook' | 'ContactCard' | 'Calendar' | 'CalendarEvent'; +export type ItemType = 'ContactCard' | 'CalendarEvent'; +export type ContainerType = 'AddressBook' | 'Calendar'; + +/** + * A change log. Stalwart keeps one per collection: AddressBook and + * ContactCard share one, Calendar and CalendarEvent the other, and each + * `/changes` call filters it to its own type. + */ +export type TypeGroup = 'contacts' | 'calendars'; + +export type FaultKind = 'network' | 'timeout' | 'auth' | 'rateLimit'; + +export interface SetErrorShape { + type: string; + description?: string; + properties?: string[]; + [extra: string]: unknown; +} + +export interface FakeLimits { + maxObjectsInGet: number; + maxObjectsInSet: number; + maxCallsInRequest: number; + /** Bytes of the serialised request. */ + maxSizeRequest: number; + /** Server cap on `/changes` `maxChanges` (Stalwart: 5000). */ + maxChanges: number; + /** Server cap on `/query` `limit` (Stalwart: 5000). */ + maxQueryResults: number; +} + +export interface AccountOptions { + name: string; + /** Default true. The first personal account with a capability is its primary account. */ + isPersonal?: boolean; + isReadOnly?: boolean; + /** Capabilities the account advertises; both by default. */ + capabilities?: Array<'contacts' | 'calendars'>; + /** + * The owner's calendar addresses, without `mailto:`: they decide + * `isOrigin` and the organizer Stalwart assigns. Default: the name, when it + * is an email address. + */ + addresses?: string[]; +} + +export interface RequestRecord { + seq: number; + using: string[]; + calls: JmapInvocation[]; + methods: string[]; + /** What the server answered; null when the request never ran. */ + responses: JmapInvocation[] | null; + /** + * `ok`: answered. `failed`: a transport fault before anything ran. `lost`: + * ran (writes applied), then the response was lost. `rejected`: refused as + * a whole (a request-level limit). + */ + outcome: 'ok' | 'failed' | 'lost' | 'rejected'; + fault?: FaultKind; +} + +export interface SchedulingRecord { + accountId: string; + op: 'create' | 'update' | 'destroy'; + id: string; + requestSeq: number; +} + +export interface FaultOptions { + /** Run the request (writes are applied), then fail it: a lost response. */ + applied?: boolean; + /** For `rateLimit`. Default 1000. */ + retryAfterMs?: number; + /** How many matching requests fail. Default 1. */ + times?: number; + /** Only requests with a method call of this name, or passing this test. */ + match?: string | ((calls: JmapInvocation[]) => boolean); +} + +/** One object a `/set` call touches, as `setErrorFor` matchers see it. */ +export interface SetTarget { + op: 'create' | 'update' | 'destroy'; + /** The creation id for creates, else the object id. */ + id: string; + /** The create object or the PatchObject. */ + object?: Record; +} + +export interface FakeJmapServerOptions { + /** Clock for `updated` stamps. Default: a manual clock (`setTime`, `advanceTime`). */ + now?: () => number; + /** Start of the manual clock. Default 2026-01-01T00:00:00Z. */ + startTime?: number; + limits?: Partial; +} + +type RequestListener = (request: RequestRecord) => void | Promise; + +// ─── Constants ───────────────────────────────────────── + +type Obj = Record; +type Side = 'container' | 'item'; +type ChangeOp = 'insert' | 'update' | 'delete'; + +const DEFAULT_LIMITS: FakeLimits = { + maxObjectsInGet: 500, + maxObjectsInSet: 500, + maxCallsInRequest: 16, + maxSizeRequest: 10_000_000, + maxChanges: 5000, + maxQueryResults: 5000, +}; + +const ID_PATTERN = /^[A-Za-z0-9_-]{1,255}$/; + +/** Prefix of the ids the fake mints, per type (`c1`, `c2`, … `ca`, …). */ +const ID_PREFIX: Record = { + AddressBook: 'ab', + ContactCard: 'c', + Calendar: 'cal', + CalendarEvent: 'ev', +}; + +const CAPABILITY_OF: Record = { + Core: JMAP_CORE, + AddressBook: JMAP_CONTACTS, + ContactCard: JMAP_CONTACTS, + Calendar: JMAP_CALENDARS, + CalendarEvent: JMAP_CALENDARS, +}; + +const SUPPORTED_METHODS = new Set([ + 'Core/echo', + 'AddressBook/get', + 'AddressBook/changes', + 'ContactCard/get', + 'ContactCard/set', + 'ContactCard/query', + 'ContactCard/changes', + 'Calendar/get', + 'Calendar/changes', + 'CalendarEvent/get', + 'CalendarEvent/set', + 'CalendarEvent/query', + 'CalendarEvent/changes', +]); + +/** Responses a result reference may point at (Stalwart: /get, /changes, /query, /queryChanges). */ +const REFERENCEABLE = /\/(get|changes|query|queryChanges)$/; + +/** Top-level ContactCard properties (RFC 9553, RFC 9610); anything else is "Invalid property." (verified). */ +const CARD_PROPERTIES = new Set([ + '@type', 'version', 'created', 'kind', 'language', 'members', 'prodId', 'uid', 'updated', 'name', + 'nicknames', 'organizations', 'speakToAs', 'titles', 'emails', 'onlineServices', 'phones', + 'preferredLanguages', 'calendars', 'schedulingAddresses', 'addresses', 'cryptoKeys', 'directories', + 'links', 'media', 'localizations', 'anniversaries', 'keywords', 'notes', 'personalInfo', 'relatedTo', + 'vCard', +]); + +/** + * Top-level CalendarEvent properties calcard maps: JSCalendar-bis names only. + * `recurrenceRules`, `excludedRecurrenceRule(s)`, `timeZones`, + * `progressUpdated` and other JSCalendar 1.0 names are "Invalid property." + * (verified). + */ +const EVENT_PROPERTIES = new Set([ + '@type', 'uid', 'relatedTo', 'prodId', 'created', 'updated', 'sequence', 'title', 'description', + 'descriptionContentType', 'showWithoutTime', 'locations', 'mainLocationId', 'virtualLocations', 'links', + 'locale', 'keywords', 'categories', 'color', 'recurrenceId', 'recurrenceIdTimeZone', 'recurrenceRule', + 'recurrenceOverrides', 'excluded', 'priority', 'freeBusyStatus', 'privacy', 'replyTo', 'sentBy', + 'participants', 'requestStatus', 'alerts', 'timeZone', 'start', 'duration', 'endTimeZone', 'status', + 'organizerCalendarAddress', 'iCalendar', 'due', 'estimatedDuration', 'percentComplete', 'progress', +]); + +/** JMAP-only event flags: booleans stored beside the iCalendar data. */ +const EVENT_FLAGS = ['isDraft', 'useDefaultAlerts', 'mayInviteSelf', 'mayInviteOthers', 'hideAttendees']; + +/** Computed on read; any value sent is "This property is immutable." (verified for isOrigin). */ +const IMMUTABLE_EVENT_PROPERTIES = new Set(['isOrigin', 'baseEventId', 'method']); + +/** Properties calcard drops from an override on read (`remove_forbidden_override_patches`). */ +const FORBIDDEN_OVERRIDE_PROPERTIES = [ + '@type', 'method', 'organizerCalendarAddress', 'privacy', 'prodId', 'recurrenceId', + 'recurrenceIdTimeZone', 'sentBy', 'uid', 'recurrenceRule', 'recurrenceOverrides', +]; + +/** Card maps that vanish when empty (no vCard line is written). `keywords` and `members` keep `{}`. */ +const CARD_MAPS = [ + 'nicknames', 'organizations', 'titles', 'emails', 'onlineServices', 'phones', 'preferredLanguages', + 'calendars', 'schedulingAddresses', 'addresses', 'cryptoKeys', 'directories', 'links', 'media', + 'localizations', 'anniversaries', 'notes', 'personalInfo', 'relatedTo', +]; + +/** Card maps whose keys are PROP-IDs, regenerated as `k1…kN` when those are lost. */ +const CARD_KEYED_MAPS = CARD_MAPS.filter((m) => m !== 'relatedTo' && m !== 'localizations'); + +/** Event maps that vanish when empty (no iCalendar line or component is written). */ +const EVENT_MAPS = [ + 'alerts', 'participants', 'locations', 'virtualLocations', 'links', 'recurrenceOverrides', 'keywords', + 'categories', 'relatedTo', 'replyTo', +]; + +/** Name component kinds that have a vCard N position and so make up a derived `full`. */ +const NAME_KINDS = new Set(['surname', 'given', 'given2', 'title', 'credential', 'surname2', 'generation']); + +const PRIVACY_RANK = ['public', 'private', 'secret']; + +const ADDRESS_BOOK_DEFAULTS: Obj = { + name: '', + description: null, + sortOrder: 0, + isSubscribed: true, + shareWith: null, + myRights: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true }, +}; + +const CALENDAR_DEFAULTS: Obj = { + name: '', + description: null, + color: null, + sortOrder: 0, + isSubscribed: true, + isVisible: true, + includeInAvailability: 'all', + defaultAlertsWithTime: {}, + defaultAlertsWithoutTime: {}, + timeZone: null, + shareWith: null, + myRights: { + mayReadFreeBusy: true, + mayReadItems: true, + mayWriteAll: true, + mayWriteOwn: true, + mayUpdatePrivate: true, + mayRSVP: true, + mayShare: true, + mayDelete: true, + }, +}; + +// ─── JSON helpers ────────────────────────────────────── + +function isObj(value: unknown): value is Obj { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function clone(value: T): T { + return value === undefined ? value : (JSON.parse(JSON.stringify(value)) as T); +} + +function hasOwn(obj: Obj, key: string): boolean { + return Object.prototype.hasOwnProperty.call(obj, key); +} + +/** Assign an own property, also for the key `__proto__`. */ +function setKey(obj: Obj, key: string, value: unknown): void { + if (key === '__proto__') { + Object.defineProperty(obj, key, { value, enumerable: true, writable: true, configurable: true }); + } else { + obj[key] = value; + } +} + +/** Drop null members of objects, recursively: the converters write nothing for null. */ +function stripNulls(value: unknown): void { + if (Array.isArray(value)) { + for (const item of value) stripNulls(item); + return; + } + if (!isObj(value)) return; + for (const key of Object.keys(value)) { + if (value[key] === null) delete value[key]; + else stripNulls(value[key]); + } +} + +function booleanSet(ids: string[]): Obj { + const out: Obj = {}; + for (const id of ids) setKey(out, id, true); + return out; +} + +function invalidProperties(properties: string[], description: string): SetErrorShape { + return { type: 'invalidProperties', properties, description }; +} + +class MethodError extends Error { + constructor(readonly type: string, readonly detail?: string) { + super(detail ?? type); + } +} + +// ─── State strings ───────────────────────────────────── + +// Stalwart's encoding (jmap-proto/src/types/state.rs): `n` for "no changes +// yet", `s` + base32(leb128(changeId)) for an exact state, and `r` + +// base32(leb128(from), leb128(to - from), leb128(itemsSent)) for the +// intermediate state of a paged `/changes`. Base32 uses Stalwart's alphabet. + +type ParsedState = + | { kind: 'initial' } + | { kind: 'exact'; id: number } + | { kind: 'intermediate'; from: number; to: number; sent: number }; + +const BASE32 = 'abcdefghijklmnopqrstuvwxyz792013'; + +function leb128(value: number, out: number[]): void { + let n = value; + do { + let byte = n % 128; + n = Math.floor(n / 128); + if (n > 0) byte |= 0x80; + out.push(byte); + } while (n > 0); +} + +function toBase32(bytes: number[]): string { + let out = ''; + let buffer = 0; + let bits = 0; + for (const byte of bytes) { + buffer = (buffer << 8) | byte; + bits += 8; + while (bits >= 5) { + out += BASE32[(buffer >> (bits - 5)) & 31]; + bits -= 5; + } + buffer &= (1 << bits) - 1; + } + if (bits > 0) out += BASE32[(buffer << (5 - bits)) & 31]; + return out; +} + +function fromBase32(text: string): number[] | null { + const out: number[] = []; + let buffer = 0; + let bits = 0; + for (const ch of text) { + const value = BASE32.indexOf(ch); + if (value < 0) return null; + buffer = (buffer << 5) | value; + bits += 5; + if (bits >= 8) { + out.push((buffer >> (bits - 8)) & 0xff); + bits -= 8; + buffer &= (1 << bits) - 1; + } + } + return out; +} + +function readLeb128(bytes: number[], pos: number): [number, number] | null { + let value = 0; + let scale = 1; + for (let i = pos; i < bytes.length; i++) { + value += (bytes[i] & 0x7f) * scale; + if ((bytes[i] & 0x80) === 0) return [value, i + 1]; + scale *= 128; + } + return null; +} + +function encodeState(state: ParsedState): string { + const bytes: number[] = []; + switch (state.kind) { + case 'initial': + return 'n'; + case 'exact': + leb128(state.id, bytes); + return `s${toBase32(bytes)}`; + case 'intermediate': + leb128(state.from, bytes); + leb128(state.to - state.from, bytes); + leb128(state.sent, bytes); + return `r${toBase32(bytes)}`; + } +} + +function parseState(value: string): ParsedState | null { + const bytes = fromBase32(value.slice(1)); + if (value[0] === 'n') return { kind: 'initial' }; + if (!bytes) return null; + if (value[0] === 's') { + const id = readLeb128(bytes, 0); + if (!id) return null; + return id[0] === 0 ? { kind: 'initial' } : { kind: 'exact', id: id[0] }; + } + if (value[0] === 'r') { + const from = readLeb128(bytes, 0); + const delta = from && readLeb128(bytes, from[1]); + const sent = delta && readLeb128(bytes, delta[1]); + if (!from || !delta || !sent || sent[0] === 0) return null; + return { kind: 'intermediate', from: from[0], to: from[0] + delta[0], sent: sent[0] }; + } + return null; +} + +function stateOfChangeId(changeId: number): ParsedState { + return changeId === 0 ? { kind: 'initial' } : { kind: 'exact', id: changeId }; +} + +// ─── JSON Pointer (jmap-tools semantics) ─────────────── + +// A PatchObject key containing `/` is a JSON Pointer. jmap-tools parses it +// with RFC 6901's `~0`/`~1` escapes, plus `\` escaping the next character; an +// all-digit segment (other than a leading-zero one like `01`) is an array +// index that can replace an existing member but never add one; `*` is a +// wildcard that makes the patch fail (jmap-tools pointer/parser.rs, eval.rs). + +type Segment = + | { kind: 'key'; key: string } + | { kind: 'index'; index: number; raw: string } + | { kind: 'wildcard' }; + +function parsePointer(value: string): Segment[] { + const segments: Segment[] = []; + let token: 'unknown' | 'number' | 'string' | 'wildcard' | 'escaped' = 'unknown'; + let buf = ''; + let num = 0; + let start = 0; + const flush = (end: number) => { + const raw = value.slice(start, end); + if (token === 'string') { + segments.push({ kind: 'key', key: buf }); + buf = ''; + } else if (token === 'number') { + segments.push(raw.length > 1 && raw[0] === '0' ? { kind: 'key', key: raw } : { kind: 'index', index: num, raw }); + num = 0; + } else if (token === 'wildcard') { + segments.push({ kind: 'wildcard' }); + } else if (token === 'unknown' && start > 0) { + segments.push({ kind: 'key', key: '' }); + } + }; + for (let i = 0; i < value.length; i++) { + const ch = value[i]; + if (ch >= '0' && ch <= '9' && (token === 'unknown' || token === 'number')) { + num = num * 10 + Number(ch); + token = 'number'; + } else if (ch === '*' && token === 'unknown') { + token = 'wildcard'; + } else if ((ch === '0' || ch === '1') && token === 'escaped') { + buf += ch === '0' ? '~' : '/'; + token = 'string'; + } else if (ch === '/') { + flush(i); + token = 'unknown'; + start = i + 1; + } else { + if ((token === 'number' || token === 'wildcard') && i > start) buf += value.slice(start, i); + if (ch === '~' && token !== 'escaped') { + token = 'escaped'; + } else if (ch === '\\') { + i++; + buf += i < value.length ? value[i] : '\\'; + token = 'string'; + } else { + buf += ch; + token = 'string'; + } + } + } + flush(value.length); + return segments; +} + +/** + * Apply one pointer patch the way `patch_jptr` does: every parent must exist, + * the last segment replaces or adds a member. One deliberate difference: + * Stalwart keeps `key: null` in the map, and for most contact maps its + * converter then leaves a valueless vCard line (or a JSPROP null) behind + * (research E2). The engine never relies on that and resends the whole map + * instead, so here a null simply deletes the member. + */ +function applyPointer(node: unknown, segments: Segment[], value: unknown, i = 0): boolean { + const segment = segments[i]; + if (!segment) return false; + const last = i === segments.length - 1; + if (segment.kind === 'key') { + if (!isObj(node)) return false; + if (hasOwn(node, segment.key)) { + if (!last) return applyPointer(node[segment.key], segments, value, i + 1); + assignOrDelete(node, segment.key, value); + return true; + } + if (!last) return false; + assignOrDelete(node, segment.key, value); + return true; + } + if (segment.kind === 'index') { + if (Array.isArray(node)) { + if (segment.index >= node.length) return false; + if (!last) return applyPointer(node[segment.index], segments, value, i + 1); + node[segment.index] = value; + return true; + } + const key = String(segment.index); + if (isObj(node) && hasOwn(node, key)) { + if (!last) return applyPointer(node[key], segments, value, i + 1); + assignOrDelete(node, key, value); + return true; + } + } + return false; +} + +function assignOrDelete(obj: Obj, key: string, value: unknown): void { + if (value === null) delete obj[key]; + else setKey(obj, key, value); +} + +/** A result reference path (RFC 8620 §3.7): a JSON Pointer where `*` maps over an array. */ +function resolveReferencePath(root: unknown, path: string): { value: unknown } | null { + if (path === '') return { value: root }; + if (!path.startsWith('/')) return null; + const tokens = path.slice(1).split('/').map((t) => t.replace(/~1/g, '/').replace(/~0/g, '~')); + const walk = (node: unknown, i: number): { value: unknown } | null => { + if (i === tokens.length) return { value: node }; + const token = tokens[i]; + if (Array.isArray(node)) { + if (token === '*') { + const out: unknown[] = []; + for (const item of node) { + const result = walk(item, i + 1); + if (!result) return null; + if (Array.isArray(result.value)) out.push(...result.value); + else out.push(result.value); + } + return { value: out }; + } + if (!/^\d+$/.test(token) || Number(token) >= node.length) return null; + return walk(node[Number(token)], i + 1); + } + if (isObj(node) && hasOwn(node, token)) return walk(node[token], i + 1); + return null; + }; + return walk(root, 0); +} + +// ─── uuid5 ───────────────────────────────────────────── + +// calcard keys participants, locations, links and virtual locations that have +// no JSID by uuid5 of their address, name, href or uri in its own namespace +// (7f1e1965-ae73-4454-b088-232c90730ce2, calcard jscalendar/mod.rs). + +const JSCAL_NAMESPACE = [0x7f, 0x1e, 0x19, 0x65, 0xae, 0x73, 0x44, 0x54, 0xb0, 0x88, 0x23, 0x2c, 0x90, 0x73, 0x0c, 0xe2]; + +function sha1(message: Uint8Array): Uint8Array { + const padded = new Uint8Array((((message.length + 8) >> 6) + 1) << 6); + padded.set(message); + padded[message.length] = 0x80; + const view = new DataView(padded.buffer); + const bits = message.length * 8; + view.setUint32(padded.length - 8, Math.floor(bits / 0x100000000)); + view.setUint32(padded.length - 4, bits >>> 0); + const h = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0]; + const w = new Uint32Array(80); + for (let offset = 0; offset < padded.length; offset += 64) { + for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4); + for (let i = 16; i < 80; i++) { + const x = w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]; + w[i] = (x << 1) | (x >>> 31); + } + let [a, b, c, d, e] = h; + for (let i = 0; i < 80; i++) { + const f = i < 20 ? (b & c) | (~b & d) : i < 40 ? b ^ c ^ d : i < 60 ? (b & c) | (b & d) | (c & d) : b ^ c ^ d; + const k = i < 20 ? 0x5a827999 : i < 40 ? 0x6ed9eba1 : i < 60 ? 0x8f1bbcdc : 0xca62c1d6; + const t = (((a << 5) | (a >>> 27)) + f + e + k + w[i]) >>> 0; + e = d; + d = c; + c = ((b << 30) | (b >>> 2)) >>> 0; + b = a; + a = t; + } + h[0] = (h[0] + a) >>> 0; + h[1] = (h[1] + b) >>> 0; + h[2] = (h[2] + c) >>> 0; + h[3] = (h[3] + d) >>> 0; + h[4] = (h[4] + e) >>> 0; + } + const out = new Uint8Array(20); + const outView = new DataView(out.buffer); + h.forEach((value, i) => outView.setUint32(i * 4, value)); + return out; +} + +/** The key calcard gives an entry without a stored JSID, e.g. `uuid5('mailto:a@b.c')`. */ +export function uuid5(text: string): string { + const name = new TextEncoder().encode(text); + const input = new Uint8Array(JSCAL_NAMESPACE.length + name.length); + input.set(JSCAL_NAMESPACE); + input.set(name, JSCAL_NAMESPACE.length); + const hash = sha1(input); + hash[6] = (hash[6] & 0x0f) | 0x50; + hash[8] = (hash[8] & 0x3f) | 0x80; + const hex = Array.from(hash.slice(0, 16), (byte) => byte.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +// ─── Change log ──────────────────────────────────────── + +interface Change { + side: Side; + op: ChangeOp; + id: string; +} + +interface LogEntry { + changeId: number; + /** The truncation marker Stalwart writes where it trimmed the log. */ + marker: boolean; + changes: Change[]; +} + +interface ChangeWindow { + /** Compacted changes by `side:id`, in log order. */ + changes: Map; + fromChangeId: number; + toChangeId: number; + containerChangeId: number | null; + itemChangeId: number | null; + isTruncated: boolean; +} + +type LogQuery = { all: true } | { since: number } | { from: number; to: number }; + +/** + * Compaction inside a window (store/src/query/log.rs): an update after an + * insert is dropped, an insert then delete vanishes, an update then delete + * leaves only the delete, and a repeated update moves to the end. + */ +function compact(changes: Map, change: Change): void { + const key = `${change.side}:${change.id}`; + const current = changes.get(key); + if (change.op === 'insert') { + changes.set(key, change); + return; + } + if (current?.op === 'insert') { + if (change.op === 'delete') changes.delete(key); + return; + } + changes.delete(key); + changes.set(key, change); +} + +class ChangeLog { + entries: LogEntry[] = []; + /** The last change id with container (or item) changes: the type's state. */ + containerChangeId = 0; + itemChangeId = 0; + + append(changeId: number, changes: Change[]): void { + this.entries.push({ changeId, marker: false, changes }); + if (changes.some((c) => c.side === 'container')) this.containerChangeId = changeId; + if (changes.some((c) => c.side === 'item')) this.itemChangeId = changeId; + } + + sideChangeId(side: Side): number { + return side === 'container' ? this.containerChangeId : this.itemChangeId; + } + + /** `Store::changes`: the entries of a range, compacted. */ + query(q: LogQuery): ChangeWindow { + const [inclusive, from, to]: [boolean, number, number] = + 'all' in q ? [true, 0, Infinity] : 'since' in q ? [false, q.since, Infinity] : [true, q.from, q.to]; + const out: ChangeWindow = { + changes: new Map(), + fromChangeId: 0, + toChangeId: 0, + containerChangeId: null, + itemChangeId: null, + isTruncated: false, + }; + for (const entry of this.entries) { + const id = entry.changeId; + if (id < from || id > to) continue; + if (!inclusive && id === from) { + out.fromChangeId = id; + out.toChangeId = id; + continue; + } + if (entry.marker) { + out.isTruncated = true; + continue; + } + if (out.changes.size === 0) out.fromChangeId = id; + out.toChangeId = id; + let container = false; + let item = false; + for (const change of entry.changes) { + compact(out.changes, change); + if (change.side === 'container') container = true; + else item = true; + } + if (container) out.containerChangeId = id; + if (item) out.itemChangeId = id; + } + return out; + } + + /** + * `delete_changes`: keep the newest `keep` entries; the one before them + * becomes the truncation marker and everything older goes. + */ + truncate(keep: number): void { + if (this.entries.length <= keep) return; + const markerIndex = this.entries.length - keep - 1; + const marker: LogEntry = { changeId: this.entries[markerIndex].changeId, marker: true, changes: [] }; + this.entries = [marker, ...this.entries.slice(markerIndex + 1)]; + } +} + +/** The changes of one commit, as Stalwart's ChangeLogBuilder collects them. */ +class PendingChanges { + private readonly sets: Record>> = { + container: { insert: new Set(), update: new Set(), delete: new Set() }, + item: { insert: new Set(), update: new Set(), delete: new Set() }, + }; + + insert(side: Side, id: string): void { + const sets = this.sets[side]; + if (sets.delete.delete(id)) sets.update.add(id); + else sets.insert.add(id); + } + + update(side: Side, id: string): void { + this.sets[side].update.add(id); + } + + delete(side: Side, id: string): void { + const sets = this.sets[side]; + sets.update.delete(id); + sets.delete.add(id); + } + + toChanges(): Change[] { + const out: Change[] = []; + for (const side of ['container', 'item'] as const) { + for (const op of ['insert', 'update', 'delete'] as const) { + for (const id of this.sets[side][op]) out.push({ side, op, id }); + } + } + return out; + } +} + +// ─── Normalisation ───────────────────────────────────── + +/** RFC 3339 with offset → UTC at second precision with `Z`; null when unparsable. */ +function utcSeconds(value: string): string | null { + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/i.test(value)) return null; + const time = Date.parse(value); + return Number.isNaN(time) ? null : new Date(time).toISOString().replace(/\.\d{3}Z$/, 'Z'); +} + +/** A LocalDateTime at second precision; a fraction and any `Z` or offset are ignored. */ +function localDateTime(value: string): string | null { + const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:Z|z|[+-]\d{2}:?\d{2})?$/.exec(value); + return match ? match[1] : null; +} + +/** + * `full` as calcard derives it when a name has components but no `full` + * (written as `FN;DERIVED=TRUE` and read back as `name.full`): the + * components in order joined by the default separator (a space unless + * `defaultSeparator` says otherwise), `separator` components verbatim. + */ +function deriveFullName(name: Obj): string { + if (!Array.isArray(name.components)) return ''; + const separator = + typeof name.defaultSeparator === 'string' && name.defaultSeparator !== '' ? name.defaultSeparator : ' '; + let full = ''; + let afterValue = false; + for (const component of name.components) { + if (!isObj(component) || typeof component.value !== 'string') continue; + if (component.kind === 'separator') { + full += component.value; + afterValue = false; + } else if (typeof component.kind === 'string' && NAME_KINDS.has(component.kind)) { + if (afterValue) full += separator; + full += component.value; + afterValue = true; + } + } + return full; +} + +/** Components make a name or address ordered (JSCOMPS is always written); without them the flag goes. */ +function normalizeOrdered(value: Obj): void { + if (Array.isArray(value.components) && value.components.length > 0) { + value.isOrdered = true; + } else { + delete value.isOrdered; + if (Array.isArray(value.components)) delete value.components; + } +} + +function dropEmptyMaps(obj: Obj, maps: string[]): void { + for (const key of maps) { + const value = obj[key]; + if (isObj(value) && Object.keys(value).length === 0) delete obj[key]; + } +} + +/** + * A card as `ContactCard/get` returns it after Stalwart stored it as vCard: + * `@type: "Card"` and `version: "1.0"` always (verified); `isOrdered: true` + * on a name and on addresses with components (verified); `kind: "title"` on + * titles without a kind (verified); a derived `name.full` when absent, which + * then sticks, since Stalwart never re-derives it after a component patch; + * `@type` on anniversary dates; `created`/`updated` in UTC at second + * precision; empty maps gone. `data:` URIs in media stay as sent (Stalwart + * re-encodes them as `data:;base64,…`, which the tests don't need). + */ +function normalizeCard(input: Obj): Obj { + stripNulls(input); + const card: Obj = { '@type': 'Card', version: '1.0' }; + for (const [key, value] of Object.entries(input)) { + if (key !== '@type' && key !== 'version') setKey(card, key, value); + } + for (const key of ['created', 'updated']) { + const value = card[key]; + if (typeof value !== 'string') continue; + const utc = utcSeconds(value); + if (utc) card[key] = utc; + else delete card[key]; + } + if (isObj(card.name)) { + const name = card.name; + normalizeOrdered(name); + if (typeof name.full !== 'string') { + const full = deriveFullName(name); + if (full) name.full = full; + } + } + if (isObj(card.addresses)) { + for (const address of Object.values(card.addresses)) if (isObj(address)) normalizeOrdered(address); + } + if (isObj(card.titles)) { + for (const title of Object.values(card.titles)) { + if (isObj(title) && title.kind === undefined) title.kind = 'title'; + } + } + if (isObj(card.anniversaries)) { + for (const anniversary of Object.values(card.anniversaries)) { + if (!isObj(anniversary) || !isObj(anniversary.date) || anniversary.date['@type'] !== undefined) continue; + const date = anniversary.date; + if (date.utc !== undefined) date['@type'] = 'Timestamp'; + else if (date.year !== undefined || date.month !== undefined || date.day !== undefined) date['@type'] = 'PartialDate'; + } + } + dropEmptyMaps(card, CARD_MAPS); + return card; +} + +/** `@type` calcard puts on nested event objects. */ +function addNestedTypes(component: Obj): void { + const typed: Array<[string, string]> = [ + ['participants', 'Participant'], + ['locations', 'Location'], + ['virtualLocations', 'VirtualLocation'], + ['links', 'Link'], + ['alerts', 'Alert'], + ]; + for (const [property, type] of typed) { + const map = component[property]; + if (!isObj(map)) continue; + for (const entry of Object.values(map)) { + if (!isObj(entry)) continue; + if (entry['@type'] === undefined) entry['@type'] = type; + const trigger = entry.trigger; + if (property === 'alerts' && isObj(trigger) && trigger['@type'] === undefined) { + if (trigger.offset !== undefined) trigger['@type'] = 'OffsetTrigger'; + else if (trigger.when !== undefined) trigger['@type'] = 'AbsoluteTrigger'; + } + } + } +} + +function normalizeEventTimes(component: Obj): void { + if (typeof component.start === 'string') { + const start = localDateTime(component.start); + if (start) component.start = start; + } + if (typeof component.created === 'string') { + const created = localDateTime(component.created); + if (created) component.created = `${created}Z`; + } +} + +/** + * An event as `CalendarEvent/get` returns it after Stalwart stored it as + * iCalendar: + * - `updated` is the server's time on every write (verified), and every + * override stored as its own VEVENT gets one too (verified for new and + * changed overrides; `stamp_updated` stamps all of them). In `server` mode + * (seeds, edits by another client) an `updated` that is already there is + * kept, so tests can pin it. + * - `@type` defaults to `Event` (`Task` stays); participants, locations, + * virtual locations, links and alerts get their `@type`. + * - `start` and override keys are LocalDateTimes (a fraction, `Z` or offset + * is ignored); an override key that is not one is dropped, `{excluded: + * true}` loses everything else, forbidden override properties go, and a + * stricter override `privacy` raises the event's. + * - Empty maps are gone. + */ +function normalizeEvent(input: Obj, now: string, mode: 'jmap' | 'server'): Obj { + stripNulls(input); + const event: Obj = { '@type': typeof input['@type'] === 'string' ? input['@type'] : 'Event' }; + for (const [key, value] of Object.entries(input)) if (key !== '@type') setKey(event, key, value); + normalizeEventTimes(event); + addNestedTypes(event); + if (isObj(event.recurrenceOverrides)) { + const overrides: Obj = {}; + let privacy = Math.max(0, PRIVACY_RANK.indexOf(String(event.privacy))); + for (const [key, value] of Object.entries(event.recurrenceOverrides)) { + const rid = localDateTime(key); + if (!rid || !isObj(value)) continue; + if (value.excluded === true) { + setKey(overrides, rid, { excluded: true }); + continue; + } + privacy = Math.max(privacy, PRIVACY_RANK.indexOf(String(value.privacy))); + for (const forbidden of FORBIDDEN_OVERRIDE_PROPERTIES) delete value[forbidden]; + if (Object.keys(value).length > 0) { + normalizeEventTimes(value); + addNestedTypes(value); + if (mode === 'jmap' || typeof value.updated !== 'string') value.updated = now; + } + setKey(overrides, rid, value); + } + event.recurrenceOverrides = overrides; + if (privacy > 0 && privacy > PRIVACY_RANK.indexOf(String(event.privacy))) event.privacy = PRIVACY_RANK[privacy]; + } + if (mode === 'jmap' || typeof event.updated !== 'string') event.updated = now; + dropEmptyMaps(event, EVENT_MAPS); + return event; +} + +// ─── Positional re-keying ────────────────────────────── + +interface Rekeyed { + map: Obj; + renames: Record; +} + +function rekeyMap(map: Obj, keyFor: (entry: unknown, index: number, oldKey: string) => string): Rekeyed { + const out: Obj = {}; + const renames: Record = {}; + Object.entries(map).forEach(([oldKey, entry], index) => { + let key = keyFor(entry, index, oldKey); + // jmap-tools renames a colliding key to `-` (json/object_vec.rs). + for (let n = 2; hasOwn(out, key); n++) key = `${keyFor(entry, index, oldKey)}-${n}`; + setKey(out, key, entry); + renames[oldKey] = key; + }); + return { map: out, renames }; +} + +const positional = (_entry: unknown, index: number) => `k${index + 1}`; + +function byContent(property: string): (entry: unknown, index: number, oldKey: string) => string { + return (entry, _index, oldKey) => { + const content = isObj(entry) ? entry[property] : undefined; + return typeof content === 'string' ? uuid5(content) : oldKey; + }; +} + +/** Card maps keyed by PROP-ID get `k1…kN` by position; titles follow their organization. */ +function rekeyCard(card: Obj): void { + let organizations: Record = {}; + for (const property of CARD_KEYED_MAPS) { + const map = card[property]; + if (!isObj(map)) continue; + const rekeyed = rekeyMap(map, positional); + card[property] = rekeyed.map; + if (property === 'organizations') organizations = rekeyed.renames; + } + if (isObj(card.speakToAs) && isObj(card.speakToAs.pronouns)) { + card.speakToAs.pronouns = rekeyMap(card.speakToAs.pronouns, positional).map; + } + if (isObj(card.titles)) { + for (const title of Object.values(card.titles)) { + if (isObj(title) && typeof title.organizationId === 'string' && organizations[title.organizationId]) { + title.organizationId = organizations[title.organizationId]; + } + } + } +} + +/** + * Alerts get `k1…kN` by position; participants, locations, virtual locations + * and links get uuid5 of their calendar address, name, uri and href. + */ +function rekeyEventComponent(component: Obj): void { + if (isObj(component.alerts)) component.alerts = rekeyMap(component.alerts, positional).map; + if (isObj(component.participants)) component.participants = rekeyMap(component.participants, byContent('calendarAddress')).map; + if (isObj(component.virtualLocations)) component.virtualLocations = rekeyMap(component.virtualLocations, byContent('uri')).map; + if (isObj(component.links)) component.links = rekeyMap(component.links, byContent('href')).map; + if (!isObj(component.locations)) return; + const locations = rekeyMap(component.locations, (entry, index, oldKey) => { + const name = isObj(entry) ? entry.name ?? entry.coordinates : undefined; + return typeof name === 'string' ? uuid5(name) : oldKey; + }); + component.locations = locations.map; + if (typeof component.mainLocationId === 'string' && locations.renames[component.mainLocationId]) { + component.mainLocationId = locations.renames[component.mainLocationId]; + } + if (isObj(component.participants)) { + for (const participant of Object.values(component.participants)) { + if (isObj(participant) && typeof participant.locationId === 'string' && locations.renames[participant.locationId]) { + participant.locationId = locations.renames[participant.locationId]; + } + } + } +} + +function rekeyEvent(event: Obj): void { + rekeyEventComponent(event); + if (isObj(event.recurrenceOverrides)) { + for (const override of Object.values(event.recurrenceOverrides)) if (isObj(override)) rekeyEventComponent(override); + } +} + +// ─── Server model ────────────────────────────────────── + +interface Stored { + id: string; + /** Document id: creation order, the order `/query` and `ids: null` return. */ + docId: number; + /** The JSContact/JSCalendar (or container) properties, without id, parents and JMAP-only flags. */ + data: Obj; + /** addressBookIds / calendarIds, in order. */ + parents: string[]; + /** Events: isDraft, useDefaultAlerts, mayInviteSelf, mayInviteOthers, hideAttendees. */ + flags: Obj; + /** First request whose `uid` query finds the object (the index lags). */ + uidVisibleFrom: number; + modSeq: number; +} + +interface Account { + id: string; + name: string; + isPersonal: boolean; + isReadOnly: boolean; + contacts: boolean; + calendars: boolean; + addresses: string[]; + /** Change ids are global to the account. */ + lastChangeId: number; + logs: Record; + objects: Record>; + usedIds: Record>; + idCounter: Record; + docCounter: Record; + defaultContainer: Partial>; + blobs: Map; +} + +interface Draft { + data: Obj; + parents: string[]; + flags: Obj; +} + +interface Fault { + kind: FaultKind; + target: 'request' | 'download'; + applied: boolean; + retryAfterMs: number; + remaining: number; + match?: (calls: JmapInvocation[]) => boolean; +} + +interface InjectedSetError { + type: ItemType; + accountId: string; + match: (target: SetTarget) => boolean; + error: SetErrorShape; + remaining: number; +} + +interface InjectedMethodError { + method: string; + accountId?: string; + error: { type: string; description?: string }; + remaining: number; +} + +interface CallContext { + seq: number; + /** Arguments that came from result references. */ + references: Set; +} + +function groupOf(type: DataType): TypeGroup { + return type === 'AddressBook' || type === 'ContactCard' ? 'contacts' : 'calendars'; +} + +function isContainer(type: DataType): type is ContainerType { + return type === 'AddressBook' || type === 'Calendar'; +} + +function containerOf(type: ItemType): ContainerType { + return type === 'ContactCard' ? 'AddressBook' : 'Calendar'; +} + +function parentProperty(type: ItemType): string { + return type === 'ContactCard' ? 'addressBookIds' : 'calendarIds'; +} + +function sideOf(type: DataType): Side { + return isContainer(type) ? 'container' : 'item'; +} + +function transportError(fault: Fault): Error & { retryAfterMs?: number } { + const [name, message] = { + network: ['NetworkError', 'Network request failed'], + timeout: ['RequestTimeoutError', 'Request timed out after 30s'], + auth: ['AuthenticationError', 'Session expired'], + rateLimit: ['RateLimitError', 'Rate limited by server'], + }[fault.kind]; + const error: Error & { retryAfterMs?: number } = new Error(message); + error.name = name; + if (fault.kind === 'rateLimit') error.retryAfterMs = fault.retryAfterMs; + return error; +} + +/** What jmap-client.ts throws for Stalwart's HTTP 400 request-level limit error. */ +function limitError(limit: string, detail: string): Error { + const body = JSON.stringify({ type: 'urn:ietf:params:jmap:error:limit', status: 400, limit, detail }); + return new Error(`JMAP request failed: 400 - ${body}`); +} + +// ─── The server ──────────────────────────────────────── + +export class FakeJmapServer { + /** Every request, in order, with its calls and what the server answered. */ + readonly requests: RequestRecord[] = []; + /** `/set` changes made with `sendSchedulingMessages: true` (no message is sent). */ + readonly scheduling: SchedulingRecord[] = []; + /** Blob downloads, in order. */ + readonly downloads: Array<{ accountId: string; blobId: string }> = []; + /** The most requests that were in flight at once (the engine keeps one per run). */ + maxInFlight = 0; + private inFlight = 0; + + private readonly accounts = new Map(); + private limits: FakeLimits; + private readonly customClock: (() => number) | null; + private manualTime: number; + private requestSeq = 0; + private uidLag = 0; + private uidCounter = 0; + private blobCounter = 0; + private modSeq = 0; + private sessionVersion = 0; + private faults: Fault[] = []; + private setErrors: InjectedSetError[] = []; + private methodErrors: InjectedMethodError[] = []; + private readonly beforeListeners = new Set(); + private readonly afterListeners = new Set(); + + constructor(options: FakeJmapServerOptions = {}) { + this.customClock = options.now ?? null; + this.manualTime = options.startTime ?? Date.UTC(2026, 0, 1); + this.limits = { ...DEFAULT_LIMITS, ...options.limits }; + } + + // ── Clock ── + + now(): number { + return this.customClock ? this.customClock() : this.manualTime; + } + + setTime(ms: number): void { + if (this.customClock) throw new Error('fake server: the clock was injected; move it instead'); + this.manualTime = ms; + } + + advanceTime(ms: number): void { + this.setTime(this.manualTime + ms); + } + + private nowUtc(): string { + return new Date(this.now()).toISOString().replace(/\.\d{3}Z$/, 'Z'); + } + + // ── Accounts and the port ── + + addAccount(id: string, options: AccountOptions): void { + if (!ID_PATTERN.test(id)) throw new Error(`fake server: invalid account id ${id}`); + if (this.accounts.has(id)) throw new Error(`fake server: account ${id} exists`); + const capabilities = options.capabilities ?? ['contacts', 'calendars']; + const perType = (make: () => T): Record => ({ + AddressBook: make(), + ContactCard: make(), + Calendar: make(), + CalendarEvent: make(), + }); + this.accounts.set(id, { + id, + name: options.name, + isPersonal: options.isPersonal ?? true, + isReadOnly: options.isReadOnly ?? false, + contacts: capabilities.includes('contacts'), + calendars: capabilities.includes('calendars'), + addresses: options.addresses ?? (options.name.includes('@') ? [options.name] : []), + lastChangeId: 0, + logs: { contacts: new ChangeLog(), calendars: new ChangeLog() }, + objects: perType(() => new Map()), + usedIds: perType(() => new Set()), + idCounter: perType(() => 0), + docCounter: perType(() => 0), + defaultContainer: {}, + blobs: new Map(), + }); + this.sessionVersion++; + } + + /** + * The engine's view of the server. `username` is the session's login + * (default: the first personal account's name); the primary account of a + * capability is the personal account named like it, else the first + * personal one that has the capability. + */ + port(username?: string): JmapPort { + return { + session: () => this.sessionView(username), + limits: () => ({ + maxObjectsInGet: this.limits.maxObjectsInGet, + maxObjectsInSet: this.limits.maxObjectsInSet, + maxCallsInRequest: this.limits.maxCallsInRequest, + maxConcurrentRequests: 4, + maxSizeRequest: this.limits.maxSizeRequest, + }), + request: (calls, using) => this.handleRequest(calls, using), + downloadBlob: (accountId, blobId) => this.handleDownload(accountId, blobId), + }; + } + + private sessionView(username?: string): JmapSessionView { + const accounts: Record = {}; + const all = [...this.accounts.values()]; + const personal = all.filter((a) => a.isPersonal); + const login = username ?? personal[0]?.name ?? ''; + const preferred = [...personal.filter((a) => a.name === login), ...personal.filter((a) => a.name !== login)]; + const primaryAccounts: Record = {}; + const contactsPrimary = preferred.find((a) => a.contacts); + const calendarsPrimary = preferred.find((a) => a.calendars); + if (contactsPrimary) primaryAccounts[JMAP_CONTACTS] = contactsPrimary.id; + if (calendarsPrimary) primaryAccounts[JMAP_CALENDARS] = calendarsPrimary.id; + for (const account of all) { + const accountCapabilities: Record = {}; + if (account.contacts) { + accountCapabilities[JMAP_CONTACTS] = { maxAddressBooksPerCard: null, mayCreateAddressBook: !account.isReadOnly }; + } + if (account.calendars) { + accountCapabilities[JMAP_CALENDARS] = { + maxCalendarsPerEvent: null, + minDateTime: '0001-01-01T00:00:00Z', + maxDateTime: '9999-12-31T23:59:59Z', + maxExpandedQueryDuration: 'P52W1D', + maxParticipantsPerEvent: 20, + mayCreateCalendar: !account.isReadOnly, + }; + } + accounts[account.id] = { + name: account.name, + isPersonal: account.isPersonal, + isReadOnly: account.isReadOnly, + accountCapabilities, + }; + } + const capabilities: Record = { + [JMAP_CORE]: { + maxSizeUpload: 50_000_000, + maxConcurrentUpload: 4, + maxSizeRequest: this.limits.maxSizeRequest, + maxConcurrentRequests: 4, + maxCallsInRequest: this.limits.maxCallsInRequest, + maxObjectsInGet: this.limits.maxObjectsInGet, + maxObjectsInSet: this.limits.maxObjectsInSet, + collationAlgorithms: [], + }, + }; + if (all.some((a) => a.contacts)) capabilities[JMAP_CONTACTS] = {}; + if (all.some((a) => a.calendars)) capabilities[JMAP_CALENDARS] = {}; + return { username: login, accounts, primaryAccounts, capabilities }; + } + + // ── Seeds and edits by another client ── + // Each call is one commit, so it shows up in `/changes` like any server-side + // change. Like a DAV client they skip the JMAP checks (uid uniqueness, + // rights) and get neither the organizer nor the default alerts a JMAP + // create adds; otherwise objects are normalised as Stalwart returns them. + + addAddressBook(accountId: string, book: Obj & { name: string }): string { + return this.insertContainer('AddressBook', accountId, book); + } + + addCalendar(accountId: string, calendar: Obj & { name: string }): string { + return this.insertContainer('Calendar', accountId, calendar); + } + + /** `addressBookIds` defaults to the default address book. */ + addCard(accountId: string, card: Obj = {}): string { + return this.insertItem('ContactCard', accountId, card); + } + + /** `calendarIds` defaults to the default calendar; `updated` is stamped unless given. */ + addEvent(accountId: string, event: Obj = {}): string { + return this.insertItem('CalendarEvent', accountId, event); + } + + serverCreate(type: DataType, accountId: string, object: Obj): string { + return isContainer(type) + ? this.insertContainer(type, accountId, object) + : this.insertItem(type, accountId, object); + } + + /** + * Another client patches an object (PatchObject keys, JSON Pointers + * included). Events get `updated` stamped unless the patch sets it. + */ + serverUpdate(type: DataType, accountId: string, id: string, patch: Obj): void { + const account = this.requireAccount(accountId); + const stored = this.requireObject(account, type, id); + const pending = new PendingChanges(); + if (isContainer(type)) { + const data = clone(stored.data); + for (const [key, value] of Object.entries(clone(patch))) { + if (key === 'id') continue; + if (key === 'isDefault') { + if (value === true) account.defaultContainer[type] = id; + continue; + } + if (key.includes('/')) { + if (!applyPointer(data, parsePointer(key), value)) throw new Error(`fake server: patch ${key} failed`); + } else { + assignOrDelete(data, key, value); + } + } + stored.data = data; + pending.update('container', id); + } else { + const draft: Draft = { data: clone(stored.data), parents: [...stored.parents], flags: { ...stored.flags } }; + const error = this.applyItemProperties(type, draft, clone(patch), id); + if (error) throw new Error(`fake server: ${error.type} ${error.properties?.join(',') ?? ''} ${error.description ?? ''}`); + for (const parent of draft.parents) this.requireObject(account, containerOf(type), parent); + if (type === 'ContactCard') { + draft.data = normalizeCard(draft.data); + } else { + const keepUpdated = hasOwn(patch, 'updated'); + if (!keepUpdated) delete draft.data.updated; + draft.data = normalizeEvent(draft.data, this.nowUtc(), 'server'); + } + Object.assign(stored, draft); + pending.update('item', id); + } + stored.modSeq = ++this.modSeq; + this.commit(account, groupOf(type), pending); + } + + /** + * Another client deletes an object. A container takes its items along, as + * `onDestroyRemoveContents` does: items only in it are destroyed, items in + * other containers too just lose the membership. + */ + serverDestroy(type: DataType, accountId: string, id: string): void { + const account = this.requireAccount(accountId); + this.requireObject(account, type, id); + const pending = new PendingChanges(); + account.objects[type].delete(id); + pending.delete(sideOf(type), id); + if (isContainer(type)) { + if (account.defaultContainer[type] === id) delete account.defaultContainer[type]; + const itemType: ItemType = type === 'AddressBook' ? 'ContactCard' : 'CalendarEvent'; + for (const item of [...account.objects[itemType].values()]) { + if (!item.parents.includes(id)) continue; + item.parents = item.parents.filter((p) => p !== id); + if (item.parents.length === 0) { + account.objects[itemType].delete(item.id); + pending.delete('item', item.id); + } else { + item.modSeq = ++this.modSeq; + pending.update('item', item.id); + } + } + } + this.commit(account, groupOf(type), pending); + } + + /** + * Rewrite a card's or event's map keys as Stalwart generates them for an + * object written over CardDAV/CalDAV without PROP-IDs/JSIDs: `k1…kN` by + * position for card entries and alerts, uuid5 of the address, name, uri or + * href for participants, locations, virtual locations and links. Logged + * as an update; nothing else changes (no `updated` stamp). + */ + rekeyPositionally(accountId: string, id: string, type?: ItemType): void { + const account = this.requireAccount(accountId); + const inCards = account.objects.ContactCard.has(id); + const inEvents = account.objects.CalendarEvent.has(id); + if (!type && inCards && inEvents) throw new Error(`fake server: ${id} is both a card and an event; pass the type`); + const resolved: ItemType | undefined = type ?? (inCards ? 'ContactCard' : inEvents ? 'CalendarEvent' : undefined); + if (!resolved) throw new Error(`fake server: no card or event ${id} in ${accountId}`); + const stored = this.requireObject(account, resolved, id); + if (resolved === 'ContactCard') rekeyCard(stored.data); + else rekeyEvent(stored.data); + stored.modSeq = ++this.modSeq; + const pending = new PendingChanges(); + pending.update('item', id); + this.commit(account, groupOf(resolved), pending); + } + + /** Store a blob for `downloadBlob` (e.g. a blob-backed `media` entry). */ + addBlob(accountId: string, data: Uint8Array | string, type = 'application/octet-stream'): string { + const account = this.requireAccount(accountId); + const blobId = `blob${++this.blobCounter}`; + const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data.slice(); + account.blobs.set(blobId, { bytes, type }); + return blobId; + } + + // ── Fault injection ── + + /** + * The next request (matching `opts.match`) throws the transport error + * jmap-client.ts would: NetworkError, RequestTimeoutError, + * AuthenticationError or RateLimitError with `retryAfterMs`. By default + * nothing of it runs; with `applied` it runs first (a lost response). + */ + failNextRequest(kind: FaultKind, opts: FaultOptions = {}): void { + this.faults.push(this.makeFault(kind, 'request', opts)); + } + + /** The next request runs (its writes are applied), then the response is lost: NetworkError. */ + applyThenLoseResponse(opts: Omit = {}): void { + this.failNextRequest('network', { ...opts, applied: true }); + } + + /** The next blob download throws the transport error for `kind`. */ + failNextDownload(kind: FaultKind, opts: Omit = {}): void { + this.faults.push(this.makeFault(kind, 'download', opts)); + } + + /** The next call of `method` (for `accountId`, if given) answers with this method-level error. */ + failNextMethod( + method: string, + error: { type: string; description?: string }, + opts: { accountId?: string; times?: number } = {}, + ): void { + this.methodErrors.push({ method, accountId: opts.accountId, error, remaining: opts.times ?? 1 }); + } + + /** + * The next `/set` touching `id` answers with `error` for that object (and + * applies nothing of it). `id` matches an update or destroy target, a + * creation id, or the `uid` of a create; or pass a predicate. + */ + setErrorFor( + type: ItemType, + accountId: string, + id: string | ((target: SetTarget) => boolean), + error: SetErrorShape, + times = 1, + ): void { + const match = + typeof id === 'function' + ? id + : (target: SetTarget) => + target.id === id || (target.op === 'create' && target.object?.uid === id); + this.setErrors.push({ type, accountId, match, error, remaining: times }); + } + + /** + * Trim the change log as Stalwart's daily purge does: keep the newest + * `keep` commits, mark the one before them as truncated, drop the rest. + * `/changes` from an older state then fails with `cannotCalculateChanges`. + */ + truncateChangeLog(accountId: string, group: TypeGroup | DataType, keep = 0): void { + const account = this.requireAccount(accountId); + const key: TypeGroup = group === 'contacts' || group === 'calendars' ? group : groupOf(group); + account.logs[key].truncate(keep); + } + + /** + * The `uid` query filter reads an index built after commit. With a lag of + * `n`, an object created during request R (or between R and R+1) is found + * by `uid` from request R+n+1 on. Applies to objects created afterwards. + */ + setUidIndexLag(n: number): void { + this.uidLag = Math.max(0, Math.floor(n)); + } + + setLimits(limits: Partial): void { + this.limits = { ...this.limits, ...limits }; + } + + /** Runs when a request arrives, before anything of it runs (e.g. a concurrent server edit). */ + onBeforeRequest(listener: RequestListener): () => void { + this.beforeListeners.add(listener); + return () => this.beforeListeners.delete(listener); + } + + /** Runs after a request ran, before its response reaches the caller (e.g. a device edit mid-upload). */ + onAfterRequest(listener: RequestListener): () => void { + this.afterListeners.add(listener); + return () => this.afterListeners.delete(listener); + } + + // ── Introspection ── + + /** Everything the server holds for an object: properties, parents and flags. */ + get(type: DataType, accountId: string, id: string): Obj | undefined { + const account = this.accounts.get(accountId); + const stored = account?.objects[type].get(id); + return account && stored ? this.snapshot(type, account, stored) : undefined; + } + + all(type: DataType, accountId: string): Obj[] { + const account = this.requireAccount(accountId); + return [...account.objects[type].values()].map((stored) => this.snapshot(type, account, stored)); + } + + state(type: DataType, accountId: string): string { + return this.stateOf(this.requireAccount(accountId), type); + } + + /** Method calls sent so far, in order; only those named `name` when given. */ + calls(name?: string): JmapInvocation[] { + const all = this.requests.flatMap((r) => r.calls); + return name ? all.filter(([method]) => method === name) : all; + } + + // ── Requests ── + + private async handleRequest(calls: JmapInvocation[], using: string[]): Promise { + this.inFlight++; + this.maxInFlight = Math.max(this.maxInFlight, this.inFlight); + try { + // Yield like a round trip, so requests a caller does not await overlap. + await Promise.resolve(); + return await this.runRequest(calls, using); + } finally { + this.inFlight--; + } + } + + private async runRequest(calls: JmapInvocation[], using: string[]): Promise { + const seq = ++this.requestSeq; + const record: RequestRecord = { + seq, + using: [...using], + calls: clone(calls), + methods: calls.map(([name]) => name), + responses: null, + outcome: 'ok', + }; + this.requests.push(record); + for (const listener of this.beforeListeners) await listener(record); + const fault = this.takeFault('request', calls); + if (fault && !fault.applied) { + record.outcome = 'failed'; + record.fault = fault.kind; + throw transportError(fault); + } + if (calls.length > this.limits.maxCallsInRequest) { + record.outcome = 'rejected'; + throw limitError('maxCallsInRequest', 'The request exceeds the maximum number of calls in a single request.'); + } + const size = new TextEncoder().encode(JSON.stringify({ using, methodCalls: calls })).length; + if (size > this.limits.maxSizeRequest) { + record.outcome = 'rejected'; + throw limitError('maxSizeRequest', 'The request is larger than the server is willing to process.'); + } + const responses: JmapInvocation[] = []; + for (const call of clone(calls)) responses.push(this.executeCall(call, using, responses, seq)); + record.responses = clone(responses); + for (const listener of this.afterListeners) await listener(record); + if (fault) { + record.outcome = 'lost'; + record.fault = fault.kind; + throw transportError(fault); + } + return { methodResponses: responses, sessionState: `fs${this.sessionVersion}` }; + } + + private async handleDownload(accountId: string, blobId: string): Promise { + this.downloads.push({ accountId, blobId }); + const fault = this.takeFault('download', null); + if (fault) throw transportError(fault); + const blob = this.accounts.get(accountId)?.blobs.get(blobId); + if (!blob) throw new Error('Failed to fetch blob: 404'); + return blob.bytes.slice(); + } + + private makeFault(kind: FaultKind, target: Fault['target'], opts: FaultOptions): Fault { + const match = opts.match; + return { + kind, + target, + applied: opts.applied ?? false, + retryAfterMs: opts.retryAfterMs ?? 1000, + remaining: opts.times ?? 1, + match: + typeof match === 'string' + ? (calls: JmapInvocation[]) => calls.some(([name]) => name === match) + : match, + }; + } + + private takeFault(target: Fault['target'], calls: JmapInvocation[] | null): Fault | null { + const index = this.faults.findIndex( + (f) => f.target === target && (!f.match || (calls !== null && f.match(calls))), + ); + if (index < 0) return null; + const fault = this.faults[index]; + if (--fault.remaining <= 0) this.faults.splice(index, 1); + return fault; + } + + private executeCall( + call: JmapInvocation, + using: string[], + prior: JmapInvocation[], + seq: number, + ): JmapInvocation { + const [name, rawArgs, callId] = call; + try { + if (!SUPPORTED_METHODS.has(name)) { + throw new MethodError('unknownMethod', `fake server: ${name} is not emulated`); + } + if (!isObj(rawArgs)) throw new MethodError('invalidArguments'); + const { args, references } = this.resolveReferences(rawArgs, prior); + const capability = CAPABILITY_OF[name.split('/')[0]]; + if (!using.includes(capability)) { + throw new MethodError( + 'unknownMethod', + `Method ${name} requires capability ${capability} which is not present in the "using" property.`, + ); + } + const injected = this.takeMethodError(name, args.accountId); + if (injected) throw new MethodError(injected.type, injected.description); + return [name, this.dispatch(name, args, { seq, references }), callId]; + } catch (err) { + if (!(err instanceof MethodError)) throw err; + return ['error', err.detail ? { type: err.type, description: err.detail } : { type: err.type }, callId]; + } + } + + private resolveReferences(raw: Obj, prior: JmapInvocation[]): { args: Obj; references: Set } { + const args: Obj = {}; + const references = new Set(); + for (const [key, value] of Object.entries(raw)) if (!key.startsWith('#')) setKey(args, key, value); + for (const [key, ref] of Object.entries(raw)) { + if (!key.startsWith('#')) continue; + const target = key.slice(1); + if (hasOwn(args, target)) throw new MethodError('invalidArguments', `Both ${target} and ${key} are given.`); + if (!isObj(ref) || typeof ref.resultOf !== 'string' || typeof ref.name !== 'string' || typeof ref.path !== 'string') { + throw new MethodError('invalidArguments', `Invalid result reference in ${key}.`); + } + // Stalwart resolves references to /get, /changes, /query and + // /queryChanges responses only; one to a /set is not found (verified). + const notFound = new MethodError('invalidResultReference', `Result reference to ${ref.resultOf}#${ref.name} not found.`); + const source = prior.find(([name, , id]) => id === ref.resultOf && name === ref.name); + if (!source || !REFERENCEABLE.test(source[0])) throw notFound; + const resolved = resolveReferencePath(source[1], ref.path); + if (!resolved) throw notFound; + setKey(args, target, clone(resolved.value)); + references.add(target); + } + return { args, references }; + } + + private takeMethodError(method: string, accountId: unknown): { type: string; description?: string } | null { + const index = this.methodErrors.findIndex( + (e) => e.method === method && (e.accountId === undefined || e.accountId === accountId), + ); + if (index < 0) return null; + const injected = this.methodErrors[index]; + if (--injected.remaining <= 0) this.methodErrors.splice(index, 1); + return injected.error; + } + + private dispatch(name: string, args: Obj, ctx: CallContext): Obj { + if (name === 'Core/echo') return args; + const [object, verb] = name.split('/') as [DataType, string]; + const account = this.methodAccount(args, object); + switch (verb) { + case 'get': + return this.methodGet(object, account, args); + case 'changes': + return this.methodChanges(object, account, args); + case 'query': + return this.methodQuery(object as ItemType, account, args, ctx); + case 'set': + return this.methodSet(object as ItemType, account, args, ctx); + default: + throw new MethodError('unknownMethod'); + } + } + + private methodAccount(args: Obj, type: DataType): Account { + if (typeof args.accountId !== 'string') throw new MethodError('invalidArguments', 'accountId is required.'); + const account = this.accounts.get(args.accountId); + if (!account) throw new MethodError('accountNotFound'); + if (!(groupOf(type) === 'contacts' ? account.contacts : account.calendars)) { + throw new MethodError('accountNotSupportedByMethod'); + } + return account; + } + + // ── /get ── + + private methodGet(type: DataType, account: Account, args: Obj): Obj { + const { ids, properties } = args; + if (ids != null && !Array.isArray(ids)) throw new MethodError('invalidArguments', 'ids must be an array or null.'); + if (properties != null && !Array.isArray(properties)) { + throw new MethodError('invalidArguments', 'properties must be an array or null.'); + } + if (Array.isArray(ids) && ids.length > this.limits.maxObjectsInGet) throw new MethodError('requestTooLarge'); + const store = account.objects[type]; + // `ids: null` silently returns only the first maxObjectsInGet objects (verified). + const targets: unknown[] = Array.isArray(ids) + ? ids + : [...store.keys()].slice(0, this.limits.maxObjectsInGet); + const list: Obj[] = []; + const notFound: unknown[] = []; + for (const id of targets) { + const stored = typeof id === 'string' ? store.get(id) : undefined; + if (stored) list.push(this.render(type, account, stored, properties as unknown[] | null | undefined)); + else notFound.push(id); + } + return { accountId: account.id, state: this.stateOf(account, type), list, notFound }; + } + + private render(type: DataType, account: Account, stored: Stored, properties: unknown[] | null | undefined): Obj { + const listed = properties ? properties.filter((p): p is string => typeof p === 'string') : null; + if (isContainer(type)) { + const full: Obj = { id: stored.id, ...clone(stored.data), isDefault: this.defaultContainerId(account, type) === stored.id }; + if (!listed) return full; + const out: Obj = { id: stored.id }; + for (const p of listed) setKey(out, p, hasOwn(full, p) ? full[p] : null); + return out; + } + const data = clone(stored.data); + if (type === 'ContactCard') { + if (!listed) return { id: stored.id, addressBookIds: booleanSet(stored.parents), ...data }; + const out: Obj = { id: stored.id }; + for (const p of listed) { + if (p === 'addressBookIds') out.addressBookIds = booleanSet(stored.parents); + else if (hasOwn(data, p)) setKey(out, p, data[p]); + } + return out; + } + // CalendarEvent: with `properties: null` Stalwart returns the JSCalendar + // properties plus only id, calendarIds, isDraft and isOrigin; the other + // JMAP-only fields (useDefaultAlerts, …) need an explicit list (verified). + const jmap: Obj = { + calendarIds: booleanSet(stored.parents), + isDraft: stored.flags.isDraft === true, + isOrigin: this.isOrigin(account, stored.data), + useDefaultAlerts: stored.flags.useDefaultAlerts === true, + mayInviteSelf: stored.flags.mayInviteSelf === true, + mayInviteOthers: stored.flags.mayInviteOthers === true, + hideAttendees: stored.flags.hideAttendees === true, + baseEventId: null, + }; + if (!listed) { + return { ...data, id: stored.id, calendarIds: jmap.calendarIds, isDraft: jmap.isDraft, isOrigin: jmap.isOrigin }; + } + const out: Obj = { id: stored.id }; + for (const p of listed) { + if (hasOwn(jmap, p)) setKey(out, p, jmap[p]); + else if (hasOwn(data, p)) setKey(out, p, data[p]); + } + return out; + } + + private snapshot(type: DataType, account: Account, stored: Stored): Obj { + if (isContainer(type)) { + return { id: stored.id, ...clone(stored.data), isDefault: this.defaultContainerId(account, type) === stored.id }; + } + return { + id: stored.id, + [parentProperty(type)]: booleanSet(stored.parents), + ...clone(stored.data), + ...clone(stored.flags), + }; + } + + private isOrigin(account: Account, data: Obj): boolean { + const organizer = data.organizerCalendarAddress; + if (typeof organizer !== 'string') return true; + const address = organizer.replace(/^mailto:/i, '').toLowerCase(); + return account.addresses.some((a) => a.toLowerCase() === address); + } + + // ── /changes ── + + /** jmap/src/changes/get.rs, step by step. */ + private methodChanges(type: DataType, account: Account, args: Obj): Obj { + const since = args.sinceState; + if (typeof since !== 'string') throw new MethodError('invalidArguments', 'sinceState is required.'); + const parsed = parseState(since); + if (!parsed) throw new MethodError('invalidArguments', `Invalid state ${since}.`); + const maxArg = args.maxChanges; + if (maxArg != null && (typeof maxArg !== 'number' || !Number.isInteger(maxArg) || maxArg < 0)) { + throw new MethodError('invalidArguments', 'maxChanges must be a positive integer.'); + } + const max = Math.min(typeof maxArg === 'number' && maxArg > 0 ? maxArg : Infinity, this.limits.maxChanges); + const side = sideOf(type); + const log = account.logs[groupOf(type)]; + const response = { + accountId: account.id, + oldState: since, + newState: 'n', + hasMoreChanges: false, + created: [] as string[], + updated: [] as string[], + destroyed: [] as string[], + }; + let newState: ParsedState = { kind: 'initial' }; + let itemsSent = 0; + let window: ChangeWindow; + if (parsed.kind === 'initial') { + window = log.query({ all: true }); + if (window.changes.size === 0 && window.fromChangeId === 0) return response; + } else if (parsed.kind === 'exact') { + const last = log.sideChangeId(side); + newState = stateOfChangeId(last); + if (last === parsed.id) return { ...response, newState: encodeState(newState) }; + window = log.query({ since: parsed.id }); + } else { + window = log.query({ from: parsed.from, to: parsed.to }); + const total = [...window.changes.values()].filter((c) => c.side === side).length; + if (parsed.sent >= total) window = log.query({ since: parsed.to }); + else itemsSent = parsed.sent; + } + if ((window.isTruncated || window.fromChangeId === 0) && parsed.kind !== 'initial') { + throw new MethodError( + 'cannotCalculateChanges', + window.isTruncated ? 'Change log is truncated' : 'Since state is invalid', + ); + } + const changes = [...window.changes.values()].filter((c) => c.side === side); + for (const change of changes.slice(itemsSent, itemsSent + max)) { + if (change.op === 'insert') response.created.push(change.id); + else if (change.op === 'update') response.updated.push(change.id); + else response.destroyed.push(change.id); + } + const changeId = (side === 'container' ? window.containerChangeId : window.itemChangeId) ?? window.toChangeId; + response.hasMoreChanges = changes.length > itemsSent + max; + if (response.hasMoreChanges) { + newState = { kind: 'intermediate', from: window.fromChangeId, to: changeId, sent: itemsSent + max }; + } else if (newState.kind === 'initial') { + newState = { kind: 'exact', id: changeId }; + } + response.newState = encodeState(newState); + return response; + } + + // ── /query ── + + private methodQuery(type: ItemType, account: Account, args: Obj, ctx: CallContext): Obj { + if (args.anchor != null) throw new MethodError('invalidArguments', 'fake server: anchor is not emulated'); + if (args.expandRecurrences === true) { + throw new MethodError('invalidArguments', 'fake server: expandRecurrences is not emulated'); + } + const inParent = type === 'ContactCard' ? 'inAddressBook' : 'inCalendar'; + const validate = (filter: unknown): void => { + if (filter == null) return; + if (!isObj(filter)) throw new MethodError('invalidArguments', 'filter must be an object.'); + if (filter.operator !== undefined) { + if (!['AND', 'OR', 'NOT'].includes(String(filter.operator)) || !Array.isArray(filter.conditions)) { + throw new MethodError('unsupportedFilter', `Invalid filter operator ${String(filter.operator)}.`); + } + filter.conditions.forEach(validate); + return; + } + for (const key of Object.keys(filter)) { + if (key !== inParent && key !== 'uid') { + throw new MethodError('unsupportedFilter', `fake server: filter ${key} is not emulated`); + } + } + }; + // `inAddressBook`/`inCalendar` read the synchronous cache; `uid` reads + // the search index, which lags behind writes (verified). + const matches = (stored: Stored, filter: unknown): boolean => { + if (!isObj(filter)) return true; + if (filter.operator !== undefined) { + const conditions = filter.conditions as unknown[]; + if (filter.operator === 'AND') return conditions.every((c) => matches(stored, c)); + if (filter.operator === 'OR') return conditions.some((c) => matches(stored, c)); + return !conditions.some((c) => matches(stored, c)); + } + if (filter[inParent] !== undefined && !stored.parents.includes(filter[inParent] as string)) return false; + if (filter.uid !== undefined && (stored.data.uid !== filter.uid || stored.uidVisibleFrom > ctx.seq)) return false; + return true; + }; + validate(args.filter); + let results = [...account.objects[type].values()].filter((stored) => matches(stored, args.filter)); + results = this.sortResults(type, results, args.sort); + + const total = results.length; + const cap = this.limits.maxQueryResults; + const requested = args.limit; + if (requested != null && (typeof requested !== 'number' || requested < 0)) { + throw new MethodError('invalidArguments', 'limit must be a positive integer.'); + } + const limit = typeof requested === 'number' ? Math.min(requested, cap) : cap; + const take = Math.min(limit, total); + const position = typeof args.position === 'number' ? Math.trunc(args.position) : 0; + let ids: string[]; + let responsePosition: number; + if (position >= 0) { + ids = results.slice(position, position + take).map((s) => s.id); + responsePosition = position <= total ? position : 0; + } else { + const start = Math.max(0, total + position); + ids = results.slice(start, start + take).map((s) => s.id); + responsePosition = start; + } + const response: Obj = { + accountId: account.id, + queryState: this.stateOf(account, type), + canCalculateChanges: true, + position: responsePosition, + ids, + }; + if (args.calculateTotal === true) response.total = total; + if (total > limit) response.limit = limit; + return response; + } + + private sortResults(type: ItemType, results: Stored[], sort: unknown): Stored[] { + if (sort == null) return results; + if (!Array.isArray(sort)) throw new MethodError('invalidArguments', 'sort must be an array.'); + const comparators = sort.map((comparator) => { + const property = isObj(comparator) ? comparator.property : undefined; + const ascending = !isObj(comparator) || comparator.isAscending !== false; + const allowed = type === 'ContactCard' ? ['created', 'updated'] : ['start', 'recurrenceId', 'uid']; + if (typeof property !== 'string' || !allowed.includes(property)) { + throw new MethodError('unsupportedSort', String(property)); + } + return (a: Stored, b: Stored): number => { + let diff: number; + if (property === 'created') diff = a.docId - b.docId; + else if (property === 'updated') diff = a.modSeq - b.modSeq; + else { + const field = property === 'uid' ? 'uid' : 'start'; + diff = String(a.data[field] ?? '').localeCompare(String(b.data[field] ?? '')); + } + return ascending ? diff : -diff; + }; + }); + return [...results].sort((a, b) => { + for (const compare of comparators) { + const diff = compare(a, b); + if (diff !== 0) return diff; + } + return a.docId - b.docId; + }); + } + + // ── /set ── + + private methodSet(type: ItemType, account: Account, args: Obj, ctx: CallContext): Obj { + const create = args.create ?? null; + const update = args.update ?? null; + const destroy = args.destroy ?? null; + if ((create !== null && !isObj(create)) || (update !== null && !isObj(update)) || (destroy !== null && !Array.isArray(destroy))) { + throw new MethodError('invalidArguments'); + } + const creates = Object.entries(create ?? {}); + const updates = Object.entries(update ?? {}); + const destroys = (destroy ?? []) as unknown[]; + // A destroy given as a result reference does not count (jmap-proto method/set.rs). + const counted = creates.length + updates.length + (ctx.references.has('destroy') ? 0 : destroys.length); + if (counted > this.limits.maxObjectsInSet) throw new MethodError('requestTooLarge'); + const oldState = this.stateOf(account, type); + if (args.ifInState != null) { + if (typeof args.ifInState !== 'string' || !parseState(args.ifInState)) { + throw new MethodError('invalidArguments', 'Invalid ifInState.'); + } + if (args.ifInState !== oldState) throw new MethodError('stateMismatch'); + } + if (account.isReadOnly) throw new MethodError('accountReadOnly'); + + const store = account.objects[type]; + const scheduling = type === 'CalendarEvent' && args.sendSchedulingMessages === true; + const record = (op: SchedulingRecord['op'], id: string) => { + if (scheduling) this.scheduling.push({ accountId: account.id, op, id, requestSeq: ctx.seq }); + }; + const pending = new PendingChanges(); + const createdInCall = new Set(); + const willDestroy = new Set(destroys.filter((id): id is string => typeof id === 'string')); + const created: Obj = {}; + const notCreated: Obj = {}; + const updated: Obj = {}; + const notUpdated: Obj = {}; + const destroyed: string[] = []; + const notDestroyed: Obj = {}; + + for (const [creationId, object] of creates) { + const injected = this.takeSetError(type, account.id, { + op: 'create', + id: creationId, + object: isObj(object) ? object : undefined, + }); + if (injected) { + notCreated[creationId] = injected; + continue; + } + if (!isObj(object)) { + notCreated[creationId] = { type: 'invalidProperties', description: 'Invalid object.' }; + continue; + } + const draft = this.prepareCreate(type, account, object, createdInCall); + if ('error' in draft) { + notCreated[creationId] = draft.error; + continue; + } + const id = this.mintId(account, type); + this.storeNew(account, type, id, draft); + createdInCall.add(id); + pending.insert('item', id); + created[creationId] = { id }; + record('create', id); + } + + for (const [id, patch] of updates) { + if (willDestroy.has(id)) { + notUpdated[id] = { type: 'willDestroy' }; + continue; + } + const injected = this.takeSetError(type, account.id, { op: 'update', id, object: isObj(patch) ? patch : undefined }); + if (injected) { + notUpdated[id] = injected; + continue; + } + const stored = store.get(id); + if (!stored || createdInCall.has(id)) { + notUpdated[id] = { type: 'notFound' }; + continue; + } + if (!isObj(patch)) { + notUpdated[id] = { type: 'invalidPatch' }; + continue; + } + const draft = this.prepareUpdate(type, account, stored, patch); + if ('error' in draft) { + notUpdated[id] = draft.error; + continue; + } + Object.assign(stored, draft); + stored.modSeq = ++this.modSeq; + pending.update('item', id); + updated[id] = null; + record('update', id); + } + + for (const id of destroys) { + const key = String(id); + const injected = this.takeSetError(type, account.id, { op: 'destroy', id: key }); + if (injected) { + notDestroyed[key] = injected; + continue; + } + const stored = typeof id === 'string' ? store.get(id) : undefined; + if (!stored || createdInCall.has(key)) { + notDestroyed[key] = { type: 'notFound' }; + continue; + } + const denied = stored.parents.find((p) => !this.canWrite(account, containerOf(type), p)); + if (denied) { + notDestroyed[key] = { + type: 'forbidden', + description: + type === 'ContactCard' + ? `You are not allowed to remove contacts from address book ${denied}.` + : `You are not allowed to remove calendar events from calendar ${denied}.`, + }; + continue; + } + store.delete(key); + pending.delete('item', key); + destroyed.push(key); + record('destroy', key); + } + + const changeId = this.commit(account, groupOf(type), pending); + // Stalwart leaves empty maps and lists out of a /set response; creates + // report only `{id}` and updates `null` (verified). + const response: Obj = { + accountId: account.id, + oldState, + newState: changeId === null ? oldState : encodeState({ kind: 'exact', id: changeId }), + }; + if (Object.keys(created).length) response.created = created; + if (Object.keys(updated).length) response.updated = updated; + if (destroyed.length) response.destroyed = destroyed; + if (Object.keys(notCreated).length) response.notCreated = notCreated; + if (Object.keys(notUpdated).length) response.notUpdated = notUpdated; + if (Object.keys(notDestroyed).length) response.notDestroyed = notDestroyed; + return response; + } + + private takeSetError(type: ItemType, accountId: string, target: SetTarget): SetErrorShape | null { + const index = this.setErrors.findIndex((e) => e.type === type && e.accountId === accountId && e.match(target)); + if (index < 0) return null; + const injected = this.setErrors[index]; + if (--injected.remaining <= 0) this.setErrors.splice(index, 1); + return clone(injected.error); + } + + /** + * Apply a create object or PatchObject the way `update_contact_card` and + * `update_calendar_event` do: keys with `/` are JSON Pointers (a missing + * parent fails the patch, verified), parent ids take a whole map or + * `/: true|false|null`, unknown top-level names are invalid. + */ + private applyItemProperties(type: ItemType, draft: Draft, props: Obj, expectedId: string | null): SetErrorShape | null { + const parentProp = parentProperty(type); + for (const [key, value] of Object.entries(props)) { + if (key.includes('/')) { + const segments = parsePointer(key); + const first = segments[0]; + if (first?.kind === 'key' && first.key === parentProp) { + const second = segments[1]; + const parentId = second?.kind === 'key' ? second.key : undefined; + if (parentId === undefined || (value !== true && value !== false && value !== null)) { + return invalidProperties([parentProp], `Invalid patch operation for ${parentProp}.`); + } + draft.parents = draft.parents.filter((p) => p !== parentId); + if (value === true) draft.parents.push(parentId); + continue; + } + if (!applyPointer(draft.data, segments, value)) return invalidProperties([key], 'Patch operation failed.'); + continue; + } + if (key === parentProp) { + if (!isObj(value)) return invalidProperties([parentProp], `Invalid patch operation for ${parentProp}.`); + const next = Object.keys(value).filter((id) => value[id] === true); + draft.parents = [...draft.parents.filter((p) => next.includes(p)), ...next.filter((p) => !draft.parents.includes(p))]; + continue; + } + if (key === 'id') { + if (expectedId === null || value !== expectedId) { + return invalidProperties(['id'], type === 'ContactCard' ? 'The id property is immutable.' : 'This property is immutable.'); + } + continue; + } + if (type === 'CalendarEvent') { + if (IMMUTABLE_EVENT_PROPERTIES.has(key)) return invalidProperties([key], 'This property is immutable.'); + if (EVENT_FLAGS.includes(key)) { + if (typeof value !== 'boolean') return invalidProperties([key], 'Invalid value.'); + draft.flags[key] = value; + continue; + } + if (key === 'utcStart' || key === 'utcEnd') { + return invalidProperties([key], 'fake server: utcStart/utcEnd writes are not emulated'); + } + if ((key === 'participants' || key === 'locations') && isObj(value) && Object.values(value).some(hasBlobLink)) { + return invalidProperties([key], 'blobIds in links is not supported.'); + } + } else if (key === 'media' && isObj(value) && Object.values(value).some((m) => isObj(m) && hasOwn(m, 'blobId'))) { + return invalidProperties(['media'], 'blobIds in media is not supported.'); + } + if (!(type === 'ContactCard' ? CARD_PROPERTIES : EVENT_PROPERTIES).has(key)) { + return invalidProperties([key], 'Invalid property.'); + } + assignOrDelete(draft.data, key, value); + } + if (draft.parents.length === 0) { + return invalidProperties( + [parentProp], + type === 'ContactCard' + ? 'Contact has to belong to at least one address book.' + : 'Event has to belong to at least one calendar.', + ); + } + return null; + } + + private prepareCreate(type: ItemType, account: Account, object: Obj, createdInCall: Set): Draft | { error: SetErrorShape } { + const draft: Draft = { data: {}, parents: [], flags: {} }; + const error = this.applyItemProperties(type, draft, object, null); + if (error) return { error }; + const containerType = containerOf(type); + for (const parent of draft.parents) { + if (!account.objects[containerType].has(parent)) { + return { + error: invalidProperties( + [parentProperty(type)], + type === 'ContactCard' ? `addressBookId ${parent} does not exist.` : `calendarId ${parent} does not exist.`, + ), + }; + } + if (!this.canWrite(account, containerType, parent)) { + return { + error: { + type: 'forbidden', + description: + type === 'ContactCard' + ? `You are not allowed to add contacts to address book ${parent}.` + : `You are not allowed to add calendar events to calendar ${parent}.`, + }, + }; + } + } + // Both uid checks read what was committed before this call, so two + // creates with one uid in the same call both pass (as on Stalwart). + const earlier = [...account.objects[type].values()].filter((s) => !createdInCall.has(s.id)); + if (type === 'ContactCard') { + draft.data = normalizeCard(draft.data); + const uid = draft.data.uid; + // Contact uids are unique per address book; the error names the card (verified). + const clash = typeof uid === 'string' + ? earlier.find((s) => s.data.uid === uid && s.parents.some((p) => draft.parents.includes(p))) + : undefined; + if (clash) { + return { error: invalidProperties(['uid'], `Contact with UID ${String(uid)} already exists with id ${clash.id}.`) }; + } + return draft; + } + // Stalwart generates a UUID when the create has no uid. + if (typeof draft.data.uid !== 'string') draft.data.uid = this.mintUid(); + if (draft.flags.useDefaultAlerts === true) this.addDefaultAlerts(account, draft); + draft.data = normalizeEvent(draft.data, this.nowUtc(), 'jmap'); + this.assignOrganizer(account, draft.data); + // Event uids are unique per account; the error names no event (verified). + const uid = draft.data.uid; + if (earlier.some((s) => s.data.uid === uid)) { + return { error: invalidProperties(['uid'], `An event with UID ${String(uid)} already exists.`) }; + } + return draft; + } + + private prepareUpdate(type: ItemType, account: Account, stored: Stored, patch: Obj): Draft | { error: SetErrorShape } { + const draft: Draft = { data: clone(stored.data), parents: [...stored.parents], flags: { ...stored.flags } }; + const error = this.applyItemProperties(type, draft, patch, stored.id); + if (error) return { error }; + if (type === 'ContactCard') { + draft.data = normalizeCard(draft.data); + } else { + draft.data = normalizeEvent(draft.data, this.nowUtc(), 'jmap'); + this.assignOrganizer(account, draft.data); + } + // A uid can be stripped (`uid: null`) but not changed or added. + if (draft.data.uid !== undefined && draft.data.uid !== stored.data.uid) { + return { + error: invalidProperties( + ['uid'], + type === 'ContactCard' ? 'You cannot change the UID of a contact.' : 'You cannot change the UID of a calendar event.', + ), + }; + } + const containerType = containerOf(type); + const noun = type === 'ContactCard' ? 'contacts' : 'calendar events'; + const place = type === 'ContactCard' ? 'address book' : 'calendar'; + for (const parent of draft.parents) { + if (stored.parents.includes(parent)) continue; + if (!account.objects[containerType].has(parent)) { + return { + error: invalidProperties( + [parentProperty(type)], + type === 'ContactCard' ? `addressBookId ${parent} does not exist.` : `calendarId ${parent} does not exist.`, + ), + }; + } + if (!this.canWrite(account, containerType, parent)) { + return { error: { type: 'forbidden', description: `You are not allowed to add ${noun} to ${place} ${parent}.` } }; + } + } + for (const parent of stored.parents) { + if (draft.parents.includes(parent) || this.canWrite(account, containerType, parent)) continue; + return { error: { type: 'forbidden', description: `You are not allowed to remove ${noun} from ${place} ${parent}.` } }; + } + for (const parent of draft.parents) { + if (!stored.parents.includes(parent) || this.canWrite(account, containerType, parent)) continue; + return { error: { type: 'forbidden', description: `You are not allowed to modify ${place} ${parent}.` } }; + } + return draft; + } + + /** + * `useDefaultAlerts: true` on create copies the calendars' default alerts + * into the event as alarms without a JSID, so they read back with the + * positional keys `k` after the event's own alerts. (Stalwart can then + * emit a duplicate key; here a taken key moves to the next free one.) + */ + private addDefaultAlerts(account: Account, draft: Draft): void { + const withoutTime = draft.data.showWithoutTime === true; + const alerts: Obj = isObj(draft.data.alerts) ? draft.data.alerts : {}; + let position = Object.keys(alerts).length; + for (const calendarId of draft.parents) { + const calendar = account.objects.Calendar.get(calendarId); + const defaults = calendar?.data[withoutTime ? 'defaultAlertsWithoutTime' : 'defaultAlertsWithTime']; + if (!isObj(defaults)) continue; + for (const alert of Object.values(defaults)) { + let key = `k${++position}`; + while (hasOwn(alerts, key)) key = `k${++position}`; + setKey(alerts, key, clone(alert)); + } + } + if (Object.keys(alerts).length > 0) draft.data.alerts = alerts; + } + + /** + * `itip_assign_organizer`: an event whose participants include attendees + * but that has no organizer gets the account's first address as organizer, + * which reads back as `organizerCalendarAddress` plus an owner participant + * keyed uuid5 of the address (merged into a participant with that address, + * if any). Approximated: only the master, and "attendee" means a + * participant with a calendar address that is not only an owner. + */ + private assignOrganizer(account: Account, event: Obj): void { + const address = account.addresses[0]; + const participants = event.participants; + if (!address || typeof event.organizerCalendarAddress === 'string' || !isObj(participants)) return; + const hasAttendee = Object.values(participants).some((p) => { + if (!isObj(p) || typeof p.calendarAddress !== 'string') return false; + const roles = isObj(p.roles) ? Object.keys(p.roles).filter((role) => (p.roles as Obj)[role] === true) : []; + return !(roles.length === 1 && roles[0] === 'owner'); + }); + if (!hasAttendee) return; + const uri = `mailto:${address}`; + event.organizerCalendarAddress = uri; + const existing = Object.values(participants).find((p) => isObj(p) && p.calendarAddress === uri); + if (isObj(existing)) { + existing.roles = { ...(isObj(existing.roles) ? existing.roles : {}), owner: true }; + } else { + setKey(participants, uuid5(uri), { '@type': 'Participant', calendarAddress: uri, roles: { owner: true } }); + } + } + + // ── Storage ── + + private insertContainer(type: ContainerType, accountId: string, input: Obj): string { + const account = this.requireAccount(accountId); + const { id: explicitId, isDefault, ...rest } = clone(input); + const id = this.mintId(account, type, explicitId); + const data = { ...clone(type === 'AddressBook' ? ADDRESS_BOOK_DEFAULTS : CALENDAR_DEFAULTS), ...rest }; + if (isDefault === true) account.defaultContainer[type] = id; + this.storeNew(account, type, id, { data, parents: [], flags: {} }); + const pending = new PendingChanges(); + pending.insert('container', id); + this.commit(account, groupOf(type), pending); + return id; + } + + private insertItem(type: ItemType, accountId: string, input: Obj): string { + const account = this.requireAccount(accountId); + const containerType = containerOf(type); + const object = clone(input); + const explicitId = object.id; + const parentInput = object[parentProperty(type)]; + delete object.id; + delete object[parentProperty(type)]; + let parents: string[]; + if (parentInput === undefined) { + const fallback = this.defaultContainerId(account, containerType); + if (!fallback) throw new Error(`fake server: ${accountId} has no ${containerType} for the new ${type}`); + parents = [fallback]; + } else if (isObj(parentInput)) { + parents = Object.keys(parentInput).filter((p) => parentInput[p] === true); + } else { + throw new Error(`fake server: ${parentProperty(type)} must be a map`); + } + for (const parent of parents) this.requireObject(account, containerType, parent); + const flags: Obj = {}; + if (type === 'CalendarEvent') { + for (const flag of EVENT_FLAGS) { + if (!hasOwn(object, flag)) continue; + flags[flag] = object[flag]; + delete object[flag]; + } + } + const data = type === 'ContactCard' ? normalizeCard(object) : normalizeEvent(object, this.nowUtc(), 'server'); + const id = this.mintId(account, type, explicitId); + this.storeNew(account, type, id, { data, parents, flags }); + const pending = new PendingChanges(); + pending.insert('item', id); + this.commit(account, groupOf(type), pending); + return id; + } + + private storeNew(account: Account, type: DataType, id: string, draft: Draft): void { + account.objects[type].set(id, { + id, + docId: ++account.docCounter[type], + data: draft.data, + parents: draft.parents, + flags: draft.flags, + uidVisibleFrom: this.uidLag === 0 ? 0 : this.requestSeq + this.uidLag + 1, + modSeq: ++this.modSeq, + }); + } + + /** ``, per account and type; ids are never reused. */ + private mintId(account: Account, type: DataType, explicit?: unknown): string { + const used = account.usedIds[type]; + if (explicit !== undefined) { + if (typeof explicit !== 'string' || !ID_PATTERN.test(explicit)) throw new Error(`fake server: invalid id ${String(explicit)}`); + if (used.has(explicit)) throw new Error(`fake server: ${type} id ${explicit} was used before (ids are never reused)`); + used.add(explicit); + return explicit; + } + let id: string; + do { + id = `${ID_PREFIX[type]}${(++account.idCounter[type]).toString(36)}`; + } while (used.has(id)); + used.add(id); + return id; + } + + private mintUid(): string { + return `00000000-0000-4000-8000-${(++this.uidCounter).toString(16).padStart(12, '0')}`; + } + + /** One commit: a new account-wide change id in the collection's log. */ + private commit(account: Account, group: TypeGroup, pending: PendingChanges): number | null { + const changes = pending.toChanges(); + if (changes.length === 0) return null; + const changeId = ++account.lastChangeId; + account.logs[group].append(changeId, changes); + return changeId; + } + + private stateOf(account: Account, type: DataType): string { + return encodeState(stateOfChangeId(account.logs[groupOf(type)].sideChangeId(sideOf(type)))); + } + + /** The container marked default, else the oldest one (Stalwart: the lowest id). */ + private defaultContainerId(account: Account, type: ContainerType): string | undefined { + const marked = account.defaultContainer[type]; + if (marked && account.objects[type].has(marked)) return marked; + return account.objects[type].keys().next().value; + } + + private canWrite(account: Account, type: ContainerType, id: string): boolean { + const rights = account.objects[type].get(id)?.data.myRights; + if (!isObj(rights)) return true; + return type === 'AddressBook' ? rights.mayWrite !== false : rights.mayWriteAll !== false; + } + + private requireAccount(accountId: string): Account { + const account = this.accounts.get(accountId); + if (!account) throw new Error(`fake server: no account ${accountId}`); + return account; + } + + private requireObject(account: Account, type: DataType, id: string): Stored { + const stored = account.objects[type].get(id); + if (!stored) throw new Error(`fake server: no ${type} ${id} in ${account.id}`); + return stored; + } +} + +function hasBlobLink(entry: unknown): boolean { + if (!isObj(entry) || !isObj(entry.links)) return false; + return Object.values(entry.links).some((link) => isObj(link) && hasOwn(link, 'blobId')); +} diff --git a/src/device-sync/__tests__/fakes/fake-provider.ts b/src/device-sync/__tests__/fakes/fake-provider.ts new file mode 100644 index 00000000..c642f0c6 --- /dev/null +++ b/src/device-sync/__tests__/fakes/fake-provider.ts @@ -0,0 +1,1209 @@ +/** + * In-memory stand-in for Android's Contacts and Calendar providers, as the + * device sync engine reaches them through the native bridge (`ProviderPort`), + * plus a "user" face that edits rows the way Contacts and Calendar apps do. + * + * It models the provider behaviour the engine relies on (docs/device-sync.md, + * "Device data model", and the AOSP facts quoted there): + * + * - Sync-adapter writes never set DIRTY; app writes do (data rows, events and + * their attendees/reminders). The provider never clears DIRTY. + * - RawContacts.VERSION goes up on every data-row insert, update or delete, + * whoever makes it, and on a DELETED change; never for DIRTY, SOURCE_ID or + * SYNC1-4. Groups bump VERSION on every update. + * - App deletes are soft for raw contacts (always) and for events with a + * `_sync_id`; an app-deleted event without one is gone at once. Sync-adapter + * deletes are hard, and an event's exceptions survive the master. + * - Batches are atomic here (a real provider may commit at yield points under + * contention; the engine must not rely on either), at most 499 contacts ops + * between yield points, asserts compare as text. + * - Normalisation: a lone display name is split, missing display name and + * formatted address are derived, the organizer defaults to the calendar + * owner, all-day times are zeroed in UTC and `PS` all-day durations become + * `PD`, while `PT0S`-style all-day durations, NULL STATUS updates, + * SELF_ATTENDEE_STATUS updates and RRULEs with unknown parts fail the batch. + * - Scoping: rows of other accounts are invisible and untouchable. + * + * Pure TypeScript, for vitest only. + */ +import { + Attendees, + Calendars, + Colors, + ContactsSettings, + Data, + Events, + ExtendedProperties, + GroupMembership, + Groups, + MimeType, + RawContacts, + Reminders, + StructuredName, + StructuredPostal, +} from '../../android-columns'; +import { + CALENDAR_AUTHORITY, + CALENDAR_TABLES, + CONTACTS_AUTHORITY, + CONTACTS_TABLES, + type Authority, + type BatchFailure, + type BatchResult, + type Cell, + type OpResult, + type PhotoData, + type ProviderOp, + type ProviderPort, + type ProviderQuery, + type ProviderRows, + type ProviderTable, + type Row, + type WriteCell, + type WriteRow, +} from '../../types'; +import { compileOrderBy, compileWhere, whereColumns } from './fake-sql'; + +export const FAKE_ACCOUNT_TYPE = 'com.anonymous.bulwarkmobile.account'; +const APP_PACKAGE = 'com.example.calendarapp'; + +type StoredRow = Record; + +/** Rows keyed by `_id`, which every stored row also carries as a column. */ +class RowMap extends Map { + override set(id: number, row: StoredRow): this { + row._id = id; + return super.set(id, row); + } +} +type Tables = Record; + +const numbered = (prefix: string, from: number, to: number) => + Array.from({ length: to - from + 1 }, (_, i) => `${prefix}${from + i}`); + +const COLUMNS: Record> = { + raw_contacts: new Set([...Object.values(RawContacts), 'custom_ringtone', 'send_to_voicemail', 'backup_id']), + data: new Set([...Object.values(Data), GroupMembership.GROUP_SOURCE_ID]), + groups: new Set([...Object.values(Groups), 'auto_add', 'favorites']), + settings: new Set(Object.values(ContactsSettings)), + calendars: new Set([...Object.values(Calendars), ...numbered('cal_sync', 4, 10), 'canPartiallyUpdate']), + events: new Set([...Object.values(Events), ...numbered('sync_data', 7, 10), 'lastSynced']), + attendees: new Set(Object.values(Attendees)), + reminders: new Set(Object.values(Reminders)), + extended_properties: new Set(Object.values(ExtendedProperties)), + colors: new Set(Object.values(Colors)), +}; + +/** Columns only the provider computes; writes to them are refused like SQLite would for a view. */ +const READ_ONLY_COLUMNS: Partial>> = { + data: new Set([GroupMembership.GROUP_SOURCE_ID]), + events: new Set([Events.ACCOUNT_NAME, Events.ACCOUNT_TYPE]), +}; + +class FakeProviderError extends Error { + constructor(readonly reason: BatchFailure, message: string) { + super(message); + } +} + +const isEmpty = (v: Cell | undefined) => v === null || v === undefined || v === ''; + +/** AOSP calendarcommon2 Duration.parse: `[+-]P` then ``, `T` ignored. */ +export function aospDurationValid(value: string): boolean { + return /^[+-]?P(T?\d+[WDHMS])*T?$/.test(value) && /\d/.test(value); +} + +/** AOSP EventRecurrence.parse: known parts only (X-* ignored), FREQ required, no RRULE: prefix. */ +export function aospRruleValid(value: string): boolean { + const known = new Set([ + 'FREQ', 'UNTIL', 'COUNT', 'INTERVAL', 'BYSECOND', 'BYMINUTE', 'BYHOUR', 'BYDAY', 'BYMONTHDAY', + 'BYYEARDAY', 'BYWEEKNO', 'BYMONTH', 'BYSETPOS', 'WKST', + ]); + return value.split('\n').every((rule) => { + const seen = new Set(); + let freq = false; + for (const part of rule.toUpperCase().split(';')) { + if (!part) continue; + const [name] = part.split('='); + if (name.startsWith('X-')) continue; + if (!known.has(name) || seen.has(name)) return false; + seen.add(name); + if (name === 'FREQ') freq = true; + } + return freq; + }); +} + +export interface FakeProviderOptions { + accountType?: string; + /** Derive/split names and addresses like ContactsProvider (default true). */ + normalize?: boolean; + /** Byte budget of one applyBatch before it fails with `tooLarge` (default 1 MB). */ + maxBatchBytes?: number; +} + +/** + * Both providers of one device. `port(account, authority)` is what the engine + * gets; `user` edits like apps do; the rest is for test setup and assertions. + */ +export class FakeDeviceProviders { + readonly accountType: string; + private readonly normalize: boolean; + private readonly maxBatchBytes: number; + private tables: Tables = FakeDeviceProviders.emptyTables(); + private syncState = new Map(); + private photos = new Map(); + private nextId = 1; + private nextPhotoFile = 1000; + private pendingFailure: { reason: BatchFailure; message: string } | null = null; + private beforeBatch: Array<() => void> = []; + /** Every batch applied or refused, for assertions on write counts. */ + readonly batches: Array<{ account: string; authority: Authority; ops: ProviderOp[]; ok: boolean }> = []; + + readonly user = new FakeUser(this); + + constructor(options: FakeProviderOptions = {}) { + this.accountType = options.accountType ?? FAKE_ACCOUNT_TYPE; + this.normalize = options.normalize ?? true; + this.maxBatchBytes = options.maxBatchBytes ?? 1_000_000; + } + + private static emptyTables(): Tables { + return { + raw_contacts: new RowMap(), + data: new RowMap(), + groups: new RowMap(), + settings: new RowMap(), + calendars: new RowMap(), + events: new RowMap(), + attendees: new RowMap(), + reminders: new RowMap(), + extended_properties: new RowMap(), + colors: new RowMap(), + }; + } + + port(accountName: string, authority: Authority): ProviderPort { + return new FakePort(this, accountName, authority); + } + + // ── test hooks ───────────────────────────────────────── + + /** The next applyBatch fails with this reason, applying nothing. */ + failNextBatch(reason: BatchFailure, message = `injected ${reason}`): void { + this.pendingFailure = { reason, message }; + } + + /** Runs `fn` right before the next applyBatch is applied (a concurrent user edit). */ + beforeNextBatch(fn: () => void): void { + this.beforeBatch.push(fn); + } + + /** Removes an account the way AccountManager does: the providers drop all its rows and SyncState. */ + removeAccount(accountName: string): void { + for (const table of ['raw_contacts', 'groups', 'settings', 'calendars', 'colors'] as const) { + for (const [id, row] of this.tables[table]) { + if (row.account_name === accountName && row.account_type === this.accountType) this.deleteRow(table, id); + } + } + for (const authority of [CONTACTS_AUTHORITY, CALENDAR_AUTHORITY]) this.syncState.delete(`${authority}|${accountName}`); + } + + // ── inspection ───────────────────────────────────────── + + rows(table: ProviderTable, where?: string, args: Array = []): Row[] { + const pred = compileWhere(where, args); + return [...this.tables[table].values()] + .filter((row) => pred((c) => this.readCell(table, row, c))) + .map((row) => ({ ...this.view(table, row) })); + } + + row(table: ProviderTable, id: number): Row | undefined { + const row = this.tables[table].get(id); + return row ? { ...this.view(table, row) } : undefined; + } + + /** Stored photo bytes (base64) by PHOTO_FILE_ID. */ + photo(fileId: number): string | undefined { + return this.photos.get(fileId); + } + + readSyncState(accountName: string, authority: Authority): string | null { + return this.syncState.get(`${authority}|${accountName}`) ?? null; + } + + // ── internals shared by the port and the user face ───── + + /** @internal */ allocId(): number { + return this.nextId++; + } + + /** @internal */ table(table: ProviderTable): RowMap { + return this.tables[table]; + } + + /** @internal */ readCell(table: ProviderTable, row: StoredRow, column: string): Cell { + if (table === 'data' && column === GroupMembership.GROUP_SOURCE_ID) { + if (row[Data.MIMETYPE] !== MimeType.GROUP_MEMBERSHIP || row[Data.DATA1] === null) return null; + return this.tables.groups.get(Number(row[Data.DATA1]))?.[Groups.SOURCE_ID] ?? null; + } + if (table === 'events' && (column === Events.ACCOUNT_NAME || column === Events.ACCOUNT_TYPE)) { + return this.tables.calendars.get(Number(row[Events.CALENDAR_ID]))?.[column] ?? null; + } + if (table === 'data' && column === Data.DATA15 && row[Data.MIMETYPE] === MimeType.PHOTO) return null; + const v = row[column]; + return v === undefined ? null : v; + } + + private view(table: ProviderTable, row: StoredRow): StoredRow { + const out: StoredRow = {}; + for (const column of COLUMNS[table]) out[column] = this.readCell(table, row, column); + return out; + } + + /** @internal */ owned(table: ProviderTable, row: StoredRow, accountName: string): boolean { + const mine = (r: StoredRow | undefined) => + !!r && r[RawContacts.ACCOUNT_NAME] === accountName && r[RawContacts.ACCOUNT_TYPE] === this.accountType; + switch (table) { + case 'raw_contacts': + case 'groups': + case 'settings': + case 'calendars': + case 'colors': + return mine(row); + case 'data': + return mine(this.tables.raw_contacts.get(Number(row[Data.RAW_CONTACT_ID]))); + case 'events': + return mine(this.tables.calendars.get(Number(row[Events.CALENDAR_ID]))); + case 'attendees': + case 'reminders': + case 'extended_properties': { + const event = this.tables.events.get(Number(row.event_id)); + return !!event && this.owned('events', event, accountName); + } + } + } + + /** @internal */ deleteRow(table: ProviderTable, id: number): void { + const row = this.tables[table].get(id); + if (!row) return; + this.tables[table].delete(id); + if (table === 'raw_contacts') { + for (const [dataId, d] of this.tables.data) if (d[Data.RAW_CONTACT_ID] === id) this.tables.data.delete(dataId); + } else if (table === 'groups') { + for (const [dataId, d] of this.tables.data) { + if (d[Data.MIMETYPE] === MimeType.GROUP_MEMBERSHIP && Number(d[Data.DATA1]) === id) { + this.tables.data.delete(dataId); + this.bumpVersion(Number(d[Data.RAW_CONTACT_ID])); + } + } + } else if (table === 'calendars') { + for (const [eventId, e] of this.tables.events) if (e[Events.CALENDAR_ID] === id) this.deleteRow('events', eventId); + } else if (table === 'events') { + for (const child of ['attendees', 'reminders', 'extended_properties'] as const) { + for (const [childId, c] of this.tables[child]) if (c.event_id === id) this.tables[child].delete(childId); + } + } + } + + /** @internal */ bumpVersion(rawContactId: number): void { + const rc = this.tables.raw_contacts.get(rawContactId); + if (rc) rc[RawContacts.VERSION] = Number(rc[RawContacts.VERSION] ?? 1) + 1; + } + + /** @internal */ markEventDirty(eventId: number): void { + const e = this.tables.events.get(eventId); + if (!e) return; + e[Events.DIRTY] = 1; + const mutators = String(e[Events.MUTATORS] ?? ''); + if (!mutators.split(',').includes(APP_PACKAGE)) e[Events.MUTATORS] = mutators ? `${mutators},${APP_PACKAGE}` : APP_PACKAGE; + } + + /** @internal */ storePhoto(b64: string): number { + const fileId = this.nextPhotoFile++; + this.photos.set(fileId, b64); + return fileId; + } + + /** @internal Provider-side fixes of a data row after a write (ContactsProvider's row handlers). */ + normalizeData(row: StoredRow): void { + if (!this.normalize) return; + if (row[Data.MIMETYPE] === MimeType.STRUCTURED_NAME) { + const parts = [ + StructuredName.PREFIX, + StructuredName.GIVEN_NAME, + StructuredName.MIDDLE_NAME, + StructuredName.FAMILY_NAME, + StructuredName.SUFFIX, + ]; + const anyPart = parts.some((c) => !isEmpty(row[c])); + if (isEmpty(row[StructuredName.DISPLAY_NAME]) && anyPart) { + row[StructuredName.DISPLAY_NAME] = parts.map((c) => row[c]).filter((v) => !isEmpty(v)).join(' '); + } else if (!isEmpty(row[StructuredName.DISPLAY_NAME]) && !anyPart) { + const words = String(row[StructuredName.DISPLAY_NAME]).trim().split(/\s+/); + row[StructuredName.GIVEN_NAME] = words[0] ?? null; + row[StructuredName.FAMILY_NAME] = words.length > 1 ? words.slice(1).join(' ') : null; + } + } else if (row[Data.MIMETYPE] === MimeType.STRUCTURED_POSTAL) { + const parts = [ + StructuredPostal.STREET, + StructuredPostal.POBOX, + StructuredPostal.NEIGHBORHOOD, + StructuredPostal.CITY, + StructuredPostal.REGION, + StructuredPostal.POSTCODE, + StructuredPostal.COUNTRY, + ]; + const anyPart = parts.some((c) => !isEmpty(row[c])); + if (isEmpty(row[StructuredPostal.FORMATTED_ADDRESS]) && anyPart) { + row[StructuredPostal.FORMATTED_ADDRESS] = parts.map((c) => row[c]).filter((v) => !isEmpty(v)).join(', '); + } else if (!isEmpty(row[StructuredPostal.FORMATTED_ADDRESS]) && !anyPart) { + row[StructuredPostal.STREET] = row[StructuredPostal.FORMATTED_ADDRESS]; + } + } + } + + /** @internal CalendarProvider's checks and fixes of an event row (sync adapter and app alike). */ + fixEvent(row: StoredRow, isInsert: boolean): void { + const recurring = !isEmpty(row[Events.RRULE]) || !isEmpty(row[Events.RDATE]); + const exception = !isEmpty(row[Events.ORIGINAL_SYNC_ID]) || !isEmpty(row[Events.ORIGINAL_ID]); + if (!isEmpty(row[Events.RRULE]) && !aospRruleValid(String(row[Events.RRULE]))) { + throw new FakeProviderError('provider', `Invalid recurrence rule: ${row[Events.RRULE]}`); + } + if (recurring) { + row[Events.DTEND] = null; + if (isEmpty(row[Events.DURATION])) throw new FakeProviderError('provider', 'Recurring event without DURATION'); + } else if (exception || !isEmpty(row[Events.DTEND])) { + row[Events.DURATION] = null; + } + if (!isEmpty(row[Events.DURATION]) && !aospDurationValid(String(row[Events.DURATION]))) { + throw new FakeProviderError('provider', `Invalid DURATION ${row[Events.DURATION]}`); + } + if (isInsert && isEmpty(row[Events.DTSTART])) throw new FakeProviderError('provider', 'DTSTART field missing'); + if (Number(row[Events.ALL_DAY]) === 1) { + for (const c of [Events.DTSTART, Events.DTEND]) { + if (!isEmpty(row[c])) row[c] = Math.floor(Number(row[c]) / 86_400_000) * 86_400_000; + } + const d = row[Events.DURATION]; + if (!isEmpty(d)) { + const s = String(d); + if (s.endsWith('S')) { + const n = Number(s.substring(1, s.length - 1)); // Integer.parseInt(duration.substring(1, len-1)) + if (!Number.isInteger(n)) throw new FakeProviderError('provider', `NumberFormatException: ${s}`); + row[Events.DURATION] = `P${Math.ceil(n / 86_400)}D`; + } + } + } + } + + /** @internal */ applyBatch(accountName: string, authority: Authority, ops: ProviderOp[]): BatchResult { + const record = (ok: boolean) => this.batches.push({ account: accountName, authority, ops, ok }); + if (this.pendingFailure) { + const { reason, message } = this.pendingFailure; + this.pendingFailure = null; + record(false); + return { ok: false, reason, message }; + } + if (JSON.stringify(ops).length > this.maxBatchBytes) { + record(false); + return { ok: false, reason: 'tooLarge', message: 'TransactionTooLargeException' }; + } + if (authority === CONTACTS_AUTHORITY) { + let sinceYield = 0; + for (let i = 0; i < ops.length; i++) { + if (i > 0 && ops[i].yieldAllowed) sinceYield = 0; + if (++sinceYield >= 500) { + record(false); + return { ok: false, reason: 'provider', message: 'Too many content provider operations between yield points' }; + } + } + } + for (const hook of this.beforeBatch.splice(0)) hook(); + + const snapshot = { + tables: cloneTables(this.tables), + syncState: new Map(this.syncState), + photos: new Map(this.photos), + nextId: this.nextId, + nextPhotoFile: this.nextPhotoFile, + }; + try { + const results = new BatchApplier(this, accountName, authority).apply(ops); + record(true); + return { ok: true, results }; + } catch (e) { + this.tables = snapshot.tables; + this.syncState = snapshot.syncState; + this.photos = snapshot.photos; + this.nextId = snapshot.nextId; + this.nextPhotoFile = snapshot.nextPhotoFile; + record(false); + if (e instanceof FakeProviderError) return { ok: false, reason: e.reason, message: e.message }; + return { ok: false, reason: 'provider', message: (e as Error).message }; + } + } + + /** @internal */ setSyncState(accountName: string, authority: Authority, value: string): void { + this.syncState.set(`${authority}|${accountName}`, value); + } +} + +function cloneTables(tables: Tables): Tables { + const out = {} as Tables; + for (const [name, rows] of Object.entries(tables) as Array<[ProviderTable, RowMap]>) { + const copy = new RowMap(); + for (const [id, row] of rows) copy.set(id, { ...row }); + out[name] = copy; + } + return out; +} + +function tablesFor(authority: Authority): readonly ProviderTable[] { + return authority === CONTACTS_AUTHORITY ? CONTACTS_TABLES : CALENDAR_TABLES; +} + +function checkColumns(table: ProviderTable, columns: Iterable, write: boolean): void { + for (const c of columns) { + if (!COLUMNS[table].has(c)) throw new FakeProviderError('provider', `no such column: ${c} in ${table}`); + if (write && READ_ONLY_COLUMNS[table]?.has(c)) throw new FakeProviderError('provider', `${c} is read-only in ${table}`); + } +} + +class FakePort implements ProviderPort { + constructor( + private readonly fake: FakeDeviceProviders, + readonly accountName: string, + readonly authority: Authority, + ) {} + + async query(q: ProviderQuery): Promise { + if (!tablesFor(this.authority).includes(q.table)) { + throw new Error(`scope: table ${q.table} is not part of ${this.authority}`); + } + try { + checkColumns(q.table, [...q.columns, ...whereColumns(q.where)], false); + } catch (e) { + throw new Error((e as Error).message); + } + const pred = compileWhere(q.where, q.args); + const order = compileOrderBy(q.orderBy); + const rows = [...this.fake.table(q.table).values()] + .filter((row) => this.fake.owned(q.table, row, this.accountName)) + .filter((row) => pred((c) => this.fake.readCell(q.table, row, c))) + .sort((a, b) => order((c) => this.fake.readCell(q.table, a, c), (c) => this.fake.readCell(q.table, b, c))); + return { + columns: q.columns, + rows: rows.map((row) => q.columns.map((c) => this.fake.readCell(q.table, row, c))), + }; + } + + async applyBatch(ops: ProviderOp[]): Promise { + return this.fake.applyBatch(this.accountName, this.authority, structuredClone(ops)); + } + + async readSyncState(): Promise { + return this.fake.readSyncState(this.accountName, this.authority); + } + + async readPhoto(rawContactId: number, _maxPx: number): Promise { + const rc = this.fake.table('raw_contacts').get(rawContactId); + if (!rc || !this.fake.owned('raw_contacts', rc, this.accountName)) throw new Error('scope: raw contact of another account'); + for (const row of this.fake.table('data').values()) { + if (row[Data.RAW_CONTACT_ID] === rawContactId && row[Data.MIMETYPE] === MimeType.PHOTO) { + const fileId = row[Data.DATA14] === null ? null : Number(row[Data.DATA14]); + const b64 = fileId === null ? undefined : this.fake.photo(fileId); + return b64 === undefined ? null : { jpegBase64: b64, fileId }; + } + } + return null; + } +} + +/** Applies one batch as a sync adapter; throws FakeProviderError to roll it back. */ +class BatchApplier { + private readonly results: OpResult[] = []; + private readonly insertedDataFor = new Set(); + + constructor( + private readonly fake: FakeDeviceProviders, + private readonly account: string, + private readonly authority: Authority, + ) {} + + apply(ops: ProviderOp[]): OpResult[] { + ops.forEach((op, index) => this.results.push(this.applyOne(op, index))); + // Data-row inserts bump each raw contact's VERSION once per transaction. + for (const id of this.insertedDataFor) this.fake.bumpVersion(id); + return this.results; + } + + private scopeError(msg: string): never { + throw new FakeProviderError('scope', msg); + } + + private checkTable(table: ProviderTable): void { + if (!tablesFor(this.authority).includes(table)) this.scopeError(`table ${table} is not part of ${this.authority}`); + } + + private target(table: ProviderTable, id: number | undefined, where: string | undefined, args: Array | undefined): number[] { + const rows = this.fake.table(table); + if (id !== undefined) { + const row = rows.get(id); + if (!row) return []; + if (!this.fake.owned(table, row, this.account)) this.scopeError(`${table} ${id} belongs to another account`); + return [id]; + } + checkColumns(table, whereColumns(where), false); + const pred = compileWhere(where, args); + return [...rows] + .filter(([, row]) => this.fake.owned(table, row, this.account)) + .filter(([, row]) => pred((c) => this.fake.readCell(table, row, c))) + .map(([rowId]) => rowId); + } + + private values(table: ProviderTable, values: WriteRow): StoredRow { + checkColumns(table, Object.keys(values), true); + const out: StoredRow = {}; + for (const [column, cell] of Object.entries(values) as Array<[string, WriteCell]>) { + if (cell !== null && typeof cell === 'object') { + if (!(table === 'data' && column === Data.DATA15)) this.scopeError(`blob not allowed in ${table}.${column}`); + out[column] = `blob:${cell.b64}`; + } else { + out[column] = cell; + } + } + return out; + } + + /** A photo row's full-size bytes become a stored display photo plus a file id, like PhotoProcessor. */ + private processPhoto(row: StoredRow): void { + const v = row[Data.DATA15]; + if (typeof v === 'string' && v.startsWith('blob:')) { + row[Data.DATA14] = this.fake.storePhoto(v.slice(5)); + row[Data.DATA15] = 'thumbnail'; + } + } + + private applyOne(op: ProviderOp, index: number): OpResult { + if (op.op === 'syncState') { + this.fake.setSyncState(this.account, this.authority, op.value); + return {}; + } + this.checkTable(op.table); + switch (op.op) { + case 'insert': + return this.insert(op.table, op.values, op.refs, index); + case 'update': + return this.update(op.table, op.id, op.where, op.args, op.values, op.expectCount); + case 'delete': + return this.delete(op.table, op.id, op.where, op.args, op.expectCount); + case 'assert': + return this.assert(op.table, op.id, op.where, op.args, op.values, op.expectCount); + } + } + + private insert(table: ProviderTable, values: WriteRow, refs: Record | undefined, index: number): OpResult { + const row = this.values(table, values); + for (const [column, ref] of Object.entries(refs ?? {})) { + if (ref >= index || this.results[ref]?.id === undefined) throw new FakeProviderError('provider', `bad back-reference ${ref}`); + row[column] = this.results[ref].id!; + } + const id = this.fake.allocId(); + const account = { account_name: this.account, account_type: this.fake.accountType }; + const parentOwned = (parentTable: ProviderTable, parentId: Cell) => { + const parent = this.fake.table(parentTable).get(Number(parentId)); + if (!parent || !this.fake.owned(parentTable, parent, this.account)) { + this.scopeError(`${table} insert names ${parentTable} ${String(parentId)} outside the account`); + } + return parent; + }; + const accountMismatch = (r: StoredRow) => + (r.account_name !== undefined && r.account_name !== this.account) || + (r.account_type !== undefined && r.account_type !== this.fake.accountType); + switch (table) { + case 'raw_contacts': + if (accountMismatch(row)) this.scopeError('raw contact of another account'); + Object.assign(row, { ...defaults(RAW_CONTACT_DEFAULTS), ...row, ...account }); + break; + case 'groups': + if (accountMismatch(row)) this.scopeError('group of another account'); + Object.assign(row, { ...defaults(GROUP_DEFAULTS), ...row, ...account }); + break; + case 'settings': { + for (const [sid, s] of this.fake.table('settings')) { + if (s.account_name === this.account && s.account_type === this.fake.accountType) this.fake.table('settings').delete(sid); + } + Object.assign(row, { [ContactsSettings.UNGROUPED_VISIBLE]: 0, [ContactsSettings.SHOULD_SYNC]: 1, ...row, ...account }); + break; + } + case 'data': { + parentOwned('raw_contacts', row[Data.RAW_CONTACT_ID]); + row[Data.RAW_CONTACT_ID] = Number(row[Data.RAW_CONTACT_ID]); + if (isEmpty(row[Data.MIMETYPE])) throw new FakeProviderError('provider', 'data row without mimetype'); + row[Data.DATA_VERSION] = 0; + if (row[Data.MIMETYPE] === MimeType.GROUP_MEMBERSHIP) this.resolveMembership(row); + this.processPhoto(row); + this.fake.normalizeData(row); + this.insertedDataFor.add(Number(row[Data.RAW_CONTACT_ID])); + break; + } + case 'calendars': { + if (accountMismatch(row)) this.scopeError('calendar of another account'); + for (const c of [Calendars.NAME, Calendars.CALENDAR_DISPLAY_NAME, Calendars.CALENDAR_COLOR, Calendars.CALENDAR_ACCESS_LEVEL, Calendars.OWNER_ACCOUNT]) { + if (isEmpty(row[c])) throw new FakeProviderError('provider', `calendar insert without ${c}`); + } + Object.assign(row, { ...defaults(CALENDAR_DEFAULTS), ...row, ...account }); + break; + } + case 'events': { + const calendar = parentOwned('calendars', row[Events.CALENDAR_ID])!; + row[Events.CALENDAR_ID] = Number(row[Events.CALENDAR_ID]); + Object.assign(row, { ...defaults(EVENT_DEFAULTS), ...row }); + this.fake.fixEvent(row, true); + if (isEmpty(row[Events.ORGANIZER])) row[Events.ORGANIZER] = calendar[Calendars.OWNER_ACCOUNT]; + this.linkException(row); + const self = row[Events.SELF_ATTENDEE_STATUS]; + this.fake.table('events').set(id, row); + if (!isEmpty(self)) { + this.fake.table('attendees').set(this.fake.allocId(), { + [Attendees.EVENT_ID]: id, + [Attendees.ATTENDEE_EMAIL]: calendar[Calendars.OWNER_ACCOUNT], + [Attendees.ATTENDEE_STATUS]: self, + }); + } + return { id }; + } + case 'attendees': + case 'reminders': + case 'extended_properties': + parentOwned('events', row.event_id); + row.event_id = Number(row.event_id); + break; + case 'colors': + if (accountMismatch(row)) this.scopeError('color of another account'); + Object.assign(row, { ...row, ...account }); + break; + } + this.fake.table(table).set(id, row); + return { id }; + } + + private resolveMembership(row: StoredRow): void { + const sourceId = row[GroupMembership.GROUP_SOURCE_ID]; + delete row[GroupMembership.GROUP_SOURCE_ID]; + if (row[Data.DATA1] !== null && row[Data.DATA1] !== undefined) return; + if (isEmpty(sourceId)) throw new FakeProviderError('provider', 'membership needs GROUP_ROW_ID or GROUP_SOURCE_ID'); + for (const [gid, g] of this.fake.table('groups')) { + if (g[Groups.SOURCE_ID] === sourceId && this.fake.owned('groups', g, this.account)) { + row[Data.DATA1] = gid; + return; + } + } + // Like the provider: an unknown source id creates an empty, invisible group. + const gid = this.fake.allocId(); + this.fake.table('groups').set(gid, { + ...defaults(GROUP_DEFAULTS), + [Groups.SOURCE_ID]: sourceId, + account_name: this.account, + account_type: this.fake.accountType, + }); + row[Data.DATA1] = gid; + } + + /** ORIGINAL_ID and ORIGINAL_SYNC_ID fill each other in from the master, as CalendarProvider does. */ + private linkException(row: StoredRow): void { + const events = this.fake.table('events'); + if (!isEmpty(row[Events.ORIGINAL_SYNC_ID]) && isEmpty(row[Events.ORIGINAL_ID])) { + for (const [mid, m] of events) { + if (m[Events._SYNC_ID] === row[Events.ORIGINAL_SYNC_ID] && m[Events.CALENDAR_ID] === row[Events.CALENDAR_ID]) { + row[Events.ORIGINAL_ID] = mid; + break; + } + } + } else if (!isEmpty(row[Events.ORIGINAL_ID]) && isEmpty(row[Events.ORIGINAL_SYNC_ID])) { + row[Events.ORIGINAL_SYNC_ID] = events.get(Number(row[Events.ORIGINAL_ID]))?.[Events._SYNC_ID] ?? null; + } + } + + private update( + table: ProviderTable, + id: number | undefined, + where: string | undefined, + args: Array | undefined, + values: WriteRow, + expectCount: number | undefined, + ): OpResult { + const ids = this.target(table, id, where, args); + if (expectCount !== undefined && ids.length !== expectCount) { + throw new FakeProviderError('assert', `wrong number of rows: ${ids.length}`); + } + const patch = this.values(table, values); + for (const rowId of ids) { + const row = this.fake.table(table).get(rowId)!; + const before = { ...row }; + if (table === 'events') { + if (Events.SELF_ATTENDEE_STATUS in patch) throw new FakeProviderError('provider', 'IllegalArgumentException: selfAttendeeStatus'); + if (Events.STATUS in patch && patch[Events.STATUS] === null) throw new FakeProviderError('provider', 'NullPointerException: eventStatus'); + } + Object.assign(row, patch); + switch (table) { + case 'raw_contacts': + if (before[RawContacts.DELETED] !== row[RawContacts.DELETED]) this.fake.bumpVersion(rowId); + break; + case 'groups': + row[Groups.VERSION] = Number(row[Groups.VERSION] ?? 1) + 1; + break; + case 'data': + row[Data.DATA_VERSION] = Number(row[Data.DATA_VERSION] ?? 0) + 1; + this.processPhoto(row); + this.fake.normalizeData(row); + this.fake.bumpVersion(Number(row[Data.RAW_CONTACT_ID])); + break; + case 'events': + this.fake.fixEvent(row, false); + if (Events.DIRTY in patch && Number(patch[Events.DIRTY]) === 0) row[Events.MUTATORS] = null; + if (before[Events._SYNC_ID] !== row[Events._SYNC_ID] && !isEmpty(before[Events._SYNC_ID])) { + for (const e of this.fake.table('events').values()) { + if (e[Events.ORIGINAL_SYNC_ID] === before[Events._SYNC_ID]) e[Events.ORIGINAL_SYNC_ID] = row[Events._SYNC_ID]; + } + } + break; + } + } + return { count: ids.length }; + } + + private delete( + table: ProviderTable, + id: number | undefined, + where: string | undefined, + args: Array | undefined, + expectCount: number | undefined, + ): OpResult { + const ids = this.target(table, id, where, args); + if (expectCount !== undefined && ids.length !== expectCount) { + throw new FakeProviderError('assert', `wrong number of rows: ${ids.length}`); + } + for (const rowId of ids) { + const row = this.fake.table(table).get(rowId); + if (!row) continue; + if (table === 'data') this.fake.bumpVersion(Number(row[Data.RAW_CONTACT_ID])); + this.fake.deleteRow(table, rowId); + } + return { count: ids.length }; + } + + private assert( + table: ProviderTable, + id: number | undefined, + where: string | undefined, + args: Array | undefined, + values: Row | undefined, + expectCount: number | undefined, + ): OpResult { + const ids = this.target(table, id, where, args); + if (expectCount !== undefined && ids.length !== expectCount) { + throw new FakeProviderError('assert', `wrong number of rows: ${ids.length}`); + } + if (values) { + checkColumns(table, Object.keys(values), false); + for (const rowId of ids) { + const row = this.fake.table(table).get(rowId)!; + for (const [column, expected] of Object.entries(values)) { + const actual = this.fake.readCell(table, row, column); + const same = actual === null || expected === null ? actual === expected : String(actual) === String(expected); + if (!same) { + throw new FakeProviderError('assert', `Found value ${String(actual)} when expected ${String(expected)} for column ${column}`); + } + } + } + } + return { count: ids.length }; + } +} + +const defaults = (d: StoredRow): StoredRow => ({ ...d }); + +const RAW_CONTACT_DEFAULTS: StoredRow = { + [RawContacts.SOURCE_ID]: null, + [RawContacts.VERSION]: 1, + [RawContacts.DIRTY]: 0, + [RawContacts.DELETED]: 0, + [RawContacts.SYNC1]: null, + [RawContacts.SYNC2]: null, + [RawContacts.SYNC3]: null, + [RawContacts.SYNC4]: null, + [RawContacts.RAW_CONTACT_IS_READ_ONLY]: 0, + [RawContacts.AGGREGATION_MODE]: 0, + [RawContacts.STARRED]: 0, +}; + +const GROUP_DEFAULTS: StoredRow = { + [Groups.SOURCE_ID]: null, + [Groups.VERSION]: 1, + [Groups.DIRTY]: 0, + [Groups.DELETED]: 0, + [Groups.GROUP_VISIBLE]: 0, + [Groups.SHOULD_SYNC]: 1, + [Groups.GROUP_IS_READ_ONLY]: 0, + auto_add: 0, + favorites: 0, +}; + +const CALENDAR_DEFAULTS: StoredRow = { + [Calendars.VISIBLE]: 1, + [Calendars.SYNC_EVENTS]: 0, + [Calendars.ALLOWED_REMINDERS]: '0,1', + [Calendars.ALLOWED_AVAILABILITY]: '0,1', + [Calendars.ALLOWED_ATTENDEE_TYPES]: '0,1,2', + [Calendars.MAX_REMINDERS]: 5, + [Calendars.CAN_ORGANIZER_RESPOND]: 1, + [Calendars.CAN_MODIFY_TIME_ZONE]: 1, + [Calendars.DIRTY]: 0, +}; + +const EVENT_DEFAULTS: StoredRow = { + [Events.DIRTY]: 0, + [Events.DELETED]: 0, + [Events.AVAILABILITY]: 0, + [Events.ACCESS_LEVEL]: 0, + [Events.HAS_ATTENDEE_DATA]: 0, + [Events.GUESTS_CAN_MODIFY]: 0, + [Events.GUESTS_CAN_INVITE_OTHERS]: 1, + [Events.GUESTS_CAN_SEE_GUESTS]: 1, + [Events.ALL_DAY]: 0, + [Events.STATUS]: 0, +}; + +/** + * Edits as Contacts and Calendar apps make them (not a sync adapter): they + * set DIRTY, soft-delete, and some re-insert rows. Named after the app whose + * behaviour they copy where it matters. + */ +export class FakeUser { + constructor(private readonly fake: FakeDeviceProviders) {} + + private rc(id: number): StoredRow { + const rc = this.fake.table('raw_contacts').get(id); + if (!rc) throw new Error(`no raw contact ${id}`); + return rc; + } + + private dirtyContact(id: number): void { + this.rc(id)[RawContacts.DIRTY] = 1; + } + + /** A new contact from a Contacts app: no SOURCE_ID, DIRTY once data rows exist. */ + insertContact(accountName: string, rows: Row[]): number { + const id = this.fake.allocId(); + this.fake.table('raw_contacts').set(id, { + ...defaults(RAW_CONTACT_DEFAULTS), + account_name: accountName, + account_type: this.fake.accountType, + }); + for (const row of rows) this.insertData(id, row); + return id; + } + + insertData(rawContactId: number, values: Row): number { + const id = this.fake.allocId(); + const row: StoredRow = { ...values, [Data.RAW_CONTACT_ID]: rawContactId, [Data.DATA_VERSION]: 0 }; + this.fake.normalizeData(row); + this.fake.table('data').set(id, row); + this.fake.bumpVersion(rawContactId); + this.dirtyContact(rawContactId); + return id; + } + + /** AOSP Contacts: in-place update of the changed columns only (DATA_SYNC kept). */ + updateData(dataId: number, values: Row): void { + const row = this.fake.table('data').get(dataId); + if (!row) throw new Error(`no data row ${dataId}`); + Object.assign(row, values); + row[Data.DATA_VERSION] = Number(row[Data.DATA_VERSION] ?? 0) + 1; + this.fake.normalizeData(row); + const rcId = Number(row[Data.RAW_CONTACT_ID]); + this.fake.bumpVersion(rcId); + this.dirtyContact(rcId); + } + + deleteData(dataId: number): void { + const row = this.fake.table('data').get(dataId); + if (!row) return; + const rcId = Number(row[Data.RAW_CONTACT_ID]); + this.fake.table('data').delete(dataId); + this.fake.bumpVersion(rcId); + this.dirtyContact(rcId); + } + + /** + * Fossify Contacts' save: the name row is updated in place, the rows of + * every other kind it models are deleted and `rows` inserted without + * DATA_SYNC; relations and foreign kinds are left alone. Also stars through + * the aggregate, which dirties the raw contact. + */ + fossifySave(rawContactId: number, name: Row | null, rows: Row[]): void { + const modeled = new Set([ + MimeType.NICKNAME, MimeType.PHONE, MimeType.EMAIL, MimeType.STRUCTURED_POSTAL, MimeType.EVENT, + MimeType.NOTE, MimeType.ORGANIZATION, MimeType.WEBSITE, MimeType.GROUP_MEMBERSHIP, + 'vnd.android.cursor.item/im', + ]); + for (const [id, row] of [...this.fake.table('data')]) { + if (row[Data.RAW_CONTACT_ID] !== rawContactId) continue; + if (name && row[Data.MIMETYPE] === MimeType.STRUCTURED_NAME) this.updateData(id, name); + else if (modeled.has(String(row[Data.MIMETYPE]))) this.deleteData(id); + } + for (const row of rows) this.insertData(rawContactId, row); + this.dirtyContact(rawContactId); + } + + /** Soft delete: DELETED=1, DIRTY=1, rows kept until the sync adapter purges them. */ + deleteContact(rawContactId: number): void { + const rc = this.rc(rawContactId); + if (Number(rc[RawContacts.DELETED]) !== 1) this.fake.bumpVersion(rawContactId); + rc[RawContacts.DELETED] = 1; + rc[RawContacts.DIRTY] = 1; + } + + /** Starring through the aggregate marks the raw contact dirty (any app, even a sync adapter). */ + star(rawContactId: number, starred = true): void { + const rc = this.rc(rawContactId); + rc[RawContacts.STARRED] = starred ? 1 : 0; + rc[RawContacts.DIRTY] = 1; + } + + /** A new photo from an editor: a new display photo file id on the photo row. */ + setPhoto(rawContactId: number, jpegBase64: string): void { + const fileId = this.fake.storePhoto(jpegBase64); + for (const [id, row] of this.fake.table('data')) { + if (row[Data.RAW_CONTACT_ID] === rawContactId && row[Data.MIMETYPE] === MimeType.PHOTO) { + this.updateData(id, { [Data.DATA14]: fileId, [Data.DATA15]: 'thumbnail' }); + return; + } + } + this.insertData(rawContactId, { [Data.MIMETYPE]: MimeType.PHOTO, [Data.DATA14]: fileId, [Data.DATA15]: 'thumbnail' }); + } + + insertGroup(accountName: string, title: string): number { + const id = this.fake.allocId(); + this.fake.table('groups').set(id, { + ...defaults(GROUP_DEFAULTS), + [Groups.TITLE]: title, + [Groups.DIRTY]: 1, + account_name: accountName, + account_type: this.fake.accountType, + }); + return id; + } + + updateGroup(groupId: number, values: Row): void { + const g = this.fake.table('groups').get(groupId); + if (!g) throw new Error(`no group ${groupId}`); + Object.assign(g, values, { [Groups.DIRTY]: 1 }); + g[Groups.VERSION] = Number(g[Groups.VERSION] ?? 1) + 1; + } + + /** App delete: memberships go at once, the group stays DELETED=1 until purged. */ + deleteGroup(groupId: number): void { + for (const [id, row] of [...this.fake.table('data')]) { + if (row[Data.MIMETYPE] === MimeType.GROUP_MEMBERSHIP && Number(row[Data.DATA1]) === groupId) { + this.fake.table('data').delete(id); + this.fake.bumpVersion(Number(row[Data.RAW_CONTACT_ID])); + } + } + const g = this.fake.table('groups').get(groupId); + if (g) Object.assign(g, { [Groups.DELETED]: 1, [Groups.DIRTY]: 1 }); + } + + /** Fossify deletes groups through a sync-adapter URI: gone at once, no DELETED marker. */ + fossifyDeleteGroup(groupId: number): void { + this.fake.deleteRow('groups', groupId); + } + + addToGroup(rawContactId: number, groupId: number): number { + return this.insertData(rawContactId, { [Data.MIMETYPE]: MimeType.GROUP_MEMBERSHIP, [Data.DATA1]: groupId }); + } + + // ── calendar ─────────────────────────────────────────── + + private event(id: number): StoredRow { + const e = this.fake.table('events').get(id); + if (!e) throw new Error(`no event ${id}`); + return e; + } + + /** A new event from a calendar app: no _SYNC_ID, DIRTY=1, organizer defaults to the owner. */ + insertEvent(calendarId: number, values: Row, children: { attendees?: Row[]; reminders?: Row[] } = {}): number { + const calendar = this.fake.table('calendars').get(calendarId); + if (!calendar) throw new Error(`no calendar ${calendarId}`); + const id = this.fake.allocId(); + const row: StoredRow = { ...defaults(EVENT_DEFAULTS), ...values, [Events.CALENDAR_ID]: calendarId }; + this.fake.fixEvent(row, true); + if (isEmpty(row[Events.ORGANIZER])) row[Events.ORGANIZER] = calendar[Calendars.OWNER_ACCOUNT]; + this.fake.table('events').set(id, row); + if (!isEmpty(row[Events.ORIGINAL_SYNC_ID]) && isEmpty(row[Events.ORIGINAL_ID])) { + for (const [mid, m] of this.fake.table('events')) { + if (mid !== id && m[Events._SYNC_ID] === row[Events.ORIGINAL_SYNC_ID]) row[Events.ORIGINAL_ID] = mid; + } + } + for (const a of children.attendees ?? []) this.fake.table('attendees').set(this.fake.allocId(), { ...a, event_id: id }); + for (const r of children.reminders ?? []) this.fake.table('reminders').set(this.fake.allocId(), { ...r, event_id: id }); + this.fake.markEventDirty(id); + return id; + } + + updateEvent(eventId: number, values: Row): void { + const e = this.event(eventId); + if (Events.STATUS in values && values[Events.STATUS] === null) throw new Error('NullPointerException: eventStatus'); + Object.assign(e, values); + this.fake.fixEvent(e, false); + this.fake.markEventDirty(eventId); + } + + /** + * App delete: without _SYNC_ID the row (and its un-synced exceptions) is + * gone at once; with one it becomes DELETED=1, DIRTY=1, its reminders and + * extended properties go at once, un-synced exceptions are hard-deleted and + * synced exceptions stay. + */ + deleteEvent(eventId: number): void { + const e = this.event(eventId); + const syncId = e[Events._SYNC_ID]; + const exceptionsOf = () => + [...this.fake.table('events')].filter(([, x]) => Number(x[Events.ORIGINAL_ID]) === eventId); + if (isEmpty(syncId)) { + for (const [xid] of exceptionsOf()) this.fake.deleteRow('events', xid); + this.fake.deleteRow('events', eventId); + return; + } + for (const [xid, x] of exceptionsOf()) if (isEmpty(x[Events._SYNC_ID])) this.fake.deleteRow('events', xid); + for (const child of ['reminders', 'extended_properties'] as const) { + for (const [cid, c] of [...this.fake.table(child)]) if (c.event_id === eventId) this.fake.table(child).delete(cid); + } + e[Events.DELETED] = 1; + this.fake.markEventDirty(eventId); + } + + /** Etar "this event" edit: a new row with ORIGINAL_SYNC_ID and ORIGINAL_INSTANCE_TIME (no UID_2445). */ + insertException(masterId: number, originalInstanceTime: number, values: Row): number { + const m = this.event(masterId); + return this.insertEvent(Number(m[Events.CALENDAR_ID]), { + [Events.TITLE]: m[Events.TITLE], + [Events.EVENT_TIMEZONE]: m[Events.EVENT_TIMEZONE], + [Events.ALL_DAY]: m[Events.ALL_DAY], + [Events.STATUS]: m[Events.STATUS], + ...values, + [Events.ORIGINAL_SYNC_ID]: m[Events._SYNC_ID], + [Events.ORIGINAL_ID]: masterId, + [Events.ORIGINAL_INSTANCE_TIME]: originalInstanceTime, + [Events.ORIGINAL_ALL_DAY]: m[Events.ALL_DAY], + }); + } + + /** Etar/Fossify "delete this event": a cancelled exception row, no EXDATE. */ + cancelInstance(masterId: number, originalInstanceTime: number, instanceEnd: number): number { + return this.insertException(masterId, originalInstanceTime, { + [Events.STATUS]: 2, + [Events.DTSTART]: originalInstanceTime, + [Events.DTEND]: instanceEnd, + }); + } + + /** + * CONTENT_EXCEPTION_URI without RRULE: clones the master except _SYNC_ID and + * SYNC_DATA*, copies attendees and reminders, marks only the exception dirty. + */ + exceptionViaUri(masterId: number, originalInstanceTime: number, values: Row): number { + const m = this.event(masterId); + const clone: StoredRow = { ...m }; + for (const c of [Events._SYNC_ID, Events.SYNC_DATA1, Events.SYNC_DATA2, Events.SYNC_DATA3, Events.SYNC_DATA4, Events.SYNC_DATA5, Events.SYNC_DATA6]) clone[c] = null; + for (const c of [Events.RRULE, Events.RDATE, Events.EXRULE, Events.EXDATE]) clone[c] = null; + const duration = String(m[Events.DURATION] ?? 'P0S'); + const secs = Number(/(\d+)S$/.exec(duration)?.[1] ?? 0) + Number(/(\d+)D$/.exec(duration)?.[1] ?? 0) * 86_400; + Object.assign(clone, { + [Events.DTSTART]: originalInstanceTime, + [Events.DTEND]: originalInstanceTime + secs * 1000, + [Events.DURATION]: null, + ...values, + [Events.ORIGINAL_ID]: masterId, + [Events.ORIGINAL_SYNC_ID]: m[Events._SYNC_ID], + [Events.ORIGINAL_INSTANCE_TIME]: originalInstanceTime, + [Events.ORIGINAL_ALL_DAY]: m[Events.ALL_DAY], + [Events.DIRTY]: 1, + [Events.DELETED]: 0, + }); + delete clone[Events.SELF_ATTENDEE_STATUS]; + const id = this.fake.allocId(); + this.fake.table('events').set(id, clone); + for (const child of ['attendees', 'reminders'] as const) { + for (const c of [...this.fake.table(child).values()]) { + if (c.event_id === masterId) this.fake.table(child).set(this.fake.allocId(), { ...c, event_id: id }); + } + } + const self = values[Events.SELF_ATTENDEE_STATUS]; + if (!isEmpty(self)) { + const owner = this.fake.table('calendars').get(Number(m[Events.CALENDAR_ID]))?.[Calendars.OWNER_ACCOUNT]; + const mine = [...this.fake.table('attendees').values()].filter((a) => a.event_id === id && a[Attendees.ATTENDEE_EMAIL] === owner); + if (mine.length !== 1) throw new Error('Status update WTF'); + mine[0][Attendees.ATTENDEE_STATUS] = self; + } + this.fake.markEventDirty(id); + return id; + } + + /** + * CONTENT_EXCEPTION_URI with an RRULE ("this and following" split by a third + * party): the master's rule gets UNTIL without DIRTY, and a clone INCLUDING + * _SYNC_ID, SYNC_DATA* and UID_2445 is inserted. + */ + splitViaUri(masterId: number, splitAt: number, untilUtc: string, values: Row): number { + const m = this.event(masterId); + m[Events.RRULE] = `${String(m[Events.RRULE]).replace(/;?(UNTIL|COUNT)=[^;]*/g, '')};UNTIL=${untilUtc}`; + const id = this.fake.allocId(); + this.fake.table('events').set(id, { ...m, [Events.DTSTART]: splitAt, ...values, [Events.DIRTY]: 1 }); + this.fake.markEventDirty(id); + return id; + } + + /** Etar RSVP "all events": the attendee row's status, event DIRTY. */ + setAttendeeStatus(eventId: number, email: string, status: number): void { + for (const a of this.fake.table('attendees').values()) { + if (a.event_id === eventId && a[Attendees.ATTENDEE_EMAIL] === email) a[Attendees.ATTENDEE_STATUS] = status; + } + this.fake.markEventDirty(eventId); + } + + addAttendee(eventId: number, values: Row): number { + const id = this.fake.allocId(); + this.fake.table('attendees').set(id, { ...values, event_id: eventId }); + this.fake.markEventDirty(eventId); + return id; + } + + removeAttendee(attendeeId: number): void { + const a = this.fake.table('attendees').get(attendeeId); + if (!a) return; + this.fake.table('attendees').delete(attendeeId); + this.fake.markEventDirty(Number(a.event_id)); + } + + setReminders(eventId: number, reminders: Array<{ minutes: number; method?: number }>): void { + for (const [id, r] of [...this.fake.table('reminders')]) if (r.event_id === eventId) this.fake.table('reminders').delete(id); + for (const r of reminders) { + this.fake.table('reminders').set(this.fake.allocId(), { + event_id: eventId, + [Reminders.MINUTES]: r.minutes, + [Reminders.METHOD]: r.method ?? 1, + }); + } + this.fake.markEventDirty(eventId); + } + + /** Fossify Calendar's save: the event row updated, attendees and reminders deleted and re-inserted. */ + fossifySaveEvent(eventId: number, values: Row, attendees: Row[], reminders: Array<{ minutes: number; method?: number }>): void { + this.updateEvent(eventId, values); + for (const [id, a] of [...this.fake.table('attendees')]) if (a.event_id === eventId) this.fake.table('attendees').delete(id); + for (const a of attendees) this.fake.table('attendees').set(this.fake.allocId(), { ...a, event_id: eventId }); + this.setReminders(eventId, reminders); + } +} diff --git a/src/device-sync/__tests__/fakes/fake-sql.ts b/src/device-sync/__tests__/fakes/fake-sql.ts new file mode 100644 index 00000000..8d93e98f --- /dev/null +++ b/src/device-sync/__tests__/fakes/fake-sql.ts @@ -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(); + +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 = []): (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; + }; +} diff --git a/src/device-sync/__tests__/purity.test.ts b/src/device-sync/__tests__/purity.test.ts new file mode 100644 index 00000000..4f27efaf --- /dev/null +++ b/src/device-sync/__tests__/purity.test.ts @@ -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(); + 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); + }); +}); diff --git a/src/device-sync/android-columns.ts b/src/device-sync/android-columns.ts new file mode 100644 index 00000000..dc689821 --- /dev/null +++ b/src/device-sync/android-columns.ts @@ -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; diff --git a/src/device-sync/common/ids.ts b/src/device-sync/common/ids.ts new file mode 100644 index 00000000..4bcaee10 --- /dev/null +++ b/src/device-sync/common/ids.ts @@ -0,0 +1,78 @@ +/** + * Identifiers device sync mints and parses: map keys for new JSContact / + * JSCalendar entries, uids for new objects, and the `/` + * 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` keys itself, so neither + * shape is ever minted. + */ +export function makeKeyMinter(random: RandomSource = Math.random): (taken?: Iterable) => 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 (`/`), as in SYNC1 and the selections. */ +export const collectionKey = objectRef; +export const parseCollectionKey = parseObjectRef; diff --git a/src/device-sync/common/json.ts b/src/device-sync/common/json.ts new file mode 100644 index 00000000..99afd240 --- /dev/null +++ b/src/device-sync/common/json.ts @@ -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)[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; + const bo = b as Record; + 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(text: unknown): T | null { + if (typeof text !== 'string' || !text) return null; + try { + return JSON.parse(text) as T; + } catch { + return null; + } +} + +export function clone(value: T): T { + return value === undefined ? value : (JSON.parse(JSON.stringify(value)) as T); +} diff --git a/src/device-sync/common/patch.ts b/src/device-sync/common/patch.ts new file mode 100644 index 00000000..a1d999c8 --- /dev/null +++ b/src/device-sync/common/patch.ts @@ -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; + +/** 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(target: T, patch: PatchObject): T | null { + const out = clone(target) as Record; + for (const [pointer, value] of Object.entries(patch)) { + const segments = ptrSegments(pointer); + let parent: Record = 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; + } + 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; +} diff --git a/src/device-sync/planner.ts b/src/device-sync/planner.ts new file mode 100644 index 00000000..ebf16a05 --- /dev/null +++ b/src/device-sync/planner.ts @@ -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; + /** `/`. */ + 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; + /** 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 = + /** Create `object` (which carries `uid`) in `collectionId`, after looking the uid up. */ + | { kind: 'create'; uid: string; collectionId: string; object: Partial; 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 = + | { kind: 'clean'; ops: OpGroup } + | { kind: 'claim'; ops: OpGroup } + | { kind: 'upload'; actions: UploadAction[] } + | { 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; + /** `/`, 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 (`/`). */ + 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; + 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; + 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[]; +} + +// ─── 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`: `/`, `/#` (exception), `~pending/`, 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; + /** `/`. */ + 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, + ): { 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; + 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[]; 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 }; diff --git a/src/device-sync/types.ts b/src/device-sync/types.ts index 8bf1b899..ce1f8fb8 100644 --- a/src/device-sync/types.ts +++ b/src/device-sync/types.ts @@ -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; isRunCancelled(runId: string): Promise; + /** + * 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; /** `ProviderQuery` JSON in, `ProviderRows` JSON out. */ query(name: string, authority: Authority, queryJson: string): Promise; @@ -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, callId: string]; + +export interface JmapResponse { + methodResponses: JmapInvocation[]; + sessionState?: string; +} + +export interface JmapAccountView { + name: string; + isPersonal: boolean; + isReadOnly: boolean; + accountCapabilities: Record; +} + +export interface JmapSessionView { + username: string; + accounts: Record; + primaryAccounts: Record; + capabilities: Record; +} + +/** 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; + /** Bytes of a blob-backed `media` entry (other servers; Stalwart keeps photos as `data:` URIs). */ + downloadBlob(accountId: string, blobId: string, type?: string): Promise; +} + +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 `/` everywhere. */ diff --git a/src/device-sync/wire.ts b/src/device-sync/wire.ts new file mode 100644 index 00000000..cbbfb1c5 --- /dev/null +++ b/src/device-sync/wire.ts @@ -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 & { + '@type'?: string; + version?: string; + [property: string]: unknown; +}; + +/** A CalendarEvent (JSCalendar 2.0 as Stalwart speaks it) on the wire. */ +export type CalendarEventWire = Omit & { + recurrenceRule?: RecurrenceRule | null; + /** Keys are LocalDateTimes in the event's zone; values are PatchObjects against the master. */ + recurrenceOverrides?: Record> | null; + [property: string]: unknown; +}; + +export type AddressBookLike = Omit; + +export type CalendarLike = Omit & { + defaultAlertsWithTime?: Record | null; + defaultAlertsWithoutTime?: Record | 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; diff --git a/src/stores/device-sync-store.ts b/src/stores/device-sync-store.ts new file mode 100644 index 00000000..2350fb4d --- /dev/null +++ b/src/stores/device-sync-store.ts @@ -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; + calendarSelection: Record; + /** 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>; + lastRun: Partial>; + /** 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>; +} + +export function emptyAccountDeviceSync(): AccountDeviceSync { + return { + contactsSelection: {}, + calendarSelection: {}, + reminderOwner: null, + intervalSeconds: {}, + lastRun: {}, + }; +} + +interface DeviceSyncState { + accounts: Record; + update: (registryId: string, patch: Partial) => 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()( + 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 { + 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', + }; +}