fix(broker): reconcile deletes into cache + resync before marking outbox done

Two write-path cache bugs surfaced during Gate 2 live testing:

P1 (delete didn't work / ghost event): syncCalendar only UPSERTED events
present on Fastmail and never removed cache rows for events that disappeared.
A successful CalDAV delete left the row in calendar_events forever, so
GET /api/events kept returning it and the UI showed a ghost that 'wouldn't
delete' (even after refresh). Add a prune step: delete calendar_events rows for
this calendar whose uid is absent from the server response (scoped to cal.id so
it never touches another calendar or the other member's rows — BUG B). Empty
server result prunes the whole calendar's cache.

P2 (edit needed a manual refresh): the outbox worker marked a row 'done' BEFORE
triggerTargetedResync refreshed the cache. The PWA's SyncStateToast invalidates
['events'] the instant sync-status flips to 'done', so it refetched stale cache.
Re-sync first, then mark done — 'done' now guarantees the cache reflects the write.

Tests: +2 prune regressions (present-subset prune, empty-server prune-all).
This commit is contained in:
Lucas Berger
2026-06-07 16:12:43 -04:00
parent bb61d21c83
commit 98753d8e34
3 changed files with 79 additions and 3 deletions
+7 -2
View File
@@ -410,12 +410,17 @@ export async function runOutboxDrain(): Promise<void> {
failedCreateGroups.add(row.groupId)
}
} else if (result.success) {
// Success — mark done, trigger targeted re-sync to refresh the cache (D-06)
// Success — refresh the local cache BEFORE marking done. The PWA's
// SyncStateToast polls sync-status and invalidates ['events'] the
// instant it sees status='done'; if we marked done first, that refetch
// raced the re-sync and returned stale cache (deleted event still
// present, edit not yet applied) — forcing a manual refresh. Re-syncing
// first means 'done' guarantees the cache already reflects the write.
await triggerTargetedResync(row.calendarUrl, row.userId)
await db
.update(calendarOutbox)
.set({ status: 'done' })
.where(eq(calendarOutbox.id, row.id))
await triggerTargetedResync(row.calendarUrl, row.userId)
} else if (result.hardFail) {
// Hard fail — mark failed immediately, no retry (D-07)
await db
+20 -1
View File
@@ -18,7 +18,7 @@
import type { DAVCalendar } from 'tsdav'
import type { FastmailClient } from './client.js'
import ICAL from 'ical.js'
import { and, eq } from 'drizzle-orm'
import { and, eq, notInArray } from 'drizzle-orm'
import { db } from '../db/client.js'
import { calendars, calendarEvents } from '../db/schema.js'
@@ -73,6 +73,9 @@ export async function syncCalendar(
const objects = await client.fetchCalendarObjects({ calendar: davCal })
// 4. Parse each VCALENDAR/VEVENT and upsert into calendar_events.
// Track every uid we see on the server so step 5 can prune cache rows that
// no longer exist on Fastmail (deletes — local or external).
const seenUids: string[] = []
for (const obj of objects) {
if (!obj.data) continue
@@ -92,6 +95,7 @@ export async function syncCalendar(
const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time | null
const uid = vevent.getFirstPropertyValue('uid') as string | null
if (!uid) continue
seenUids.push(uid)
// D-13 / Pitfall #3: isDate=true → DATE column; isDate=false → TIMESTAMP column
const allDay: boolean = dtstart?.isDate ?? false
@@ -133,4 +137,19 @@ export async function syncCalendar(
},
})
}
// 5. Prune deletes: remove cached events for THIS calendar whose uid is no
// longer present on the server. Without this, a deleted event (local delete
// via the outbox, or an external delete in another client) lingers in
// calendar_events forever — GET /api/events keeps returning it and the UI
// shows a ghost event that "won't delete". Scoped to cal.id so it never
// touches another calendar or the other household member's rows (BUG B).
// When the server returns zero events, prune the whole calendar's cache.
if (seenUids.length > 0) {
await db
.delete(calendarEvents)
.where(and(eq(calendarEvents.calendarId, cal.id), notInArray(calendarEvents.uid, seenUids)))
} else {
await db.delete(calendarEvents).where(eq(calendarEvents.calendarId, cal.id))
}
}