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:
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user