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 {