From b9e7d88a12c4db9bd4b366b620b69a3c0d82ac98 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Wed, 7 Oct 2026 23:59:56 +0200 Subject: [PATCH] Re-fetch everything from iCloud when the local store was recreated On tvOS SwiftData keeps the store in the app group's Library/Caches, which the system may purge. The CKSyncEngine tokens live in UserDefaults and survive, so the next launch opened an empty store and fetched only records changed since the old tokens; subscriptions and bookmarks that had not changed since never came back. DataManager now records whether the store file existed when the container opened, and the sync engine clears its persisted state once per launch when it did not, so the first sync after a loss is a full fetch. Also log the store path and a persistent-store failure that previously fell back to in-memory storage silently. --- Yattee/Core/AppEnvironment.swift | 1 + Yattee/Data/DataManager.swift | 17 ++++++++++++++- .../CloudKit/CloudKitSyncEngine.swift | 21 +++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/Yattee/Core/AppEnvironment.swift b/Yattee/Core/AppEnvironment.swift index fa764789..83c12130 100644 --- a/Yattee/Core/AppEnvironment.swift +++ b/Yattee/Core/AppEnvironment.swift @@ -196,6 +196,7 @@ final class AppEnvironment { do { dm = try DataManager(iCloudSyncEnabled: settings.iCloudSyncEnabled) } catch { + LoggingService.shared.logCloudKitError("Persistent store unavailable, falling back to in-memory storage", error: error) // Fall back to in-memory storage if persistent storage fails dm = try! DataManager(inMemory: true) } diff --git a/Yattee/Data/DataManager.swift b/Yattee/Data/DataManager.swift index 855282e7..1d09636d 100644 --- a/Yattee/Data/DataManager.swift +++ b/Yattee/Data/DataManager.swift @@ -25,6 +25,16 @@ final class DataManager { /// Whether CloudKit sync is currently enabled for this instance. private(set) var isCloudKitEnabled: Bool = false + + /// Whether the on-disk store did not exist when this container was opened. + /// + /// On tvOS SwiftData keeps the store in Library/Caches, which the system + /// purges under storage pressure. The CloudKit sync tokens live in + /// UserDefaults and survive such a purge, so a fresh store must be reported + /// to the sync engine — otherwise it only fetches changes made since the + /// old tokens and records that did not change since (subscriptions, + /// bookmarks) never come back. + private(set) var storeWasCreated: Bool = false /// Cached set of bookmarked video IDs for fast O(1) lookup. /// Updated when bookmarks are added/removed locally or via CloudKit sync. @@ -71,15 +81,20 @@ final class DataManager { ) } + let storeExisted = inMemory || FileManager.default.fileExists(atPath: configuration.url.path) self.modelContainer = try ModelContainer( for: Self.schema, configurations: [configuration] ) + self.storeWasCreated = !storeExisted self.modelContext = modelContainer.mainContext self.modelContext.autosaveEnabled = true self.isCloudKitEnabled = false - LoggingService.shared.logCloudKit("SwiftData initialized with local storage (CloudKit sync via CKSyncEngine)") + LoggingService.shared.logCloudKit( + "SwiftData initialized with local storage (CloudKit sync via CKSyncEngine)", + details: inMemory ? "in-memory" : "store: \(configuration.url.path), existed: \(storeExisted)" + ) // Initialize bookmark cache refreshBookmarkCache() diff --git a/Yattee/Services/CloudKit/CloudKitSyncEngine.swift b/Yattee/Services/CloudKit/CloudKitSyncEngine.swift index a4980f15..93a6a3c6 100644 --- a/Yattee/Services/CloudKit/CloudKitSyncEngine.swift +++ b/Yattee/Services/CloudKit/CloudKitSyncEngine.swift @@ -160,6 +160,11 @@ final class CloudKitSyncEngine: @unchecked Sendable { /// UserDefaults key for the record schema version this app last synced with. private let lastSchemaVersionKey = "cloudKitLastSchemaVersion" + + /// A recreated store is answered once per launch; later engine setups in + /// the same session (manual refresh, account change) start from the tokens + /// that full fetch produced. + private var storeLossHandled = false /// Legacy UserDefaults key for pending save record names (pre state-driven /// engine). Only read once for migration, then removed. @@ -424,6 +429,7 @@ final class CloudKitSyncEngine: @unchecked Sendable { // re-delivered on a full fetch, so a schema upgrade must invalidate the // persisted state once to pick them up. invalidateSyncStateAfterSchemaUpgradeIfNeeded() + invalidateSyncStateAfterStoreLossIfNeeded() // Resume from persisted sync state so launches fetch only changes since // the last session instead of replaying the entire zone change history. @@ -592,6 +598,21 @@ final class CloudKitSyncEngine: @unchecked Sendable { UserDefaults.standard.set(Int(currentVersion), forKey: lastSchemaVersionKey) } + /// Clears persisted sync state when the local store was recreated from + /// scratch while the tokens survived. The store lives in Library/Caches on + /// tvOS, which the system may purge; the tokens live in UserDefaults and + /// do not go with it. An incremental fetch against an empty store only + /// brings back records changed since the tokens, so the first sync after + /// a loss must be a full fetch. + private func invalidateSyncStateAfterStoreLossIfNeeded() { + guard !storeLossHandled, dataManager?.storeWasCreated == true else { return } + storeLossHandled = true + guard UserDefaults.standard.data(forKey: syncStateKey) != nil else { return } + UserDefaults.standard.removeObject(forKey: syncStateKey) + clearDeferredItems() + LoggingService.shared.logCloudKit("Local store was recreated while sync state persisted, cleared sync state for full re-fetch") + } + /// Loads persisted sync state from disk. private func loadSyncState() -> CKSyncEngine.State.Serialization? { guard let data = UserDefaults.standard.data(forKey: syncStateKey) else {