// // SyncedCollection.swift // LeStorage // // Created by Laurent Morvillier on 11/10/2024. // import Foundation protocol SomeSyncedCollection: SomeCollection { func loadDataFromServerIfAllowed(clear: Bool) async throws func loadCollectionsFromServerIfNoFile() async throws } public class SyncedCollection: SomeSyncedCollection, CollectionDelegate { public typealias Item = T let store: Store let collection: StoredCollection init(store: Store, indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil, synchronousLoading: Bool = false, noLoad: Bool = false) { self.store = store self.collection = StoredCollection(store: store, indexed: indexed, inMemory: inMemory, limit: limit, synchronousLoading: synchronousLoading, noLoad: noLoad) } init(store: Store, inMemory: Bool) async { self.store = store self.collection = await StoredCollection(store: store, inMemory: inMemory) } var storeCenter: StoreCenter { return self.store.storeCenter } public var storeId: String? { return self.store.identifier } /// Returns a dummy SyncedCollection instance public static func placeholder() -> SyncedCollection { return SyncedCollection(store: Store(storeCenter: StoreCenter.main)) } /// Migrates if necessary and asynchronously decodes the json file // override func load() async { // do { // if self.inMemory { // try await self.loadDataFromServerIfAllowed() // } else { // await self.loadFromFile() // } // } catch { // Logger.error(error) // } // } /// Loads the collection using the server data only if the collection file doesn't exists func loadCollectionsFromServerIfNoFile() async throws { let fileURL: URL = try self.store.fileURL(type: T.self) if !FileManager.default.fileExists(atPath: fileURL.path()) { try await self.loadDataFromServerIfAllowed() } } /// Retrieves the data from the server and loads it into the items array public func loadDataFromServerIfAllowed(clear: Bool = false) async throws { do { try await self.storeCenter.sendGetRequest(T.self, storeId: self.storeId, clear: clear) } catch { Logger.error(error) } } func loadOnceAsync() async throws { let items: [T] = try await self.storeCenter.service().get() await self.loadItems(items, clear: true) } /// Updates a local item from a server instance. This method is typically used when the server makes update /// to an object when it's inserted. The SyncedCollection possibly needs to update its own copy with new values. /// - serverInstance: the instance of the object on the server func updateFromServerInstance(_ serverInstance: T) { guard T.copyServerResponse else { return } Task { await self.collection.updateLocalInstance(serverInstance) } } @MainActor func loadItems(_ items: [T], clear: Bool = false) { self.collection.loadAndWrite(items, clear: clear) } // MARK: - Basic operations with sync /// Adds or update an instance synchronously, dispatching network operations to background tasks public func addOrUpdate(instance: T) { let result = _addOrUpdateCore(instance: instance) if result.method == .insert { Task { await self._sendInsertion(instance) } } else { Task { await self._sendUpdate(instance) } } } /// Private helper function that contains the shared logic private func _addOrUpdateCore(instance: T) -> ActionResult { instance.lastUpdate = Date() let result = self.collection.addOrUpdate(instance: instance) if result.method == .update { if instance.sharing != nil { self._cleanUpSharedDependencies() } } return result } fileprivate func _addOrUpdateCore(contentOfs sequence: any Sequence) -> OperationBatch { let date = Date() let batch = OperationBatch() for instance in sequence { instance.lastUpdate = date let result = self.collection.addOrUpdate(instance: instance) if result.method == .insert { batch.addInsert(instance) } else { batch.addUpdate(instance) } } self._cleanUpSharedDependencies() return batch } /// Adds or update a sequence and writes public func addOrUpdate(contentOfs sequence: any Sequence) { let batch = self._addOrUpdateCore(contentOfs: sequence) Task { await self._sendOperationBatch(batch) } } /// Deletes an instance and writes public func delete(instance: T) { self.collection.delete(instance: instance, actionOption: .syncedCascade) self.storeCenter.createDeleteLog(instance) Task { await self._sendDeletion(instance) } } /// Deletes all items of the sequence by id and sets the collection as changed to trigger a write public func delete(contentOfs sequence: any RandomAccessCollection) { self.delete(contentOfs: sequence, actionOption: .syncedCascade) } func delete(contentOfs sequence: any RandomAccessCollection, actionOption: ActionOption) { guard sequence.isNotEmpty else { return } let batch = self._deleteCore(contentOfs: sequence, actionOption: actionOption) if actionOption.synchronize { Task { await self._sendOperationBatch(batch) } } } /// Deletes all items of the sequence by id and sets the collection as changed to trigger a write fileprivate func _deleteCore(contentOfs sequence: any RandomAccessCollection, actionOption: ActionOption) -> OperationBatch { var deleted: [T] = [] self.collection.delete(contentOfs: sequence, actionOption: actionOption) { result in self.storeCenter.createDeleteLog(result.instance) if !result.pending { deleted.append(result.instance) } } let batch = OperationBatch() batch.deletes = deleted return batch } /// Deletes an instance without writing, logs the operation and sends an API call // fileprivate func _deleteNoWrite(instance: T) { // self.deleteItem(instance, shouldBeSynchronized: true) // self.storeCenter.createDeleteLog(instance) //// await self._sendDeletion(instance) // } // public func deleteDependencies(_ items: any RandomAccessCollection, actionOption: ActionOption) { // guard items.isNotEmpty else { return } // if actionOption.synchronize { // self.delete(contentOfs: items) // } else { // self.deleteNoSync(contentOfs: items) // } // } // public func deleteDependencies(_ items: any Sequence) { // // self.collection.deleteDependencies(items) // //// super.deleteDependencies(items) // // let batch = OperationBatch() // batch.deletes = Array(items) // Task { await self._sendOperationBatch(batch) } // } // public func deleteDependenciesAsync(_ items: any Sequence) async { // super.deleteDependencies(items) // // let batch = OperationBatch() // batch.deletes = Array(items) // await self._sendOperationBatch(batch) // } fileprivate func _cleanUpSharedDependencies() { for relationship in T.relationships() { if let syncedType = relationship.type as? (any SyncedStorable.Type) { do { try self._deleteUnusedSharedInstances(relationship: relationship, type: syncedType, originStoreId: self.storeId) } catch { Logger.error(error) } } } } fileprivate func _deleteUnusedSharedInstances(relationship: Relationship, type: S.Type, originStoreId: String?) throws { let store: Store switch relationship.storeLookup { case .main: store = self.store.storeCenter.mainStore case .same: store = self.store case .child: throw StoreError.invalidStoreLookup(from: type, to: relationship.type) } // if relationship.storeLookup { // store = self.store.storeCenter.mainStore // } else { // store = self.store // } let collection: SyncedCollection = try store.syncedCollection() collection._deleteUnusedGrantedInstances(originStoreId: originStoreId) } fileprivate func _deleteUnusedGrantedInstances(originStoreId: String?) { let sharedItems = self.collection.items.filter { $0.sharing == .granted } for sharedItem in sharedItems { self.store.deleteUnusedGrantedIfNecessary(sharedItem, originStoreId: originStoreId ) } } public func deleteAllItemsAndDependencies(actionOption: ActionOption) { if actionOption.synchronize { self.delete(contentOfs: self.items, actionOption: actionOption) } else { self.collection.deleteAllItemsAndDependencies(actionOption: actionOption) } } public func deleteDependencies(actionOption: ActionOption, _ isIncluded: (T) -> Bool) { let items = self.items.filter(isIncluded) if actionOption.synchronize { self.delete(contentOfs: items, actionOption: actionOption) } else { self.collection.delete(contentOfs: items) } } // MARK: - Asynchronous operations /// Adds or update an instance asynchronously and waits for network operations public func addOrUpdateAsync(instance: T) async throws { let result = _addOrUpdateCore(instance: instance) if result.method == .insert { try await self._executeBatchOnce(OperationBatch(insert: instance)) } else { try await self._executeBatchOnce(OperationBatch(update: instance)) } } public func addOrUpdateAsync(contentOfs sequence: any Sequence) async throws { let batch = self._addOrUpdateCore(contentOfs: sequence) try await self._executeBatchOnce(batch) } /// Deletes all items of the sequence by id and sets the collection as changed to trigger a write public func deleteAsync(contentOfs sequence: any RandomAccessCollection) async throws { guard sequence.isNotEmpty else { return } let batch = self._deleteCore(contentOfs: sequence, actionOption: .syncedCascade) try await self._executeBatchOnce(batch) } /// Deletes an instance and writes public func deleteAsync(instance: T) async throws { self.collection.delete(instance: instance, actionOption: .syncedCascade) self.storeCenter.createDeleteLog(instance) try await self._executeBatchOnce(OperationBatch(delete: instance)) } // MARK: - Basic operations without sync /// Adds or update an instance without synchronizing it func addOrUpdateNoSync(_ instance: T) { self.collection.addOrUpdate(instance: instance) // self.addOrUpdateItem(instance: instance) } /// Adds or update a sequence of elements without synchronizing it func addOrUpdateNoSync(contentOfs sequence: any Sequence) { self.collection.addOrUpdate(contentOfs: sequence) } /// Deletes the instance in the collection without synchronization // func deleteNoSync(instance: T) { // self.collection.delete(instance: instance) // } public func deleteNoSync(contentOfs sequence: any RandomAccessCollection) { self.collection.delete(contentOfs: sequence) } /// Deletes the instance in the collection without synchronization public func deleteNoSync(instance: T, cascading: Bool = false) { self.collection.delete(instance: instance, actionOption: .cascade) } func deleteUnusedGranted(instance: T) { guard instance.sharing != nil else { return } self.deleteByStringId(instance.stringId) instance.deleteUnusedSharedDependencies(store: self.store) } /// Deletes the instance in the collection without synchronization // func deleteNoSyncNoCascade(id: String) { // self.collection.deleteByStringId(id, actionOption: .standard) // } // // /// Deletes the instance in the collection without synchronization // func deleteNoSyncNoCascadeNoWrite(id: String) { // self.collection.deleteByStringId(id, actionOption: .noCascadeNoWrite) // } func deleteByStringId(_ id: String, actionOption: ActionOption = .standard) { self.collection.deleteByStringId(id, actionOption: actionOption) } // MARK: - Collection Delegate func loadingForMemoryCollection() async { do { try await self.loadDataFromServerIfAllowed() } catch { Logger.error(error) } } func itemMerged(_ pendingOperation: PendingOperation) { let batch = OperationBatch() switch pendingOperation.method { case .add: batch.inserts.append(pendingOperation.data) case .update: batch.updates.append(pendingOperation.data) case .delete: batch.deletes.append(pendingOperation.data) case .deleteUnusedShared: break } Task { await self._sendOperationBatch(batch) } } // MARK: - Send requests fileprivate func _sendInsertion(_ instance: T) async { await self._sendOperationBatch(OperationBatch(insert: instance)) } fileprivate func _sendUpdate(_ instance: T) async { await self._sendOperationBatch(OperationBatch(update: instance)) } fileprivate func _sendDeletion(_ instance: T) async { await self._sendOperationBatch(OperationBatch(delete: instance)) } fileprivate func _sendOperationBatch(_ batch: OperationBatch) async { do { try await self.storeCenter.sendOperationBatch(batch) } catch { Logger.error(error) } } fileprivate func _executeBatchOnce(_ batch: OperationBatch) async throws { try await self.storeCenter.singleBatchExecution(batch) } // MARK: Single calls public func addsIfPostSucceeds(_ instance: T) async throws { if let result = try await self.storeCenter.service().post(instance) { self.addOrUpdateNoSync(result) } } public func updateIfPutSucceeds(_ instance: T) async throws { if let result = try await self.storeCenter.service().put(instance) { self.addOrUpdateNoSync(result) } } // MARK: - Synchronization /// Adds or update an instance if it is newer than the local instance func addOrUpdateIfNewer(_ instance: T, shared: SharingStatus?) { // defer { // self.triggerWrite() // } if let index = self.collection.items.firstIndex(where: { $0.id == instance.id }) { let localInstance = self.collection.items[index] if instance.lastUpdate > localInstance.lastUpdate { self.collection.update(instance, index: index, actionOption: .standard) } else { // print("do not update \(T.resourceName()): \(instance.lastUpdate.timeIntervalSince1970) / local: \(localInstance.lastUpdate.timeIntervalSince1970)") } } else { // insert instance.sharing = shared self.collection.add(instance: instance, actionOption: .standard) } } // MARK: - Others /// Sends a POST request for the instance, and changes the collection to perform a write public func writeChangeAndInsertOnServer(instance: T) { self.collection.addOrUpdate(instance: instance) Task { await self._sendInsertion(instance) } } // MARK: - SomeCollection public var hasLoaded: Bool { return self.collection.hasLoaded} public var inMemory: Bool { return self.collection.inMemory } public var type: any Storable.Type { return T.self } public func hasParentReferences(type: S.Type, id: String) -> Bool where S : Storable { return self.collection.hasParentReferences(type: type, id: id) } public func reset() { self.collection.reset() } public func findById(_ id: T.ID) -> T? { return self.collection.findById(id) } public var items: [T] { return self.collection.items } public func requestWrite() { self.collection.requestWrite() } } class OperationBatch { var inserts: [T] = [] var updates: [T] = [] var deletes: [T] = [] init() { } init(insert: T) { self.inserts = [insert] } init(update: T) { self.updates = [update] } init(delete: T) { self.deletes = [delete] } func addInsert(_ instance: T) { self.inserts.append(instance) } func addUpdate(_ instance: T) { self.updates.append(instance) } func addDelete(_ instance: T) { self.deletes.append(instance) } } extension SyncedCollection: RandomAccessCollection { public var startIndex: Int { return self.collection.items.startIndex } public var endIndex: Int { return self.collection.items.endIndex } public func index(after i: Int) -> Int { return self.collection.items.index(after: i) } public subscript(index: Int) -> T { get { return self.collection.items[index] } set(newValue) { self.collection.update(newValue, index: index, actionOption: .standard) // self.collection.items[index] = newValue // self._triggerWrite = true } } }