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.
This commit is contained in:
Lucas Berger
2026-06-09 22:39:11 -04:00
parent c7ef5811d1
commit 17756fc523
2 changed files with 54 additions and 0 deletions
+8
View File
@@ -256,6 +256,14 @@ export async function syncCalendar(
notInArray(calendarEvents.uid, seenUids), 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) { if (seenUids.length > 0) {
+46
View File
@@ -365,4 +365,50 @@ describe('syncCalendar', () => {
expect(mockDelete).toHaveBeenCalledTimes(1) expect(mockDelete).toHaveBeenCalledTimes(1)
expect(mockDeleteWhere).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<array> 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' })
})
}) })