From 17756fc5239fae42fd58a15ce8747e67203e7f87 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Tue, 9 Jun 2026 22:39:11 -0400 Subject: [PATCH] fix(05-review): NEW-WR-01 emit delete changes when server returns zero events (whole-cache clear) Pre-capture all currently-cached rows into pendingDeleteRows before the whole-cache db.delete() when seenUids.length === 0. The existing >0 branch behavior is unchanged. Adds a regression test verifying onChanges receives one delete change per cached row on a full-calendar clear. --- apps/api/src/broker/sync.ts | 8 ++++++ apps/api/tests/broker/sync.test.ts | 46 ++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/apps/api/src/broker/sync.ts b/apps/api/src/broker/sync.ts index d4b61cf..a9ed7f1 100644 --- a/apps/api/src/broker/sync.ts +++ b/apps/api/src/broker/sync.ts @@ -256,6 +256,14 @@ export async function syncCalendar( notInArray(calendarEvents.uid, seenUids), ), ) + } else if (onChanges) { + // NEW-WR-01: server returned zero events → entire calendar cache will be cleared. + // Capture ALL currently-cached rows before the delete so delete-change events + // are emitted for each one. Without this, bulk/clear deletions were silently dropped. + pendingDeleteRows = await db + .select({ uid: calendarEvents.uid, title: calendarEvents.title }) + .from(calendarEvents) + .where(eq(calendarEvents.calendarId, cal.id)) } if (seenUids.length > 0) { diff --git a/apps/api/tests/broker/sync.test.ts b/apps/api/tests/broker/sync.test.ts index 447d646..732f374 100644 --- a/apps/api/tests/broker/sync.test.ts +++ b/apps/api/tests/broker/sync.test.ts @@ -365,4 +365,50 @@ describe('syncCalendar', () => { expect(mockDelete).toHaveBeenCalledTimes(1) expect(mockDeleteWhere).toHaveBeenCalledTimes(1) }) + + // NEW-WR-01: When the server returns zero events (whole-cache clear), the + // onChanges callback must still receive a 'delete' change for each cached row + // that is being pruned. Before this fix, the pendingDeleteRows pre-capture only + // ran inside the seenUids.length > 0 branch, so bulk/clear deletions silently + // dropped all delete change events. + it('NEW-WR-01: emits delete change events for each cached row when server returns zero events', async () => { + const { syncCalendar } = await import('../../src/broker/sync.js') + + // First mockWhere call: calendar row lookup (ends in .limit(1)) → { limit: mockLimit } + // Second mockWhere call: pending-delete pre-capture (direct await, no .limit()) → + // must return an iterable array of cached rows. Use mockReturnValueOnce for the + // second call so it returns a resolved Promise instead of { limit }. + // + // Call order when seenUids.length === 0 and onChanges is provided: + // 1. db.select().from(calendars).where(...).limit(1) — calendar row lookup + // 2. db.select({uid,title}).from(calendarEvents).where(...) — pending-delete capture (awaited directly) + mockWhere + .mockReturnValueOnce({ limit: mockLimit }) // call 1: calendar row lookup + .mockResolvedValueOnce([ // call 2: pending-delete capture + { uid: 'uid-to-delete-1', title: 'Event A' }, + { uid: 'uid-to-delete-2', title: 'Event B' }, + ]) + + const mockClient = { + fetchCalendarObjects: vi.fn().mockResolvedValue([]), + } + const mockDavCal = { + url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', + displayName: 'Test Calendar', + ctag: 'ctag-empty', + syncToken: null, + } + + const collectedChanges: import('../../src/lib/eventChangeDispatcher.js').EventChange[] = [] + const onChanges = (changes: import('../../src/lib/eventChangeDispatcher.js').EventChange[]) => { + collectedChanges.push(...changes) + } + + await syncCalendar(mockClient as never, mockDavCal as never, 1, onChanges) + + // The callback must have received one delete change per cached row. + expect(collectedChanges).toHaveLength(2) + expect(collectedChanges[0]).toMatchObject({ uid: 'uid-to-delete-1', operation: 'delete' }) + expect(collectedChanges[1]).toMatchObject({ uid: 'uid-to-delete-2', operation: 'delete' }) + }) })