From 09c2ff1f67a0fe981989a8ec891d85d66eb65a21 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:43:31 +0200 Subject: [PATCH] fix: upload a contact's removal from a group made on the phone #34 Whether a membership edit still has to go up is decided against the group cards on the server, which the engine indexes from their shadows. The index was only read before planning a download that held contacts, so in a run that downloaded no contact, such as the upload sync after an edit, it was empty: removing a contact from a group looked already done on the server, the contact was marked clean and the removal was never sent. The engine now reads the index before planning uploads too. --- .../__tests__/engine/real-planners.test.ts | 18 ++++++++++++++++++ src/device-sync/engine/contacts-sync.ts | 8 +++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/device-sync/__tests__/engine/real-planners.test.ts b/src/device-sync/__tests__/engine/real-planners.test.ts index fc4e91d5..097c1f2b 100644 --- a/src/device-sync/__tests__/engine/real-planners.test.ts +++ b/src/device-sync/__tests__/engine/real-planners.test.ts @@ -185,6 +185,24 @@ describe('device sync engine with the real planners', () => { const uid = h.server.get('ContactCard', 'a', ada)!.uid as string; expect(h.server.get('ContactCard', 'a', group)!.members).toEqual({ [uid]: true }); }); + + it('uploads a removal from a group in a run that downloads no contact', async () => { + const h = real(); + // A keyed phone row shows the editor works in place, so a missing membership row is a removal. + h.server.addCard('a', { uid: 'ada-uid', name: { full: 'Ada Lovelace' }, phones: { p1: { '@type': 'Phone', number: '+1 111' } } }); + const group = h.server.addCard('a', { uid: 'g1', kind: 'group', name: { full: 'Friends' }, members: { 'ada-uid': true } }); + await h.run(); + const contact = h.contactNamed('Ada Lovelace')!; + const membership = h.device.rows('data').find((d) => Number(d.raw_contact_id) === contact.id && d.mimetype === MimeType.GROUP_MEMBERSHIP)!; + h.device.user.deleteData(Number(membership._id)); + + // The upload sync Android requests after the edit: the server has nothing new. + expect((await h.run(CONTACTS_AUTHORITY, { upload: true })).outcome).toBe('ok'); + + expect(h.server.get('ContactCard', 'a', group)!.members ?? {}).toEqual({}); + expect(h.contacts().find((c) => c.id === contact.id)).toMatchObject({ dirty: false }); + expect(h.device.rows('data').filter((d) => d.mimetype === MimeType.GROUP_MEMBERSHIP)).toEqual([]); + }); }); describe('a device edit of an object the server moved out of every synced collection', () => { diff --git a/src/device-sync/engine/contacts-sync.ts b/src/device-sync/engine/contacts-sync.ts index fe917a84..87854e03 100644 --- a/src/device-sync/engine/contacts-sync.ts +++ b/src/device-sync/engine/contacts-sync.ts @@ -576,8 +576,14 @@ export class ContactsSync extends ItemSync { for (const held of items) if (held.kind === this.contactKind) rows.add(held.kind.meta(held.local).rowId); } - /** The device photo of every contact about to be planned that has a photo row (JPEG, ≤ 512 px). */ + /** + * The group index `groupsOf` answers from (a run that downloaded no + * contact never read it: every removal from a group would look done), and + * the device photo of every contact about to be planned that has a photo + * row (JPEG, ≤ 512 px). + */ protected async beforeUploadPlanning(items: Held[]): Promise { + if (this.groupsStale && items.some((held) => held.kind === this.contactKind)) await this.refreshGroups(); for (const held of items) { if (held.kind !== this.contactKind) continue; const contact = held.local as LocalContact;