From 34236f677b3ff06beb6e5220ef42802f43816859 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:44:10 +0200 Subject: [PATCH] fix: never resend a group's members from an outdated copy #34 A group whose latest server version the device could not store waits on the stale list, and nothing uploads for it until it has been fetched again. Membership edits bypassed that check: adding a contact to such a group sent the whole member list as the device last knew it, which removed members another client had added meanwhile. Membership edits for a stale group now wait for that fetch, and the report lists them. --- .../__tests__/engine/real-planners.test.ts | 30 +++++++++++++++++++ src/device-sync/engine/contacts-sync.ts | 5 ++++ 2 files changed, 35 insertions(+) diff --git a/src/device-sync/__tests__/engine/real-planners.test.ts b/src/device-sync/__tests__/engine/real-planners.test.ts index 097c1f2b..a493d065 100644 --- a/src/device-sync/__tests__/engine/real-planners.test.ts +++ b/src/device-sync/__tests__/engine/real-planners.test.ts @@ -186,6 +186,36 @@ describe('device sync engine with the real planners', () => { expect(h.server.get('ContactCard', 'a', group)!.members).toEqual({ [uid]: true }); }); + it('waits with a membership edit while the group card is stale, instead of resending its members', async () => { + const { h, ada, group } = await adaJoinsFriends(); + const [bob] = addServerCards(h, ['Bob']); + const bobUid = h.server.get('ContactCard', 'a', bob)!.uid as string; + // Another client adds Bob; this device cannot store the new version of the group this run. + h.server.serverUpdate('ContactCard', 'a', group, { members: { [bobUid]: true } }); + h.deps.planners = { + ...h.deps.planners, + contacts: { + ...contactsPlanner, + planGroupDownload: (card, local, ctx) => { + if (card.id === group) throw new Error('cannot store this group now'); + return contactsPlanner.planGroupDownload(card, local, ctx); + }, + }, + }; + + await h.run(); + + const adaUid = h.server.get('ContactCard', 'a', ada)!.uid as string; + expect(h.server.get('ContactCard', 'a', group)!.members).toEqual({ [bobUid]: true }); + expect(h.contacts().find((c) => c.name === 'Ada Lovelace')).toMatchObject({ dirty: true }); + + h.deps.planners = { contacts: contactsPlanner, calendar: calendarPlanner }; + expect((await h.run()).outcome).toBe('ok'); + + expect(h.server.get('ContactCard', 'a', group)!.members).toEqual({ [bobUid]: true, [adaUid]: true }); + expect(h.contacts().find((c) => c.name === 'Ada Lovelace')).toMatchObject({ dirty: false }); + }); + 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. diff --git a/src/device-sync/engine/contacts-sync.ts b/src/device-sync/engine/contacts-sync.ts index 87854e03..3c8660ba 100644 --- a/src/device-sync/engine/contacts-sync.ts +++ b/src/device-sync/engine/contacts-sync.ts @@ -708,6 +708,11 @@ export class ContactsSync extends ItemSync { this.env.report.itemError({ ref: id ? refOf(acct, id) : 'membership', side: 'upload', type: 'unknownGroup' }); continue; } + if (this.isStale(acct, group.sourceId)) { + // Its shadow is behind the server: members built from it could undo server changes. The contact stays dirty. + this.env.report.itemError({ ref: group.sourceId as string, side: 'upload', type: 'stale', description: 'Waits until the server version could be stored' }); + continue; + } let pending = byGroup.get(group.sourceId as string); if (!pending) { const held: Held = { kind: this.groupKind, local: group };