// // PersistentContainer.swift // Reader // // Created by Shadowfacts on 12/24/21. // import CoreData import Fervor import OSLog // todo: is this actually sendable? public class PersistentContainer: NSPersistentContainer, @unchecked Sendable { private static let managedObjectModel: NSManagedObjectModel = { let url = Bundle.module.url(forResource: "Reader", withExtension: "momd")! return NSManagedObjectModel(contentsOf: url)! }() public private(set) lazy var backgroundContext: NSManagedObjectContext = { let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) // todo: should the background context really be parented to the view context, or should they both be direct children of the PSC? context.parent = self.viewContext return context }() private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PersistentContainer") public init(account: LocalData.Account) { super.init(name: account.persistenceKey, managedObjectModel: PersistentContainer.managedObjectModel) let groupContainerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.net.shadowfacts.Reader")! let containerAppSupportURL = groupContainerURL .appendingPathComponent("Library", isDirectory: true) .appendingPathComponent("Application Support", isDirectory: true) try! FileManager.default.createDirectory(at: containerAppSupportURL, withIntermediateDirectories: true) let containerStoreURL = containerAppSupportURL .appendingPathComponent(name) .appendingPathExtension("sqlite") if let existingAppSupport = try? FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false) { let existingStore = existingAppSupport .appendingPathComponent(name) .appendingPathExtension("sqlite") .relativePath .removingPercentEncoding! if FileManager.default.fileExists(atPath: existingStore) { for ext in ["", "-shm", "-wal"] { try! FileManager.default.moveItem(atPath: existingStore + ext, toPath: containerStoreURL.relativePath + ext) } } } let desc = NSPersistentStoreDescription(url: containerStoreURL) desc.type = NSSQLiteStoreType persistentStoreDescriptions = [desc] loadPersistentStores { description, error in if let error = error { fatalError("Unable to load persistent store: \(error)") } } } @MainActor public func saveViewContext() throws { if viewContext.hasChanges { try viewContext.save() } } public func lastSyncDate() async throws -> Date? { return try await backgroundContext.perform { let state = try self.backgroundContext.fetch(SyncState.fetchRequest()).first return state?.lastSync } } public func updateLastSyncDate(_ date: Date) async throws { try await backgroundContext.perform { if let state = try self.backgroundContext.fetch(SyncState.fetchRequest()).first { state.lastSync = date } else { let state = SyncState(context: self.backgroundContext) state.lastSync = date } try self.backgroundContext.save() } try await self.saveViewContext() } public func sync(serverGroups: [Fervor.Group], serverFeeds: [Fervor.Feed]) async throws { try await backgroundContext.perform { let existingGroups = try self.backgroundContext.fetch(Group.fetchRequest()) for group in serverGroups { if let existing = existingGroups.first(where: { $0.id == group.id }) { existing.updateFromServer(group) } else { let mo = Group(context: self.backgroundContext) mo.updateFromServer(group) } } for removed in existingGroups where !serverGroups.contains(where: { $0.id == removed.id }) { self.backgroundContext.delete(removed) } let existingFeeds = try self.backgroundContext.fetch(Feed.fetchRequest()) for feed in serverFeeds { if let existing = existingFeeds.first(where: { $0.id == feed.id }) { existing.updateFromServer(feed) } else { let mo = Feed(context: self.backgroundContext) mo.updateFromServer(feed) } } for removed in existingFeeds where !serverFeeds.contains(where: { $0.id == removed.id }) { self.backgroundContext.delete(removed) } if self.backgroundContext.hasChanges { try self.backgroundContext.save() } } try await self.saveViewContext() } public func upsertItems(_ items: [Fervor.Item]) async throws { try await backgroundContext.perform { try self.doUpsertItems(items, setProgress: nil) } } private func doUpsertItems(_ items: [Fervor.Item], setProgress: ((_ current: Int, _ total: Int) -> Void)?) throws { let req = Item.fetchRequest() req.predicate = NSPredicate(format: "id in %@", items.map(\.id)) let existing = try self.backgroundContext.fetch(req) self.logger.debug("doUpsertItems: updating \(existing.count, privacy: .public) items, inserting \(items.count - existing.count, privacy: .public)") // todo: this feels like it'll be slow when there are many items for (index, item) in items.enumerated() { setProgress?(index, items.count) if let existing = existing.first(where: { $0.id == item.id }) { existing.updateFromServer(item) } else { let mo = Item(context: self.backgroundContext) mo.updateFromServer(item) } } } public func syncItems(_ syncUpdate: ItemsSyncUpdate, setProgress: @escaping (_ current: Int, _ total: Int) -> Void) async throws { try await backgroundContext.perform { self.logger.debug("syncItems: deleting \(syncUpdate.delete.count, privacy: .public) items") let deleteReq = Item.fetchRequest() deleteReq.predicate = NSPredicate(format: "id in %@", syncUpdate.delete) let delete = NSBatchDeleteRequest(fetchRequest: deleteReq as! NSFetchRequest) delete.resultType = .resultTypeObjectIDs let result = try self.backgroundContext.execute(delete) if let deleteResult = result as? NSBatchDeleteResult, let objectIDs = deleteResult.result as? [NSManagedObjectID] { NSManagedObjectContext.mergeChanges(fromRemoteContextSave: [NSDeletedObjectsKey: objectIDs], into: [self.viewContext]) // todo: does the background/view contexts need to get saved then? } try self.doUpsertItems(syncUpdate.upsert, setProgress: setProgress) if self.backgroundContext.hasChanges { try self.backgroundContext.save() } } try await self.saveViewContext() } }