fix: keep the other edits of an event moved to another calendar #34

Etar moves an event, or turns a series into a single event, by deleting
the row and inserting a copy. The pair uploaded the move as a patch of
the event, but then took the copy as it stood for the new baseline and
wrote the server's version over it, so everything else saved with the
move was dropped: a changed reminder came back, and an edit made before
the move never reached the server. The synced occurrences, which Etar
does not copy, were deleted with the old row, losing an occurrence edit
that was still waiting.

The copy now starts from the old row's baseline with only what the move
changed, so any other difference stays dirty and uploads right after
the move as an ordinary update. The old row's occurrences move to the
new row with what they wait to upload. Attendee rows of the copy are
the app's re-insert (Etar writes everyone as required, without a
status) and are not read as edits.

Checked with planner tests and runs through the engine and the fake
server: a reminder saved with the move and a pending occurrence edit
both reach the server, the event keeps its uid, and the echo writes
nothing.
This commit is contained in:
Linus Rath
2026-09-27 08:44:58 +02:00
parent 0faee568cc
commit f24ff7da9e
5 changed files with 254 additions and 12 deletions
@@ -166,6 +166,91 @@ describe('calendar device edits through the engine', () => {
expect(h.server.all('CalendarEvent', 'team')).toEqual([]);
});
describe("Etar's move (delete + insert) with other edits", () => {
/** The event columns Etar copies into the row it inserts when it moves an event. */
const ETAR_COPY = [
Events.TITLE, Events.DESCRIPTION, Events.EVENT_LOCATION, Events.STATUS, Events.AVAILABILITY, Events.ACCESS_LEVEL,
Events.EVENT_COLOR, Events.DTSTART, Events.DTEND, Events.DURATION, Events.EVENT_TIMEZONE, Events.ALL_DAY, Events.RRULE, Events.EXDATE,
];
function etarMove(h: Harness, masterId: number, calendarRowId: number, reminders: Array<{ minutes: number }>): number {
const old = h.events().find((e) => Number(e._id) === masterId)!;
const values = Object.fromEntries(ETAR_COPY.filter((c) => old[c] !== undefined).map((c) => [c, old[c]]));
h.device.user.deleteEvent(masterId);
return h.device.user.insertEvent(calendarRowId, values, {
reminders: reminders.map((r) => ({ [Reminders.MINUTES]: r.minutes, [Reminders.METHOD]: 1 })),
});
}
const minutes = (h: Harness, eventId: number) =>
h.device.rows('reminders').filter((r) => Number(r[Reminders.EVENT_ID]) === eventId).map((r) => Number(r[Reminders.MINUTES]));
it('uploads a reminder saved with the move, and keeps the event', async () => {
const h = real();
const work = h.server.addCalendar('a', { name: 'Work' });
const id = h.server.addEvent('a', {
uid: 'lunch-uid',
title: 'Lunch',
start: '2026-10-06T12:00:00',
duration: 'PT1H',
timeZone: 'Europe/Berlin',
keywords: { important: true },
alerts: { al1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' }, action: 'display' } },
calendarIds: { [h.calendar]: true },
});
await h.run(CALENDAR_AUTHORITY);
const master = Number(h.events().find((e) => e[Events._SYNC_ID] === `a/${id}`)!._id);
const workRow = Number(h.device.rows('calendars').find((c) => c._sync_id === `a/${work}`)!._id);
const moved = etarMove(h, master, workRow, [{ minutes: 60 }]);
const report = await h.run(CALENDAR_AUTHORITY);
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 0, deleted: 0 } } });
const event = h.server.get('CalendarEvent', 'a', id)!;
expect(event).toMatchObject({ uid: 'lunch-uid', calendarIds: { [work]: true }, keywords: { important: true } });
const alerts = Object.values(event.alerts as Record<string, { trigger: { offset: string } }>).map((a) => a.trigger.offset);
expect(alerts).toEqual(['-PT1H']);
expect(h.events()).toEqual([expect.objectContaining({ _id: moved, _sync_id: `a/${id}`, [Events.DIRTY]: 0 })]);
expect(minutes(h, moved)).toEqual([60]);
const before = h.batches.log.length;
await h.run(CALENDAR_AUTHORITY);
expect(h.batches.log.slice(before).filter((ops) => ops.some((op) => op.op !== 'syncState' && op.op !== 'assert'))).toEqual([]);
});
it('keeps an occurrence edit that had not reached the server when the series moves', async () => {
const h = real();
const work = h.server.addCalendar('a', { name: 'Work' });
const id = h.server.addEvent('a', {
uid: 'standup-uid',
title: 'Standup',
start: '2026-09-28T09:00:00',
duration: 'PT15M',
timeZone: 'Europe/Berlin',
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'daily', count: 5 },
recurrenceOverrides: { '2026-09-29T09:00:00': { title: 'Planning' } },
calendarIds: { [h.calendar]: true },
});
await h.run(CALENDAR_AUTHORITY);
const master = Number(h.events().find((e) => e[Events._SYNC_ID] === `a/${id}`)!._id);
const exception = Number(h.events().find((e) => e[Events.ORIGINAL_SYNC_ID] === `a/${id}`)!._id);
const workRow = Number(h.device.rows('calendars').find((c) => c._sync_id === `a/${work}`)!._id);
// The occurrence is renamed on the device; before it syncs, Etar moves the series (it copies only unsynced exceptions).
h.device.user.updateEvent(exception, { [Events.TITLE]: 'Sprint planning' });
const moved = etarMove(h, master, workRow, []);
const report = await h.run(CALENDAR_AUTHORITY);
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 0, deleted: 0 } } });
const event = h.server.get('CalendarEvent', 'a', id)!;
expect(event.calendarIds).toEqual({ [work]: true });
expect((event.recurrenceOverrides as Overrides)['2026-09-29T09:00:00']).toMatchObject({ title: 'Sprint planning' });
const rows = h.events();
expect(rows.filter((e) => e[Events.ORIGINAL_ID] === null)).toEqual([expect.objectContaining({ _id: moved, _sync_id: `a/${id}`, [Events.DIRTY]: 0 })]);
expect(rows.filter((e) => e[Events.ORIGINAL_ID] !== null)).toEqual([
expect.objectContaining({ [Events.ORIGINAL_ID]: moved, [Events.CALENDAR_ID]: workRow, [Events.TITLE]: 'Sprint planning', [Events.DIRTY]: 0 }),
]);
});
});
it('keeps a description and location cleared on one occurrence cleared', async () => {
const h = real();
const id = h.server.addEvent('a', {
@@ -425,6 +425,94 @@ describe('calendar planner: pairs', () => {
const events = await h.events();
expect(calendarPlanner.planPairs(events.filter((e) => e.deleted), events.filter((e) => !e.syncId), h.ctx)).toEqual([]);
});
/** Etar's move of `single()` (delete + insert into Work), with `extra` columns and children on the new row. */
const lunchColumns = {
[Events.TITLE]: 'Lunch', [Events.DTSTART]: utc('2026-10-06T10:00:00Z'), [Events.DTEND]: utc('2026-10-06T11:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.STATUS]: 1, [Events.AVAILABILITY]: 0, [Events.ACCESS_LEVEL]: 0,
};
async function pairAndApply(h: Harness) {
const events = await h.events();
const pairs = calendarPlanner.planPairs(events.filter((e) => e.deleted), events.filter((e) => !e.syncId && !e.deleted), h.ctx);
expect(pairs).toHaveLength(1);
await h.apply(pairs[0].ops);
return pairs[0];
}
const alert15 = { al1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' }, action: 'display' } } as CalendarEventWire['alerts'];
it('keeps a reminder saved with the move for the next upload instead of taking it as the baseline', async () => {
const { h, id } = await synced(single({ alerts: alert15 }));
h.fake.user.deleteEvent(id);
const moved = h.fake.user.insertEvent(h.rowIds.get('w')!, lunchColumns, { reminders: [{ [Reminders.MINUTES]: 60, [Reminders.METHOD]: 1 }] });
const pair = await pairAndApply(h);
expect(pair.actions).toEqual([{ kind: 'update', id: 'e1', patch: { 'calendarIds/b': null, 'calendarIds/w': true } }]);
const after = (await h.local('e1'))!;
expect(after).toMatchObject({ eventId: moved, dirty: true });
const [update] = actionsOf(calendarPlanner.planUpload(after, h.ctx));
if (update.kind !== 'update') throw new Error('not an update');
// Only the reminder is left to send: the move went up with the pair.
expect(Object.keys(update.patch).every((k) => k.startsWith('alerts/'))).toBe(true);
const alerts = serverApply(after.shadow!, update.patch).alerts!;
expect(Object.values(alerts).map((a) => (a.trigger as { offset?: string }).offset)).toEqual(['-PT1H']);
});
it('uploads an edit made before the move, which the new row copied', async () => {
const { h, id } = await synced(single({ description: 'Bring salad' }));
h.fake.user.updateEvent(id, { [Events.DESCRIPTION]: 'Bring soup' });
h.fake.user.deleteEvent(id);
h.fake.user.insertEvent(h.rowIds.get('w')!, { ...lunchColumns, [Events.DESCRIPTION]: 'Bring soup' });
await pairAndApply(h);
const after = (await h.local('e1'))!;
expect(after.dirty).toBe(true);
expect(actionsOf(calendarPlanner.planUpload(after, h.ctx))).toEqual([{ kind: 'update', id: 'e1', patch: { description: 'Bring soup' } }]);
});
it('takes attendee rows an app re-inserted with constant details as they are, uploading nothing for them', async () => {
const participants = {
bob: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.net', name: 'Bob', roles: { attendee: true, optional: true }, participationStatus: 'accepted' },
carol: { '@type': 'Participant', calendarAddress: 'mailto:carol@example.net', name: 'Carol', roles: { attendee: true }, participationStatus: 'declined' },
} as CalendarEventWire['participants'];
const { h, id } = await synced(single({ participants, organizerCalendarAddress: 'mailto:boss@example.net' }));
const attendees = h.fake.rows('attendees', `${Attendees.EVENT_ID} = ?`, [id]).map((a) => ({
// Etar writes every attendee it re-inserts as a required attendee without a status.
[Attendees.ATTENDEE_EMAIL]: a[Attendees.ATTENDEE_EMAIL], [Attendees.ATTENDEE_NAME]: a[Attendees.ATTENDEE_NAME],
[Attendees.ATTENDEE_RELATIONSHIP]: 1, [Attendees.ATTENDEE_TYPE]: 1, [Attendees.ATTENDEE_STATUS]: 0,
}));
const organizer = h.row(id)[Events.ORGANIZER];
h.fake.user.deleteEvent(id);
h.fake.user.insertEvent(h.rowIds.get('w')!, { ...lunchColumns, [Events.ORGANIZER]: organizer }, { attendees });
await pairAndApply(h);
const after = (await h.local('e1'))!;
expect(after.dirty).toBe(false);
expect(calendarPlanner.planUpload({ ...after, dirty: true }, h.ctx).kind).toBe('clean');
});
it('keeps the timing of a series turned single against the series, so a start moved before uploads', async () => {
const { h, id } = await synced(single({ recurrenceRule: { frequency: 'daily' } }));
// Moved an hour later in place, then turned into a single event (Etar: delete + insert).
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-06T11:00:00Z') });
h.fake.user.deleteEvent(id);
h.fake.user.insertEvent(h.rowIds.get('b')!, { ...lunchColumns, [Events.DTSTART]: utc('2026-10-06T11:00:00Z'), [Events.DTEND]: utc('2026-10-06T12:00:00Z') });
const pair = await pairAndApply(h);
expect(pair.actions).toEqual([{ kind: 'update', id: 'e1', patch: { recurrenceRule: null } }]);
const after = (await h.local('e1'))!;
expect(after.dirty).toBe(true);
expect(actionsOf(calendarPlanner.planUpload(after, h.ctx))).toEqual([{ kind: 'update', id: 'e1', patch: { start: '2026-10-06T13:00:00' } }]);
// Without the earlier edit, the single event matches what the server holds after the pair: nothing left.
const plain = await synced(single({ recurrenceRule: { frequency: 'daily' } }));
plain.h.fake.user.deleteEvent(plain.id);
plain.h.fake.user.insertEvent(plain.h.rowIds.get('b')!, lunchColumns);
await pairAndApply(plain.h);
expect((await plain.h.local('e1'))!.dirty).toBe(false);
});
});
describe('calendar planner: baselines, zones and reminder owners', () => {
+74 -11
View File
@@ -9,17 +9,20 @@
* target account and a destroy in the source.
*/
import { Attendees, Events } from '../android-columns';
import type { CalendarContext, LocalEvent, LocalEventRow, OpGroup } from '../planner';
import type { CalendarContext, EventBaseline, LocalEvent, LocalEventRow, OpGroup } from '../planner';
import type { Row } from '../types';
import type { CalendarEventWire } from '../wire';
import { exceptionRef, parseObjectRef } from '../common/ids';
import { clone } from '../common/json';
import { applyPatch, type PatchObject } from '../common/patch';
import { EXCEPTION_CELLS, MASTER_CELLS, encodeBaseline, makeBaseline } from './columns';
import { durationTextSeconds } from './duration';
import { isExcluded, overridesOf } from './exceptions';
import { GroupBuilder, assertRow, deleteRow, updateRow } from './rows';
import { linkedExceptionCount } from './merge';
import { GroupBuilder, assertExceptionCount, assertRow, deleteRow, updateRow } from './rows';
import { rruleParts } from './rrule';
import { sendsSchedulingMessages } from './scheduling';
import { MASTER_COLUMN_UNITS, deviceChangedUnit, exdateEntries, remindersDiffer, sideOfBaseline, sideOfRow } from './units';
import { put } from './upload';
import { isUnsetStatus } from './values';
@@ -101,24 +104,80 @@ export function pairPatch(
return { patch, sendSchedulingMessages: scheduling };
}
const children = (row: LocalEventRow) => ({ attendees: row.attendees.map((a) => a.cells), reminders: row.reminders.map((r) => r.cells) });
/**
* Applied once the pair's patch is accepted: the old row and its exception
* rows go, the new row takes the identity (its copied exception rows the
* overrides that still exist), and the shadow is the old one with the patch
* applied until the next download brings the server's.
* The new row's baseline: the old row's (what the server held, as last
* written) with what the patch changed, the calendar and, for a series turned
* single, its rule and its timing as a single event. So an edit saved with the
* move, or made before it, still differs from the baseline and uploads next.
* Attendee rows are the app's re-insert (Etar writes every attendee it copies
* as required, without a status), so their details are no edits: they are
* taken as they are, like the reminders of a row that copied none.
*/
export function pairOps(deleted: LocalEvent, fresh: LocalEvent, patch: PatchObject): OpGroup {
function pairedBaseline(deleted: LocalEvent, fresh: LocalEvent, kind: PairKind, ctx: CalendarContext): EventBaseline {
const own = makeBaseline(fresh.cells, MASTER_CELLS, children(fresh).attendees, children(fresh).reminders);
if (!deleted.baseline) return own;
const next = clone(deleted.baseline);
next.cells[Events.CALENDAR_ID] = own.cells[Events.CALENDAR_ID];
next.attendees = own.attendees;
if (ctx.reminderOwner !== 'device' || !fresh.reminders.length) next.reminders = own.reminders;
if (kind.unrecur) {
const start = num(deleted.baseline.cells[Events.DTSTART]);
const length = lengthMs(deleted.baseline.cells);
next.cells[Events.DTEND] = start !== null && length !== null ? start + length : own.cells[Events.DTEND];
next.cells[Events.DURATION] = null;
next.cells[Events.RRULE] = own.cells[Events.RRULE];
next.cells[Events.EXDATE] = own.cells[Events.EXDATE];
}
return next;
}
/** Whether a master holds a change against `baseline` that an upload would look at. */
function changedSince(row: LocalEventRow, baseline: EventBaseline, ctx: CalendarContext): boolean {
const cur = sideOfRow(row);
const bl = sideOfBaseline(baseline);
if (MASTER_COLUMN_UNITS.some((unit) => deviceChangedUnit(unit, cur, bl))) return true;
const [now, before] = [exdateEntries(cur), exdateEntries(bl)];
if (now.size !== before.size || [...now].some((e) => !before.has(e))) return true;
return ctx.reminderOwner === 'device' && remindersDiffer(cur, bl);
}
/**
* Applied once the pair's patch is accepted: the new row takes the identity
* and the old row goes. The old row's exception rows move to the new row with
* what they wait to upload (Etar copies only the exceptions it never synced;
* an app's copy of one the old row has goes). A copied exception takes the
* identity of its override. The shadow is the old one with the patch applied
* until the server's comes back. A row that still differs from its baseline
* (see `pairedBaseline`) stays dirty and uploads next.
*/
export function pairOps(deleted: LocalEvent, fresh: LocalEvent, kind: PairKind, patch: PatchObject, ctx: CalendarContext): OpGroup {
const syncId = deleted.syncId!;
const shadow: CalendarEventWire = applyPatch(deleted.shadow!, patch) ?? deleted.shadow!;
const group = new GroupBuilder(syncId);
assertRow(group, deleted, false, false);
for (const x of deleted.exceptions) assertRow(group, x, true, x.dirty || x.deleted);
assertExceptionCount(group, deleted.eventId, linkedExceptionCount(deleted));
assertRow(group, fresh, false, true);
for (const x of fresh.exceptions) assertRow(group, x, true, true);
for (const x of deleted.exceptions) deleteRow(group, x.eventId);
assertExceptionCount(group, fresh.eventId, linkedExceptionCount(fresh));
const kept = new Set<string>();
for (const x of deleted.exceptions) {
// A series turned single has no occurrences left.
if (kind.unrecur || !x.recurrenceId) {
deleteRow(group, x.eventId);
continue;
}
kept.add(x.recurrenceId);
const values: Row = { [Events.ORIGINAL_ID]: fresh.eventId, [Events.CALENDAR_ID]: fresh.calendarRowId };
if (x.baseline) values[Events.SYNC_DATA4] = encodeBaseline({ ...x.baseline, cells: { ...x.baseline.cells, [Events.CALENDAR_ID]: fresh.calendarRowId } });
group.write({ op: 'update', table: 'events', id: x.eventId, values, expectCount: 1 });
}
deleteRow(group, deleted.eventId);
const children = (row: LocalEventRow) => ({ attendees: row.attendees.map((a) => a.cells), reminders: row.reminders.map((r) => r.cells) });
const cells: Row = {};
for (const c of MASTER_CELLS) cells[c] = fresh.cells[c] ?? null;
const baseline = pairedBaseline(deleted, fresh, kind, ctx);
updateRow(
group,
{ ...fresh, shadow: null },
@@ -126,18 +185,22 @@ export function pairOps(deleted: LocalEvent, fresh: LocalEvent, patch: PatchObje
isException: false,
cells,
...children(fresh),
baseline: makeBaseline(fresh.cells, MASTER_CELLS, children(fresh).attendees, children(fresh).reminders),
baseline,
syncId,
uid: typeof shadow.uid === 'string' ? shadow.uid : null,
shadow,
pending: null,
clearDirty: true,
clearDirty: !changedSince(fresh, baseline, ctx),
hasParticipants: fresh.attendees.length > 0,
},
);
const overrides = overridesOf(shadow);
for (const x of fresh.exceptions) {
const key = x.recurrenceId;
if (key && kept.has(key)) {
deleteRow(group, x.eventId);
continue;
}
const values: Row = { [Events.ORIGINAL_SYNC_ID]: syncId };
if (key && overrides[key] && !isExcluded(overrides[key])) {
Object.assign(values, {
+1 -1
View File
@@ -385,7 +385,7 @@ function planPairs(
if (!pair) continue;
const action: UploadAction<CalendarEventWire> = { kind: 'update', id: ref.id, patch: pair.patch };
if (pair.sendSchedulingMessages) action.sendSchedulingMessages = true;
pairs.push({ deleted: d, fresh: f, actions: [action], ops: pairOps(d, f, pair.patch) });
pairs.push({ deleted: d, fresh: f, actions: [action], ops: pairOps(d, f, kind, pair.patch, ctx) });
used.add(f.eventId);
break;
}
+6
View File
@@ -492,6 +492,12 @@ export class CalendarSync extends ItemSync {
if (ownId) this.markStale(acct, ownId);
return [];
}
if (this.eventKind.meta(moved).dirty) {
// Edits saved with the move, or made before it, are still to go up: the update phase
// sends them as the moved event's, and its accepted write brings the server's version.
this.env.report.stats.uploaded.updated++;
return [];
}
const held: Held = { kind: this.eventKind, local: moved };
try {
const work = this.acceptWork(acct, { held, meta: this.eventKind.meta(moved), fingerprint: '', actions: [] }, server, false);