fix: never undo server changes to a card moved out of synced books #34

When another client moved a contact or an event out of every address
book or calendar the device syncs while the device had an unsent edit
of it, the download only noted it for dropping after the upload. Its
shadow stayed at the old version while the stored state moved past the
change, so the upload passed `ifInState` and resent whole maps built
from the old shadow: a number or an occurrence added on the server
meanwhile was lost. A full reconcile that found such an object did the
same.

The download now merges the new version into the dirty rows like any
other change, so the upload builds on it, and the rows still go once
it is accepted. A contact's collections are read from its shadow:
SYNC1 lists only the selected books and is empty for a card that left
them all, so such a contact was never dropped after its upload. The
calendar planner cannot merge an event that is in no synced calendar
yet; until it can, such an event waits on the device and is reported
instead of overwriting the server.

Checked with the real planners through `/changes` and through a full
reconcile.
This commit is contained in:
Linus Rath
2026-09-27 07:53:35 +02:00
parent 7dbbd1ce25
commit 7187cab904
3 changed files with 101 additions and 5 deletions
@@ -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<string, unknown>) =>
Object.values((card.phones ?? {}) as Record<string, { number: string }>).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<string, { title?: string }>).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', {
+2 -1
View File
@@ -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({
+15 -4
View File
@@ -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<ServerObject>(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<Kind, Set<string>> | 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) => {