diff --git a/apps/api/src/broker/sync.ts b/apps/api/src/broker/sync.ts index cc8c13c..d4b61cf 100644 --- a/apps/api/src/broker/sync.ts +++ b/apps/api/src/broker/sync.ts @@ -239,9 +239,15 @@ export async function syncCalendar( // touches another calendar or the other household member's rows (BUG B). // When the server returns zero events, prune the whole calendar's cache. // NOTIF-03: collect deleted uids for the onChanges callback. + // + // WR-04: pre-capture the rows to delete BEFORE the DB delete so we know what + // was removed, then push changes AFTER the delete completes. This ensures the + // onChanges payload only describes events that are truly gone from the cache — + // not events that may have been re-fetched in a concurrent poll. + let pendingDeleteRows: Array<{ uid: string; title: string | null }> = [] if (onChanges && seenUids.length > 0) { // Find cached uids that are about to be pruned so we can emit delete changes - const cachedRows = await db + pendingDeleteRows = await db .select({ uid: calendarEvents.uid, title: calendarEvents.title }) .from(calendarEvents) .where( @@ -250,13 +256,6 @@ export async function syncCalendar( notInArray(calendarEvents.uid, seenUids), ), ) - for (const row of cachedRows) { - changes.push({ - uid: row.uid, - title: row.title ?? null, - operation: 'delete', - }) - } } if (seenUids.length > 0) { @@ -267,6 +266,16 @@ export async function syncCalendar( await db.delete(calendarEvents).where(eq(calendarEvents.calendarId, cal.id)) } + // Collect delete changes AFTER the DB delete (WR-04: avoids race where the same + // uid is re-inserted by a concurrent poll before onChanges fires). + for (const row of pendingDeleteRows) { + changes.push({ + uid: row.uid, + title: row.title ?? null, + operation: 'delete', + }) + } + // 6. Fire onChanges callback if provided and there are changes (NOTIF-03). // Fire-and-forget: sync correctness must not depend on push success. if (onChanges && changes.length > 0) {