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 };