import Foundation import HAKit import PromiseKit import RealmSwift public final class ModelManager { private var notificationTokens = [NotificationToken]() private var hakitTokens = [HACancellable]() deinit { hakitTokens.forEach { $0.cancel() } notificationTokens.forEach { $0.invalidate() } } public func observe( for collection: AnyRealmCollection, handler: @escaping (AnyRealmCollection) -> Promise ) { notificationTokens.append(collection.observe { change in switch change { case .initial: break case .update(let collection, deletions: _, insertions: _, modifications: _): handler(collection).cauterize() case let .error(error): Current.Log.error("failed to watch \(collection): \(error)") } }) } public struct CleanupDefinition { public var model: Object.Type public var createdKey: String public var duration: Measurement public init( model: Object.Type, createdKey: String, duration: Measurement = .init(value: 256, unit: .hours) ) { self.model = model self.createdKey = createdKey self.duration = duration } public static var defaults: [Self] = [ CleanupDefinition( model: LocationHistoryEntry.self, createdKey: #keyPath(LocationHistoryEntry.CreatedAt) ), CleanupDefinition( model: LocationError.self, createdKey: #keyPath(LocationError.CreatedAt) ), CleanupDefinition( model: ClientEvent.self, createdKey: #keyPath(ClientEvent.date) ), ] } public func cleanup( definitions: [CleanupDefinition] = CleanupDefinition.defaults, on queue: DispatchQueue = .global(qos: .utility) ) -> Promise { let (promise, seal) = Promise.pending() queue.async { let realm = Current.realm() let writes = definitions.map { definition in realm.reentrantWrite { self.cleanup(using: definition, realm: realm) } } when(fulfilled: writes).pipe(to: seal.resolve) } return promise } private func cleanup( using definition: CleanupDefinition, realm: Realm ) { let duration = definition.duration.converted(to: .seconds).value let date = Current.date().addingTimeInterval(-duration) let objects = realm .objects(definition.model) .filter("%K < %@", definition.createdKey, date) if objects.isEmpty == false { Current.Log.info("\(definition.model): \(objects.count)") realm.delete(objects) } } public struct SubscribeDefinition { public var subscribe: ( _ connection: HAConnection, _ queue: DispatchQueue, _ modelManager: ModelManager ) -> [HACancellable] static func states< UM: Object & UpdatableModel >( domain: String, type: UM.Type ) -> Self where UM.Source == HAEntity { .init(subscribe: { connection, queue, manager in // working around a swift compiler crash, xcode 12.4 let someManager = manager var lastEntities = Set() return [ connection.caches.states.subscribe { [weak someManager] token, value in queue.async { guard let manager = someManager else { token.cancel() return } let entities = value.all.filter { $0.domain == domain } if entities != lastEntities { manager.store(type: type, sourceModels: entities).cauterize() lastEntities = entities } } }, ] }) } public static var defaults: [Self] = [ .states(domain: "zone", type: RLMZone.self), .states(domain: "scene", type: RLMScene.self), ] } public func subscribe( definitions: [SubscribeDefinition] = SubscribeDefinition.defaults, on queue: DispatchQueue = .global(qos: .utility) ) { hakitTokens.forEach { $0.cancel() } hakitTokens = definitions.flatMap { $0.subscribe(Current.apiConnection, queue, self) } } public struct FetchDefinition { public var update: ( _ api: HomeAssistantAPI, _ connection: HAConnection, _ queue: DispatchQueue, _ modelManager: ModelManager ) -> Promise public static var defaults: [Self] = [ FetchDefinition(update: { api, _, queue, manager in api.GetMobileAppConfig().then(on: queue) { when(fulfilled: [ manager.store(type: NotificationCategory.self, sourceModels: $0.push.categories), manager.store(type: Action.self, sourceModels: $0.actions), ]) } }), ] } public func fetch( definitions: [FetchDefinition] = FetchDefinition.defaults, on queue: DispatchQueue = .global(qos: .utility) ) -> Promise { Current.api.then(on: nil) { api in when(fulfilled: definitions.map { $0.update(api, Current.apiConnection, queue, self) }) } } internal enum StoreError: Error { case missingPrimaryKey } internal func store( type realmObjectType: UM.Type, sourceModels: C ) -> Promise where C.Element == UM.Source { let realm = Current.realm() return realm.reentrantWrite { guard let realmPrimaryKey = realmObjectType.primaryKey() else { Current.Log.error("invalid realm object type: \(realmObjectType)") throw StoreError.missingPrimaryKey } let existingIDs = Set(realm.objects(UM.self).compactMap { $0[realmPrimaryKey] as? String }) let incomingIDs = Set(sourceModels.map(\.primaryKey)) let deletedIDs = existingIDs.subtracting(incomingIDs) let newIDs = incomingIDs.subtracting(existingIDs) let deleteObjects = realm.objects(UM.self) .filter(UM.updateEligiblePredicate) .filter("%K in %@", realmPrimaryKey, deletedIDs) Current.Log.verbose( [ "updating \(UM.self)", "from(\(existingIDs.count))", "eligible(\(incomingIDs.count))", "deleted(\(deleteObjects.count))", "ignored(\(deletedIDs.count - deleteObjects.count))", "new(\(newIDs.count))", ].joined(separator: " ") ) let updatedModels: [UM] = sourceModels.compactMap { model in let updating: UM if let existing = realm.object(ofType: UM.self, forPrimaryKey: model.primaryKey) { updating = existing } else { Current.Log.verbose("creating \(model.primaryKey)") updating = UM() } if updating.update(with: model, using: realm) { return updating } else { return nil } } realm.add(updatedModels, update: .all) UM.didUpdate(objects: updatedModels, realm: realm) UM.willDelete(objects: Array(deleteObjects), realm: realm) realm.delete(deleteObjects) } } }