diff --git a/src/device-sync/__tests__/engine/real-planners.test.ts b/src/device-sync/__tests__/engine/real-planners.test.ts index d44e3e06..2a20e232 100644 --- a/src/device-sync/__tests__/engine/real-planners.test.ts +++ b/src/device-sync/__tests__/engine/real-planners.test.ts @@ -155,6 +155,90 @@ describe('device sync engine with the real planners', () => { expect(h.contacts().every((c) => !c.dirty)).toBe(true); }); + describe('a device edit of an object the server moved out of every synced collection', () => { + /** Ada with two numbers in the synced book; the device deletes the second in place (AOSP). */ + async function adaWithDeletedNumber() { + const h = real(); + const archive = h.server.addAddressBook('a', { name: 'Archive' }); + h.prefs.contactsSelection[`a/${archive}`] = false; + const ada = h.server.addCard('a', { + uid: 'ada-uid', + name: { full: 'Ada Lovelace' }, + phones: { p1: { '@type': 'Phone', number: '+1 111' }, p2: { '@type': 'Phone', number: '+1 222' } }, + addressBookIds: { [h.book]: true }, + }); + await h.run(); + const contact = h.contactNamed('Ada Lovelace')!; + const p2 = h.device.rows('data').find((d) => Number(d.raw_contact_id) === contact.id && d[Data.DATA1] === '+1 222')!; + h.device.user.deleteData(Number(p2._id)); + // Meanwhile another client files Ada in Archive only and adds a number. + h.server.serverUpdate('ContactCard', 'a', ada, { addressBookIds: { [archive]: true }, 'phones/p3': { '@type': 'Phone', number: '+1 333' } }); + return { h, ada, archive }; + } + + const numbers = (card: Record) => + Object.values((card.phones ?? {}) as Record).map((p) => p.number).sort(); + + it('uploads it against the server version, then drops the rows', async () => { + const { h, ada, archive } = await adaWithDeletedNumber(); + + const report = await h.run(); + + expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { updated: 1 } } }); + const card = h.server.get('ContactCard', 'a', ada)!; + expect(numbers(card)).toEqual(['+1 111', '+1 333']); + expect(card.addressBookIds).toEqual({ [archive]: true }); + expect(h.contacts()).toEqual([]); + }); + + it('does the same when a full reconcile finds the object outside', async () => { + const { h, ada, archive } = await adaWithDeletedNumber(); + h.server.truncateChangeLog('a', 'contacts'); + + const report = await h.run(); + + expect(report).toMatchObject({ outcome: 'ok', itemErrors: [] }); + const card = h.server.get('ContactCard', 'a', ada)!; + expect(numbers(card)).toEqual(['+1 111', '+1 333']); + expect(card.addressBookIds).toEqual({ [archive]: true }); + expect(h.contacts()).toEqual([]); + }); + + it('never uploads an event edit against an outdated shadow', async () => { + const h = real(); + const archive = h.server.addCalendar('a', { name: 'Archive' }); + h.prefs.calendarSelection[`a/${archive}`] = false; + const series = h.server.addEvent('a', { + uid: 'series-uid', + title: 'Standup', + start: '2026-09-28T09:00:00', + duration: 'PT15M', + timeZone: 'Europe/Berlin', + recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'daily' }, + recurrenceOverrides: { '2026-09-29T09:00:00': { title: 'Planning' } }, + calendarIds: { [h.calendar]: true }, + }); + await h.run(CALENDAR_AUTHORITY); + const master = h.events().find((e) => e._sync_id === `a/${series}`)!; + const later = Number(master[Events.DTSTART]) + 3_600_000; + // The device moves the series by an hour; another client files it in Archive and changes another instance. + h.device.user.updateEvent(Number(master._id), { [Events.DTSTART]: later }); + h.server.serverUpdate('CalendarEvent', 'a', series, { + calendarIds: { [archive]: true }, + 'recurrenceOverrides/2026-10-01T09:00:00': { title: 'Retro' }, + }); + + expect((await h.run(CALENDAR_AUTHORITY)).outcome).toBe('ok'); + + const event = h.server.get('CalendarEvent', 'a', series)!; + const titles = Object.values((event.recurrenceOverrides ?? {}) as Record).map((o) => o.title).sort(); + expect(titles).toEqual(['Planning', 'Retro']); + // The move went up, or it still waits on the device: it is not lost. + const row = h.events().find((e) => Number(e._id) === Number(master._id)); + expect(event.start === '2026-09-28T10:00:00' || (Number(row?.dirty) === 1 && Number(row?.[Events.DTSTART]) === later)).toBe(true); + }); + }); + it('creates a device event that carries the uid of a synced event (an app copied it) under a fresh uid', async () => { const h = real(); const standup = h.server.addEvent('a', { diff --git a/src/device-sync/engine/contacts-sync.ts b/src/device-sync/engine/contacts-sync.ts index 3d5c55ee..40257ee4 100644 --- a/src/device-sync/engine/contacts-sync.ts +++ b/src/device-sync/engine/contacts-sync.ts @@ -151,7 +151,8 @@ export class ContactsSync extends ItemSync { deleted: c.deleted, isNew: !c.sourceId, poison: c.poison, - collections: c.collections, + // The card's books as last seen: SYNC1 lists only the selected ones, none for a card that left them all. + collections: c.shadow ? this.cardKeys(accountOfRef(c.sourceId), c.shadow) : c.collections, }), fingerprint: (c) => poisonFingerprint({ diff --git a/src/device-sync/engine/item-sync.ts b/src/device-sync/engine/item-sync.ts index f390a57f..e93ec84a 100644 --- a/src/device-sync/engine/item-sync.ts +++ b/src/device-sync/engine/item-sync.ts @@ -408,14 +408,16 @@ export abstract class ItemSync { if (flags.dirty || flags.deleted) verify.push(id); else gone.add(id); } + const outside: string[] = []; if (verify.length) { - // Destroyed, or moved out of the synced collections? Only a destroy takes dirty rows. + // Destroyed, or moved out of the synced collections? Only a destroy takes dirty rows; the + // others are downloaded like any change, so a dirty item merges the server's version. const { list, notFound } = await this.env.jmap.get(this.itemType, acct, verify, ['id']); for (const id of notFound) gone.add(id); - for (const object of list) this.outsideOf(acct).add(refOf(acct, object.id)); + for (const object of list) outside.push(object.id); } const selected = collections.map((c) => collectionKey(acct, c)); - await this.processIds(acct, [], [...gone], (next) => { + await this.processIds(acct, outside, [...gone], (next) => { const account = accountOf(next, acct); account.itemsState = from; account.reconcile = null; @@ -555,7 +557,14 @@ export abstract class ItemSync { written: Map> | null, ): Work | null { if (!kind || !this.inSelection(acct, object)) { - return found ? this.removeWork(acct, object.id, found, 'outside') : null; + if (!found) return null; + const meta = found.kind.meta(found.local); + if (found.kind === kind && meta.dirty && !meta.deleted && !meta.isNew) { + // Uploaded first, dropped afterwards: merged like any dirty item, so the upload builds on the server's version. + this.outsideOf(acct).add(refOf(acct, object.id)); + return this.downloadWork(kind, acct, object, found.local, written); + } + return this.removeWork(acct, object.id, found, 'outside'); } if (found && found.kind !== kind) { const meta = found.kind.meta(found.local); @@ -1263,6 +1272,8 @@ export abstract class ItemSync { const counted = () => { if (created) this.env.report.stats.uploaded.created++; else this.env.report.stats.uploaded.updated++; + // In no synced collection any more (moved away on the server): the rows go after the upload. + if (!this.inSelection(acct, server)) this.outsideOf(acct).add(refOf(acct, server.id)); this.onAccepted(kind, acct, server); }; const failed = (reason: string, message: string) => {