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.
This commit is contained in:
Linus Rath
2026-09-27 07:53:36 +02:00
parent 09c2ff1f67
commit 34236f677b
2 changed files with 35 additions and 0 deletions
@@ -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.
+5
View File
@@ -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 };