diff --git a/apps/api/src/broker/outboxWorker.ts b/apps/api/src/broker/outboxWorker.ts index 085fe07..8f25bb2 100644 --- a/apps/api/src/broker/outboxWorker.ts +++ b/apps/api/src/broker/outboxWorker.ts @@ -193,11 +193,28 @@ async function dispatchRow(row: OutboxRow): Promise { description: fields.description as string | undefined, rruleString: fields.recurrence && fields.recurrence !== 'none' ? RRULE_PRESETS[fields.recurrence as string] : undefined, }) + // WR-02: re-read the freshest etag from calendarEvents just before PUT. + // Rapid successive edits to the same uid enqueue multiple update rows, each + // carrying the etag at enqueue time. If a prior edit succeeded and triggered + // a re-sync, calendarEvents.etag was updated but the next update row still + // carries the old enqueue-time etag — guaranteed 412 on the second edit. + // Using the freshest cached etag here prevents the spurious conflict toast + // while still preserving genuine conflict detection (D-08): a real external + // change updates calendarEvents.etag differently from any pending row's etag. + let etagForPut: string | null = row.etag ?? null + const freshEtagRows = (await db + .select({ etag: calendarEvents.etag }) + .from(calendarEvents) + .where(eq(calendarEvents.uid, row.uid))) as Array<{ etag: string | null }> + if (freshEtagRows.length > 0 && freshEtagRows[0].etag != null) { + etagForPut = freshEtagRows[0].etag + } + response = await updateCalendarEvent( client, row.calendarObjectUrl, icsString, - row.etag ?? null, + etagForPut, ) } else { // create