|
|
|
|
@ -7,9 +7,14 @@ |
|
|
|
|
|
|
|
|
|
import Foundation |
|
|
|
|
|
|
|
|
|
enum StoredCollectionError : Error { |
|
|
|
|
case unmanagedHTTPMethod(method: String) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
protocol SomeCollection : Identifiable { |
|
|
|
|
func allItems() -> [any Storable] |
|
|
|
|
func deleteById(_ id: String) throws |
|
|
|
|
func deleteApiCallById(_ id: String) throws |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
extension Notification.Name { |
|
|
|
|
@ -34,6 +39,8 @@ public class StoredCollection<T : Storable> : RandomAccessCollection, SomeCollec |
|
|
|
|
/// Provides fast access for instances if the collection has been instanced with [indexed] = true |
|
|
|
|
fileprivate var _index: [String : T]? = nil |
|
|
|
|
|
|
|
|
|
fileprivate var apiCallsCollection: StoredCollection<ApiCall<T>>? = nil |
|
|
|
|
|
|
|
|
|
/// Indicates whether the collection has changed, thus requiring a write operation |
|
|
|
|
fileprivate var _hasChanged: Bool = false { |
|
|
|
|
didSet { |
|
|
|
|
@ -58,9 +65,16 @@ public class StoredCollection<T : Storable> : RandomAccessCollection, SomeCollec |
|
|
|
|
} |
|
|
|
|
self._store = store |
|
|
|
|
self.loadCompletion = loadCompletion |
|
|
|
|
|
|
|
|
|
if synchronized { |
|
|
|
|
self.apiCallsCollection = StoredCollection<ApiCall<T>>(synchronized: false, store: store, loadCompletion: { apiCallCollection in |
|
|
|
|
self._rescheduleApiCalls() |
|
|
|
|
}) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
self._load() |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Loading |
|
|
|
|
|
|
|
|
|
/// Migrates if necessary and asynchronously decodes the json file |
|
|
|
|
@ -77,17 +91,17 @@ public class StoredCollection<T : Storable> : RandomAccessCollection, SomeCollec |
|
|
|
|
} else { |
|
|
|
|
try self._decodeJSONFile() |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
} |
|
|
|
|
// else { |
|
|
|
|
// try? self.loadDataFromServer() |
|
|
|
|
// } |
|
|
|
|
// else { |
|
|
|
|
// try? self.loadDataFromServer() |
|
|
|
|
// } |
|
|
|
|
} catch { |
|
|
|
|
Logger.log(error) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Decodes the json file into the items array |
|
|
|
|
fileprivate func _decodeJSONFile() throws { |
|
|
|
|
let jsonString = try FileUtils.readDocumentFile(fileName: T.fileName()) |
|
|
|
|
@ -131,36 +145,33 @@ public class StoredCollection<T : Storable> : RandomAccessCollection, SomeCollec |
|
|
|
|
/// Adds it if its id is not found, and otherwise updates it |
|
|
|
|
public func addOrUpdate(instance: T) { |
|
|
|
|
|
|
|
|
|
// DispatchQueue(label: "lestorage.queue.items").sync { |
|
|
|
|
defer { |
|
|
|
|
self._hasChanged = true |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
defer { |
|
|
|
|
self._hasChanged = true |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// update |
|
|
|
|
if let index = self.items.firstIndex(where: { $0.id == instance.id }) { |
|
|
|
|
self.items[index] = instance |
|
|
|
|
self._sendUpdateIfNecessary(instance) |
|
|
|
|
} else { // insert |
|
|
|
|
self.items.append(instance) |
|
|
|
|
self._index?[instance.stringId] = instance |
|
|
|
|
self._sendInsertionIfNecessary(instance) |
|
|
|
|
} |
|
|
|
|
// } |
|
|
|
|
// update |
|
|
|
|
if let index = self.items.firstIndex(where: { $0.id == instance.id }) { |
|
|
|
|
self.items[index] = instance |
|
|
|
|
self._sendUpdateIfNecessary(instance) |
|
|
|
|
} else { // insert |
|
|
|
|
self.items.append(instance) |
|
|
|
|
self._index?[instance.stringId] = instance |
|
|
|
|
self._sendInsertionIfNecessary(instance) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/// Deletes the instance in the collection by id |
|
|
|
|
public func delete(instance: T) throws { |
|
|
|
|
|
|
|
|
|
defer { |
|
|
|
|
self._hasChanged = true |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
try instance.deleteDependencies() |
|
|
|
|
self.items.removeAll { $0.id == instance.id } |
|
|
|
|
self._index?.removeValue(forKey: instance.stringId) |
|
|
|
|
self._sendDeletionIfNecessary(instance) |
|
|
|
|
|
|
|
|
|
defer { |
|
|
|
|
self._hasChanged = true |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
try instance.deleteDependencies() |
|
|
|
|
self.items.removeAll { $0.id == instance.id } |
|
|
|
|
self._index?.removeValue(forKey: instance.stringId) |
|
|
|
|
self._sendDeletionIfNecessary(instance) |
|
|
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
@ -232,16 +243,56 @@ public class StoredCollection<T : Storable> : RandomAccessCollection, SomeCollec |
|
|
|
|
|
|
|
|
|
// MARK: - Synchronization |
|
|
|
|
|
|
|
|
|
fileprivate func _callForInstance(_ instance: T, method: Method) throws -> ApiCall<T>? { |
|
|
|
|
guard let apiCallCollection = self.apiCallsCollection else { |
|
|
|
|
throw StoreError.apiCallCollectionNotRegistered(type: T.resourceName()) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
if let existingCall = apiCallCollection.first(where: { $0.dataId == instance.id }) { |
|
|
|
|
switch existingCall.method { |
|
|
|
|
case Method.post.rawValue, Method.put.rawValue: |
|
|
|
|
existingCall.body = try instance.jsonString() |
|
|
|
|
return existingCall |
|
|
|
|
case Method.delete.rawValue: |
|
|
|
|
try self.deleteApiCallById(existingCall.id) |
|
|
|
|
return nil |
|
|
|
|
default: |
|
|
|
|
throw StoredCollectionError.unmanagedHTTPMethod(method: existingCall.method) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
} else { |
|
|
|
|
return try self._createCall(instance, method: method) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
fileprivate func _createCall(_ instance: T, method: Method) throws -> ApiCall<T> { |
|
|
|
|
guard let baseURL = _store.service?.baseURL else { |
|
|
|
|
throw StoreError.missingService |
|
|
|
|
} |
|
|
|
|
let jsonString = try instance.jsonString() |
|
|
|
|
let url = baseURL + T.resourceName() + "/" |
|
|
|
|
return ApiCall(url: url, method: method.rawValue, dataId: String(instance.id), body: jsonString) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
fileprivate func _prepareCall(apiCall: ApiCall<T>) { |
|
|
|
|
apiCall.lastAttemptDate = Date() |
|
|
|
|
apiCall.attemptsCount += 1 |
|
|
|
|
self.apiCallsCollection?.addOrUpdate(instance: apiCall) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/// Sends an insert api call for the provided [instance] |
|
|
|
|
fileprivate func _sendInsertionIfNecessary(_ instance: T) { |
|
|
|
|
guard self.synchronized else { |
|
|
|
|
return |
|
|
|
|
} |
|
|
|
|
Logger.log("Call service...") |
|
|
|
|
Task { |
|
|
|
|
do { |
|
|
|
|
let _ = try await self._store.service?.insert(instance) |
|
|
|
|
if let apiCall = try self._callForInstance(instance, method: Method.post) { |
|
|
|
|
self._prepareCall(apiCall: apiCall) |
|
|
|
|
_ = try await self._store.execute(apiCall: apiCall) |
|
|
|
|
} |
|
|
|
|
} catch { |
|
|
|
|
self.rescheduleApiCallsIfNecessary() |
|
|
|
|
Logger.error(error) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
@ -256,9 +307,15 @@ public class StoredCollection<T : Storable> : RandomAccessCollection, SomeCollec |
|
|
|
|
|
|
|
|
|
Task { |
|
|
|
|
do { |
|
|
|
|
let _ = try await self._store.service?.update(instance) |
|
|
|
|
if let apiCall = try self._callForInstance(instance, method: Method.put) { |
|
|
|
|
self._prepareCall(apiCall: apiCall) |
|
|
|
|
_ = try await self._store.execute(apiCall: apiCall) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// let _ = try await self._store.service?.update(instance) |
|
|
|
|
} catch { |
|
|
|
|
Logger.error(error) |
|
|
|
|
self.rescheduleApiCallsIfNecessary() |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
@ -272,14 +329,79 @@ public class StoredCollection<T : Storable> : RandomAccessCollection, SomeCollec |
|
|
|
|
|
|
|
|
|
Task { |
|
|
|
|
do { |
|
|
|
|
let _ = try await self._store.service?.delete(instance) |
|
|
|
|
|
|
|
|
|
if let apiCall = try self._callForInstance(instance, method: Method.delete) { |
|
|
|
|
self._prepareCall(apiCall: apiCall) |
|
|
|
|
_ = try await self._store.execute(apiCall: apiCall) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// let _ = try await self._store.service?.delete(instance) |
|
|
|
|
} catch { |
|
|
|
|
Logger.error(error) |
|
|
|
|
self.rescheduleApiCallsIfNecessary() |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// MARK: - Reschedule calls |
|
|
|
|
|
|
|
|
|
/// number of time an execution loop has been called |
|
|
|
|
fileprivate var _attemptLoops: Int = 0 |
|
|
|
|
|
|
|
|
|
/// Indicates if the collection is currently retrying ApiCalls |
|
|
|
|
fileprivate var _isRetryingCalls: Bool = false |
|
|
|
|
|
|
|
|
|
func rescheduleApiCallsIfNecessary() { |
|
|
|
|
if !self._isRetryingCalls { |
|
|
|
|
self._rescheduleApiCalls() |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
fileprivate func _rescheduleApiCalls() { |
|
|
|
|
|
|
|
|
|
guard let apiCallsCollection, apiCallsCollection.isNotEmpty else { |
|
|
|
|
return |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
self._isRetryingCalls = true |
|
|
|
|
self._attemptLoops += 1 |
|
|
|
|
|
|
|
|
|
Task { |
|
|
|
|
|
|
|
|
|
let delay = pow(2, self._attemptLoops) |
|
|
|
|
let seconds = NSDecimalNumber(decimal: delay).intValue |
|
|
|
|
Logger.log("wait for \(seconds) sec") |
|
|
|
|
try await Task.sleep(until: .now + .seconds(seconds)) |
|
|
|
|
|
|
|
|
|
let apiCallsCopy = apiCallsCollection.items |
|
|
|
|
for apiCall in apiCallsCopy { |
|
|
|
|
apiCall.attemptsCount += 1 |
|
|
|
|
apiCall.lastAttemptDate = Date() |
|
|
|
|
do { |
|
|
|
|
let _ = try await Store.main.execute(apiCall: apiCall) |
|
|
|
|
} catch { |
|
|
|
|
Logger.error(error) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
if apiCallsCollection.isEmpty { |
|
|
|
|
self._isRetryingCalls = false |
|
|
|
|
} else { |
|
|
|
|
self._rescheduleApiCalls() |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
func deleteApiCallById(_ id: String) throws { |
|
|
|
|
guard let apiCallsCollection else { |
|
|
|
|
throw StoreError.apiCallCollectionNotRegistered(type: T.resourceName()) |
|
|
|
|
} |
|
|
|
|
try apiCallsCollection.deleteById(id) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// MARK: - RandomAccessCollection |
|
|
|
|
|
|
|
|
|
public var startIndex: Int { return self.items.startIndex } |
|
|
|
|
|