mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
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:
@@ -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', {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user