You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
543 lines
18 KiB
543 lines
18 KiB
//
|
|
// 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<T : SyncedStorable>: SomeSyncedCollection, CollectionDelegate {
|
|
|
|
public typealias Item = T
|
|
|
|
let store: Store
|
|
let collection: StoredCollection<T>
|
|
|
|
init(store: Store, indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil, synchronousLoading: Bool = false, noLoad: Bool = false) {
|
|
|
|
self.store = store
|
|
self.collection = StoredCollection<T>(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<T> {
|
|
return SyncedCollection<T>(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<T> {
|
|
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<T>) -> OperationBatch<T> {
|
|
|
|
let date = Date()
|
|
let batch = OperationBatch<T>()
|
|
|
|
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<T>) {
|
|
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<T>) {
|
|
self.delete(contentOfs: sequence, actionOption: .syncedCascade)
|
|
}
|
|
|
|
func delete(contentOfs sequence: any RandomAccessCollection<T>, 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<T>, actionOption: ActionOption) -> OperationBatch<T> {
|
|
|
|
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<T>()
|
|
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<T>, 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<T>) {
|
|
//
|
|
// self.collection.deleteDependencies(items)
|
|
//
|
|
//// super.deleteDependencies(items)
|
|
//
|
|
// let batch = OperationBatch<T>()
|
|
// batch.deletes = Array(items)
|
|
// Task { await self._sendOperationBatch(batch) }
|
|
// }
|
|
|
|
// public func deleteDependenciesAsync(_ items: any Sequence<T>) async {
|
|
// super.deleteDependencies(items)
|
|
//
|
|
// let batch = OperationBatch<T>()
|
|
// 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<S: SyncedStorable>(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<S> = 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<T>) 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<T>) 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<T>) {
|
|
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<T>) {
|
|
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<T>) {
|
|
|
|
let batch = OperationBatch<T>()
|
|
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<T>) async {
|
|
do {
|
|
try await self.storeCenter.sendOperationBatch(batch)
|
|
} catch {
|
|
Logger.error(error)
|
|
}
|
|
}
|
|
|
|
fileprivate func _executeBatchOnce(_ batch: OperationBatch<T>) 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<S>(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<T> {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|