fix(cache): preserve whole source on account-level sync errors (HA/Google tokens)

The per-calendar preservation added in the previous fix only kicks in when a
sync error carries a calendar_id. But account-level failures — most commonly
a Home Assistant or Google token-refresh failure — abort the entire account
fetch and arrive WITHOUT a calendar_id. Those events were still being evicted,
so HA/Google calendars kept vanishing on a transient token hiccup.

failedCalendarKeys() now returns both per-calendar keys and whole sources:
an error without a calendar_id protects every cached calendar of that source.
mergeIntoCache() keeps events whose source is in keepSourcesInRange, and the
cached range is only extended when neither set is populated (auto-retry).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-03 21:31:05 +02:00
parent fb22aeb090
commit 61237134f0

View File

@@ -349,8 +349,9 @@ class CalendarStore {
do { do {
let (fetched, errors) = try await fetchForMode(api: api, start: start, end: end) let (fetched, errors) = try await fetchForMode(api: api, start: start, end: end)
syncErrors = errors syncErrors = errors
let failed = failedCalendarKeys(from: errors)
mergeIntoCache(fetched, rangeStart: start, rangeEnd: end, mergeIntoCache(fetched, rangeStart: start, rangeEnd: end,
keepKeysInRange: failedCalendarKeys(from: errors)) keepKeysInRange: failed.keys, keepSourcesInRange: failed.sources)
refreshFromCache(start: start, end: end) refreshFromCache(start: start, end: end)
} catch { } catch {
// Hard failure leave `syncErrors` as-is; it reflects the last // Hard failure leave `syncErrors` as-is; it reflects the last
@@ -410,8 +411,9 @@ class CalendarStore {
do { do {
let (fetched, errors) = try await fetchForMode(api: api, start: start, end: end) let (fetched, errors) = try await fetchForMode(api: api, start: start, end: end)
syncErrors = errors syncErrors = errors
let failed = failedCalendarKeys(from: errors)
mergeIntoCache(fetched, rangeStart: start, rangeEnd: end, mergeIntoCache(fetched, rangeStart: start, rangeEnd: end,
keepKeysInRange: failedCalendarKeys(from: errors)) keepKeysInRange: failed.keys, keepSourcesInRange: failed.sources)
// Refresh visible range from newly expanded cache // Refresh visible range from newly expanded cache
let (vs, ve) = rangeForCurrentView() let (vs, ve) = rangeForCurrentView()
refreshFromCache(start: vs, end: ve) refreshFromCache(start: vs, end: ve)
@@ -430,26 +432,41 @@ class CalendarStore {
allCachedEvents = [] allCachedEvents = []
} }
/// Calendar keys (source:id) for calendars that reported a per-source sync /// Calendars / sources that reported a sync error on the last fetch. Events
/// error. Events from these calendars must NOT be evicted from the cache on /// matching either must NOT be evicted from the cache on a partial sync
/// a partial sync stale data is better than a completely empty calendar. /// stale data beats a completely empty calendar.
private func failedCalendarKeys(from errors: [SyncError]) -> Set<String> { ///
/// - `keys`: per-calendar errors carrying a `calendar_id` (e.g. one CalDAV
/// calendar with bad credentials) protect just that calendar.
/// - `sources`: account-level errors the server couldn't pin to a single
/// calendar protect every cached calendar of that source. The
/// key example is a Home Assistant / Google token-refresh
/// failure: it aborts the whole account fetch, so the error
/// arrives with no `calendar_id` and every HA calendar would
/// otherwise be wiped.
private func failedCalendarKeys(from errors: [SyncError]) -> (keys: Set<String>, sources: Set<String>) {
var keys = Set<String>() var keys = Set<String>()
var sources = Set<String>()
for err in errors { for err in errors {
guard let cid = err.calendarId else { continue } if let cid = err.calendarId {
keys.insert(Self.calendarKey(source: err.source, calendarId: cid)) keys.insert(Self.calendarKey(source: err.source, calendarId: cid))
} else {
sources.insert(err.source)
}
} }
return keys return (keys, sources)
} }
private func mergeIntoCache(_ newEvents: [CalEvent], rangeStart: Date, rangeEnd: Date, private func mergeIntoCache(_ newEvents: [CalEvent], rangeStart: Date, rangeEnd: Date,
keepKeysInRange: Set<String> = []) { keepKeysInRange: Set<String> = [],
keepSourcesInRange: Set<String> = []) {
// Remove old events in the fetched range to avoid duplicates but // Remove old events in the fetched range to avoid duplicates but
// PRESERVE events from calendars that had sync errors so that a // PRESERVE events from calendars / sources that had sync errors so that a
// transient CalDAV / Google failure doesn't wipe the visible calendar. // transient CalDAV / Google / HA failure doesn't wipe the visible calendar.
let retained = allCachedEvents.filter { ev in let retained = allCachedEvents.filter { ev in
let outsideRange = ev.startDate >= rangeEnd || ev.endDate <= rangeStart let outsideRange = ev.startDate >= rangeEnd || ev.endDate <= rangeStart
if outsideRange { return true } if outsideRange { return true }
if keepSourcesInRange.contains(ev.source) { return true }
guard !keepKeysInRange.isEmpty else { return false } guard !keepKeysInRange.isEmpty else { return false }
let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId) let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId)
return keepKeysInRange.contains(key) return keepKeysInRange.contains(key)
@@ -457,12 +474,12 @@ class CalendarStore {
allCachedEvents = retained + newEvents allCachedEvents = retained + newEvents
// Only extend the cached range when the fetch was completely clean. // Only extend the cached range when the fetch was completely clean.
// When some calendars had sync errors (keepKeysInRange non-empty), leave // When some calendars/sources had sync errors, leave cachedStart/End
// cachedStart/End unchanged: this keeps isCached() returning false for // unchanged: this keeps isCached() returning false for the next
// the next loadEvents call so the failed calendars are retried // loadEvents call so the failed calendars are retried automatically
// automatically rather than being silently treated as "done" with // rather than being silently treated as "done" with empty data
// empty data (especially important on first launch or after forceReload). // (especially important on first launch or after forceReload).
guard keepKeysInRange.isEmpty else { return } guard keepKeysInRange.isEmpty, keepSourcesInRange.isEmpty else { return }
if let cs = cachedStart, let ce = cachedEnd { if let cs = cachedStart, let ce = cachedEnd {
cachedStart = min(cs, rangeStart) cachedStart = min(cs, rangeStart)
cachedEnd = max(ce, rangeEnd) cachedEnd = max(ce, rangeEnd)