fix issue where updated items needs to copy properties instead of the instance being replaced

sync2
Laurent 1 year ago
parent 56a2f6e618
commit f926a1fcbe
  1. 8
      LeStorage.xcodeproj/project.pbxproj
  2. 8
      LeStorage/ApiCallCollection.swift
  3. 12
      LeStorage/Codables/ApiCall.swift
  4. 4
      LeStorage/Codables/DataLog.swift
  5. 12
      LeStorage/Codables/FailedAPICall.swift
  6. 25
      LeStorage/Codables/GetSyncData.swift
  7. 7
      LeStorage/Codables/Log.swift
  8. 126
      LeStorage/Codables/SyncResponse.swift
  9. 88
      LeStorage/Services.swift
  10. 7
      LeStorage/Storable.swift
  11. 6
      LeStorage/Store.swift
  12. 275
      LeStorage/StoreCenter.swift
  13. 202
      LeStorage/StoredCollection.swift
  14. 2
      LeStorage/Utils/Errors.swift

@ -7,6 +7,8 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
C400D7232CC2AF560092237C /* GetSyncData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C400D7222CC2AF560092237C /* GetSyncData.swift */; };
C400D7252CC2B5CF0092237C /* SyncResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = C400D7242CC2B5CF0092237C /* SyncResponse.swift */; };
C425D4392B6D24E1002A7B48 /* LeStorage.docc in Sources */ = {isa = PBXBuildFile; fileRef = C425D4382B6D24E1002A7B48 /* LeStorage.docc */; }; C425D4392B6D24E1002A7B48 /* LeStorage.docc in Sources */ = {isa = PBXBuildFile; fileRef = C425D4382B6D24E1002A7B48 /* LeStorage.docc */; };
C425D4452B6D24E1002A7B48 /* LeStorage.h in Headers */ = {isa = PBXBuildFile; fileRef = C425D4372B6D24E1002A7B48 /* LeStorage.h */; settings = {ATTRIBUTES = (Public, ); }; }; C425D4452B6D24E1002A7B48 /* LeStorage.h in Headers */ = {isa = PBXBuildFile; fileRef = C425D4372B6D24E1002A7B48 /* LeStorage.h */; settings = {ATTRIBUTES = (Public, ); }; };
C425D4582B6D2519002A7B48 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = C425D4572B6D2519002A7B48 /* Store.swift */; }; C425D4582B6D2519002A7B48 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = C425D4572B6D2519002A7B48 /* Store.swift */; };
@ -49,6 +51,8 @@
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
C400D7222CC2AF560092237C /* GetSyncData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetSyncData.swift; sourceTree = "<group>"; };
C400D7242CC2B5CF0092237C /* SyncResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncResponse.swift; sourceTree = "<group>"; };
C425D4342B6D24E1002A7B48 /* LeStorage.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = LeStorage.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C425D4342B6D24E1002A7B48 /* LeStorage.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = LeStorage.framework; sourceTree = BUILT_PRODUCTS_DIR; };
C425D4372B6D24E1002A7B48 /* LeStorage.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = LeStorage.h; sourceTree = "<group>"; }; C425D4372B6D24E1002A7B48 /* LeStorage.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = LeStorage.h; sourceTree = "<group>"; };
C425D4382B6D24E1002A7B48 /* LeStorage.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = LeStorage.docc; sourceTree = "<group>"; }; C425D4382B6D24E1002A7B48 /* LeStorage.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = LeStorage.docc; sourceTree = "<group>"; };
@ -177,6 +181,8 @@
C45D35902C0A1DB5000F379F /* FailedAPICall.swift */, C45D35902C0A1DB5000F379F /* FailedAPICall.swift */,
C4FC2E302C353E7B0021F3BF /* Log.swift */, C4FC2E302C353E7B0021F3BF /* Log.swift */,
C4A47D9A2B7CFFC500ADC637 /* Settings.swift */, C4A47D9A2B7CFFC500ADC637 /* Settings.swift */,
C400D7222CC2AF560092237C /* GetSyncData.swift */,
C400D7242CC2B5CF0092237C /* SyncResponse.swift */,
); );
path = Codables; path = Codables;
sourceTree = "<group>"; sourceTree = "<group>";
@ -305,11 +311,13 @@
C425D4392B6D24E1002A7B48 /* LeStorage.docc in Sources */, C425D4392B6D24E1002A7B48 /* LeStorage.docc in Sources */,
C4A47DAF2B85FD3800ADC637 /* Errors.swift in Sources */, C4A47DAF2B85FD3800ADC637 /* Errors.swift in Sources */,
C4A47D612B6D3C1300ADC637 /* Services.swift in Sources */, C4A47D612B6D3C1300ADC637 /* Services.swift in Sources */,
C400D7252CC2B5CF0092237C /* SyncResponse.swift in Sources */,
C4A47D552B6D2DBF00ADC637 /* FileUtils.swift in Sources */, C4A47D552B6D2DBF00ADC637 /* FileUtils.swift in Sources */,
C456EFE22BE52379007388E2 /* StoredSingleton.swift in Sources */, C456EFE22BE52379007388E2 /* StoredSingleton.swift in Sources */,
C4A47D652B6E92FE00ADC637 /* Storable.swift in Sources */, C4A47D652B6E92FE00ADC637 /* Storable.swift in Sources */,
C4D477972CB66EEA0077713D /* Date+Extensions.swift in Sources */, C4D477972CB66EEA0077713D /* Date+Extensions.swift in Sources */,
C4A47D6D2B71364600ADC637 /* ModelObject.swift in Sources */, C4A47D6D2B71364600ADC637 /* ModelObject.swift in Sources */,
C400D7232CC2AF560092237C /* GetSyncData.swift in Sources */,
C4A47D4F2B6D280200ADC637 /* StoredCollection.swift in Sources */, C4A47D4F2B6D280200ADC637 /* StoredCollection.swift in Sources */,
C4A47D9C2B7CFFE000ADC637 /* Settings.swift in Sources */, C4A47D9C2B7CFFE000ADC637 /* Settings.swift in Sources */,
C4FC2E292C2B2EC30021F3BF /* StoreCenter.swift in Sources */, C4FC2E292C2B2EC30021F3BF /* StoreCenter.swift in Sources */,

@ -190,7 +190,13 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
case .delete: case .delete:
let _: Empty = try await self._executeApiCall(apiCall) let _: Empty = try await self._executeApiCall(apiCall)
case .get: case .get:
let _: [T] = try await self._executeApiCall(apiCall) if T.self == GetSyncData.self {
let _: Empty = try await self._executeApiCall(apiCall)
} else {
let _: [T] = try await self._executeApiCall(apiCall)
}
// process GET
// what if it is a sync GET
} }
} catch { } catch {
// Logger.log("\(T.resourceName()) > API CALL RETRY ERROR:") // Logger.log("\(T.resourceName()) > API CALL RETRY ERROR:")

@ -27,11 +27,11 @@ class ApiCall<T: Storable>: ModelObject, Storable, SomeCall {
/// The HTTP method of the call: post... /// The HTTP method of the call: post...
var method: HTTPMethod var method: HTTPMethod
/// The id of the underlying data
var dataId: String
/// The content of the call /// The content of the call
var body: String var body: String?
/// The id of the underlying data stored in the body
var dataId: String?
/// The number of times the call has been executed /// The number of times the call has been executed
var attemptsCount: Int = 0 var attemptsCount: Int = 0
@ -45,4 +45,8 @@ class ApiCall<T: Storable>: ModelObject, Storable, SomeCall {
self.body = body self.body = body
} }
func copy(from other: any Storable) {
fatalError("should not happen")
}
} }

@ -30,4 +30,8 @@ class DataLog: ModelObject, Storable {
self.operation = operation self.operation = operation
} }
func copy(from other: any Storable) {
fatalError("should not happen")
}
} }

@ -41,4 +41,16 @@ class FailedAPICall: SyncedModelObject, SyncedStorable {
self.authentication = authentication self.authentication = authentication
} }
func copy(from other: any Storable) {
guard let fac = other as? FailedAPICall else { return }
self.date = fac.date
self.callId = fac.callId
self.type = fac.type
self.apiCall = fac.apiCall
self.error = fac.error
self.authentication = fac.authentication
}
} }

@ -0,0 +1,25 @@
//
// SyncData.swift
// LeStorage
//
// Created by Laurent Morvillier on 18/10/2024.
//
import Foundation
class GetSyncData: ModelObject, SyncedStorable {
static func filterByStoreIdentifier() -> Bool { return false }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
var lastUpdate: Date = Date()
static func resourceName() -> String {
return "data"
}
func copy(from other: any Storable) {
guard let getSyncData = other as? GetSyncData else { return }
self.lastUpdate = getSyncData.lastUpdate
}
}

@ -23,4 +23,11 @@ class Log: SyncedModelObject, SyncedStorable {
self.message = message self.message = message
} }
func copy(from other: any Storable) {
guard let log = other as? Log else { return }
self.date = log.date
self.message = log.message
}
} }

@ -0,0 +1,126 @@
//
// SyncResponse.swift
// LeStorage
//
// Created by Laurent Morvillier on 18/10/2024.
//
import Foundation
struct SyncResponse: Codable {
let updates: [String: [Codable]]
let deletions: [String: [Int]]
let date: String?
enum CodingKeys: String, CodingKey {
case updates, deletions, date
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
deletions = try container.decode([String: [Int]].self, forKey: .deletions)
date = try container.decodeIfPresent(String.self, forKey: .date)
let updatesContainer = try container.nestedContainer(
keyedBy: DynamicCodingKeys.self, forKey: .updates)
var updatesDict = [String: [AnyCodable]]()
for key in updatesContainer.allKeys {
let swiftClass = try SyncResponse._classFromClassName(key.stringValue)
let decodedArray = try updatesContainer.decode([AnyCodable].self, forKey: key)
let typedArray = decodedArray.compactMap { $0.value as? AnyCodable }
updatesDict[key.stringValue] = typedArray
}
updates = updatesDict
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(deletions, forKey: .deletions)
try container.encodeIfPresent(date, forKey: .date)
var updatesContainer = container.nestedContainer(
keyedBy: DynamicCodingKeys.self, forKey: .updates)
for (key, value) in updates {
let encodableArray = value.map { AnyCodable($0) }
try updatesContainer.encode(
encodableArray, forKey: DynamicCodingKeys(stringValue: key)!)
}
}
struct DynamicCodingKeys: CodingKey {
var stringValue: String
init?(stringValue: String) {
self.stringValue = stringValue
}
var intValue: Int?
init?(intValue: Int) {
return nil
}
}
fileprivate static func _classFromClassName(_ className: String) throws -> Codable.Type {
let fullClassName = "PadelClub.\(className)"
let modelClass: AnyClass? = NSClassFromString(fullClassName)
if let type = modelClass as? Codable.Type {
return type
} else {
throw LeStorageError.cantFindClassFromName(name: className)
}
}
}
struct AnyCodable: Codable {
let value: Any
init(_ value: Any) {
self.value = value
}
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let intValue = try? container.decode(Int.self) {
value = intValue
} else if let doubleValue = try? container.decode(Double.self) {
value = doubleValue
} else if let boolValue = try? container.decode(Bool.self) {
value = boolValue
} else if let stringValue = try? container.decode(String.self) {
value = stringValue
} else if let arrayValue = try? container.decode([AnyCodable].self) {
value = arrayValue.map { $0.value }
} else if let dictionaryValue = try? container.decode([String: AnyCodable].self) {
value = dictionaryValue.mapValues { $0.value }
} else {
throw DecodingError.dataCorruptedError(
in: container, debugDescription: "AnyCodable value cannot be decoded")
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch value {
case let intValue as Int:
try container.encode(intValue)
case let doubleValue as Double:
try container.encode(doubleValue)
case let boolValue as Bool:
try container.encode(boolValue)
case let stringValue as String:
try container.encode(stringValue)
case let arrayValue as [Any]:
try container.encode(arrayValue.map { AnyCodable($0) })
case let dictionaryValue as [String: Any]:
try container.encode(dictionaryValue.mapValues { AnyCodable($0) })
default:
throw EncodingError.invalidValue(
value,
EncodingError.Context(
codingPath: container.codingPath,
debugDescription: "AnyCodable value cannot be encoded"))
}
}
}

@ -87,16 +87,21 @@ public class Services {
_ request: URLRequest, apiCall: ApiCall<T> _ request: URLRequest, apiCall: ApiCall<T>
) async throws -> V { ) async throws -> V {
let debugURL = request.url?.absoluteString ?? "" let debugURL = request.url?.absoluteString ?? ""
print("Run \(request.httpMethod ?? "") \(debugURL)") // print("Run \(request.httpMethod ?? "") \(debugURL)")
let task: (Data, URLResponse) = try await URLSession.shared.data(for: request) let task: (Data, URLResponse) = try await URLSession.shared.data(for: request)
print("response = \(String(data: task.0, encoding: .utf8) ?? "")") print("\(apiCall.method.rawValue) \(String(describing: T.self)) => \(String(data: task.0, encoding: .utf8) ?? "")")
if let response = task.1 as? HTTPURLResponse { if let response = task.1 as? HTTPURLResponse {
let statusCode = response.statusCode let statusCode = response.statusCode
print("\(debugURL) ended, status code = \(statusCode)") print("\(debugURL) ended, status code = \(statusCode)")
switch statusCode { switch statusCode {
case 200..<300: // success case 200..<300: // success
try await StoreCenter.main.deleteApiCallById(type: T.self, id: apiCall.id) try await StoreCenter.main.deleteApiCallById(type: T.self, id: apiCall.id)
if T.self == GetSyncData.self {
StoreCenter.main.synchronizeContent(task.0, decoder: self.jsonDecoder)
}
default: // error default: // error
Logger.log( Logger.log(
"Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")") "Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
@ -120,22 +125,27 @@ public class Services {
Logger.w(message) Logger.w(message)
} }
if !(V.self is Empty?.Type) { return try self._decode(data: task.0)
return try jsonDecoder.decode(V.self, from: task.0)
}
fileprivate func _decode<V: Decodable>(data: Data) throws -> V {
if !(V.self is Empty?.Type || V.self is Empty.Type) {
return try jsonDecoder.decode(V.self, from: data)
} else { } else {
return try jsonDecoder.decode(V.self, from: "{}".data(using: .utf8)!) return try jsonDecoder.decode(V.self, from: "{}".data(using: .utf8)!)
} }
} }
/// Runs a request using a traditional URLRequest /// Runs a request using a traditional URLRequest
/// - Parameters: /// - Parameters:
/// - request: the URLRequest to run /// - request: the URLRequest to run
/// - apiCallId: the id of the ApiCall to delete in case of success, or to schedule for a rerun in case of failure /// - apiCallId: the id of the ApiCall to delete in case of success, or to schedule for a rerun in case of failure
fileprivate func _runRequest<V: Decodable>(_ request: URLRequest) async throws -> V { fileprivate func _runRequest<V: Decodable>(_ request: URLRequest) async throws -> V {
let debugURL = request.url?.absoluteString ?? "" let debugURL = request.url?.absoluteString ?? ""
print("Run \(request.httpMethod ?? "") \(debugURL)") // print("Run \(request.httpMethod ?? "") \(debugURL)")
let task: (Data, URLResponse) = try await URLSession.shared.data(for: request) let task: (Data, URLResponse) = try await URLSession.shared.data(for: request)
print("response = \(String(data: task.0, encoding: .utf8) ?? "")") print("\(request.httpMethod ?? "") \(debugURL) => \(String(data: task.0, encoding: .utf8) ?? "")")
if let response = task.1 as? HTTPURLResponse { if let response = task.1 as? HTTPURLResponse {
let statusCode = response.statusCode let statusCode = response.statusCode
@ -158,7 +168,7 @@ public class Services {
StoreCenter.main.log(message: message) StoreCenter.main.log(message: message)
Logger.w(message) Logger.w(message)
} }
return try jsonDecoder.decode(V.self, from: task.0) return try self._decode(data: task.0)
} }
/// Returns if the token is required for a request /// Returns if the token is required for a request
@ -190,31 +200,31 @@ public class Services {
/// Returns a POST request for the resource /// Returns a POST request for the resource
/// - Parameters: /// - Parameters:
/// - type: the type of the request resource /// - type: the type of the request resource
fileprivate func _postRequest<T: SyncedStorable>(type: T.Type) throws -> URLRequest { // fileprivate func _postRequest<T: SyncedStorable>(type: T.Type) throws -> URLRequest {
let requiresToken = self._isTokenRequired(type: T.self, method: .post) // let requiresToken = self._isTokenRequired(type: T.self, method: .post)
return try self._baseRequest( // return try self._baseRequest(
servicePath: T.path(), method: .post, requiresToken: requiresToken) // servicePath: T.path(), method: .post, requiresToken: requiresToken)
} // }
//
/// Returns a PUT request for the resource // /// Returns a PUT request for the resource
/// - Parameters: // /// - Parameters:
/// - type: the type of the request resource // /// - type: the type of the request resource
fileprivate func _putRequest<T: SyncedStorable>(type: T.Type, id: String) throws -> URLRequest { // fileprivate func _putRequest<T: SyncedStorable>(type: T.Type, id: String) throws -> URLRequest {
let requiresToken = self._isTokenRequired(type: T.self, method: .put) // let requiresToken = self._isTokenRequired(type: T.self, method: .put)
return try self._baseRequest( // return try self._baseRequest(
servicePath: T.path(id: id), method: .put, requiresToken: requiresToken) // servicePath: T.path(id: id), method: .put, requiresToken: requiresToken)
} // }
//
/// Returns a DELETE request for the resource // /// Returns a DELETE request for the resource
/// - Parameters: // /// - Parameters:
/// - type: the type of the request resource // /// - type: the type of the request resource
fileprivate func _deleteRequest<T: SyncedStorable>(type: T.Type, id: String) throws // fileprivate func _deleteRequest<T: SyncedStorable>(type: T.Type, id: String) throws
-> URLRequest // -> URLRequest
{ // {
let requiresToken = self._isTokenRequired(type: T.self, method: .delete) // let requiresToken = self._isTokenRequired(type: T.self, method: .delete)
return try self._baseRequest( // return try self._baseRequest(
servicePath: T.path(id: id), method: .delete, requiresToken: requiresToken) // servicePath: T.path(id: id), method: .delete, requiresToken: requiresToken)
} // }
/// Returns the base URLRequest for a ServiceConf instance /// Returns the base URLRequest for a ServiceConf instance
/// - Parameters: /// - Parameters:
@ -286,7 +296,7 @@ public class Services {
guard let url = URL(string: urlString) else { guard let url = URL(string: urlString) else {
throw ServiceError.urlCreationError(url: urlString) throw ServiceError.urlCreationError(url: urlString)
} }
guard let bodyData = apiCall.body.data(using: .utf8) else { guard let body = apiCall.body, let bodyData = body.data(using: .utf8) else {
throw ServiceError.cantDecodeData(content: apiCall.body) throw ServiceError.cantDecodeData(content: apiCall.body)
} }
@ -352,9 +362,9 @@ public class Services {
/// - request: The synchronization request /// - request: The synchronization request
fileprivate func _runGetSyncLogRequest(_ request: URLRequest) async throws { fileprivate func _runGetSyncLogRequest(_ request: URLRequest) async throws {
let debugURL = request.url?.absoluteString ?? "" let debugURL = request.url?.absoluteString ?? ""
print("Run \(request.httpMethod ?? "") \(debugURL)") // print("Run \(request.httpMethod ?? "") \(debugURL)")
let task: (Data, URLResponse) = try await URLSession.shared.data(for: request) let task: (Data, URLResponse) = try await URLSession.shared.data(for: request)
print("response = \(String(data: task.0, encoding: .utf8) ?? "")") print("\(request.httpMethod ?? "") \(debugURL) => \(String(data: task.0, encoding: .utf8) ?? "")")
if let response = task.1 as? HTTPURLResponse { if let response = task.1 as? HTTPURLResponse {
let statusCode = response.statusCode let statusCode = response.statusCode
@ -422,7 +432,7 @@ public class Services {
/// Executes an ApiCall /// Executes an ApiCall
func runApiCall<T: SyncedStorable, V: Decodable>(_ apiCall: ApiCall<T>) async throws -> V { func runApiCall<T: SyncedStorable, V: Decodable>(_ apiCall: ApiCall<T>) async throws -> V {
let request = try self._syncRequest(from: apiCall) let request = try self._syncRequest(from: apiCall)
print("HTTP \(request.httpMethod ?? "") : id = \(apiCall.dataId)") // print("HTTP \(request.httpMethod ?? "") : id = \(apiCall.dataId)")
return try await self._runRequest(request, apiCall: apiCall) return try await self._runRequest(request, apiCall: apiCall)
} }
@ -433,7 +443,7 @@ public class Services {
let url = try self._url(from: apiCall) let url = try self._url(from: apiCall)
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = apiCall.method.rawValue request.httpMethod = apiCall.method.rawValue
request.httpBody = apiCall.body.data(using: .utf8) request.httpBody = apiCall.body?.data(using: .utf8)
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if self._isTokenRequired(type: T.self, method: apiCall.method) { if self._isTokenRequired(type: T.self, method: apiCall.method) {

@ -9,7 +9,7 @@ import Foundation
/// A protocol describing classes that can be stored locally in JSON and synchronized on our django server /// A protocol describing classes that can be stored locally in JSON and synchronized on our django server
public protocol Storable: Codable, Identifiable, NSObjectProtocol { public protocol Storable: Codable, Identifiable, NSObjectProtocol {
/// The store containing a reference to the instance /// The store containing a reference to the instance
var store: Store? { get set } var store: Store? { get set }
@ -28,10 +28,13 @@ public protocol Storable: Codable, Identifiable, NSObjectProtocol {
/// so when we do that on the server, we also need to do it locally /// so when we do that on the server, we also need to do it locally
func deleteDependencies() func deleteDependencies()
static var relationshipNames: [String] { get } // static var relationshipNames: [String] { get }
/// A method called after the instance has been deleted from its StoredCollection /// A method called after the instance has been deleted from its StoredCollection
func hasBeenDeleted() func hasBeenDeleted()
func copy(from other: any Storable)
} }
extension Storable { extension Storable {

@ -147,7 +147,7 @@ open class Store {
/// Loads all collection with the data from the server /// Loads all collection with the data from the server
public func loadCollectionsFromServer() { public func loadCollectionsFromServer() {
for collection in self._StoredCollections() { for collection in self._syncedCollections() {
Task { Task {
try? await collection.loadDataFromServerIfAllowed() try? await collection.loadDataFromServerIfAllowed()
} }
@ -156,7 +156,7 @@ open class Store {
/// Loads all synchronized collection with server data if they don't already have a local file /// Loads all synchronized collection with server data if they don't already have a local file
public func loadCollectionsFromServerIfNoFile() { public func loadCollectionsFromServerIfNoFile() {
for collection in self._StoredCollections() { for collection in self._syncedCollections() {
Task { Task {
do { do {
try await collection.loadCollectionsFromServerIfNoFile() try await collection.loadCollectionsFromServerIfNoFile()
@ -167,7 +167,7 @@ open class Store {
} }
} }
fileprivate func _StoredCollections() -> [any SomeSyncedCollection] { fileprivate func _syncedCollections() -> [any SomeSyncedCollection] {
return self._collections.values.compactMap { $0 as? any SomeSyncedCollection } return self._collections.values.compactMap { $0 as? any SomeSyncedCollection }
} }

@ -9,13 +9,13 @@ import Foundation
import UIKit import UIKit
public class StoreCenter { public class StoreCenter {
/// The main instance /// The main instance
public static let main: StoreCenter = StoreCenter() public static let main: StoreCenter = StoreCenter()
/// A dictionary of Stores associated to their id /// A dictionary of Stores associated to their id
fileprivate var _stores: [String : Store] = [:] fileprivate var _stores: [String: Store] = [:]
/// The URL of the django API /// The URL of the django API
public var synchronizationApiURL: String? { public var synchronizationApiURL: String? {
didSet { didSet {
@ -24,39 +24,40 @@ public class StoreCenter {
} }
} }
} }
/// Indicates to Stored Collection if they can synchronize /// Indicates to Stored Collection if they can synchronize
public var collectionsCanSynchronize: Bool = true public var collectionsCanSynchronize: Bool = true
/// Force the absence of synchronization /// Force the absence of synchronization
public var forceNoSynchronization: Bool = false public var forceNoSynchronization: Bool = false
/// A store for the Settings object /// A store for the Settings object
fileprivate var _settingsStorage: MicroStorage<Settings> = MicroStorage(fileName: "settings.json") fileprivate var _settingsStorage: MicroStorage<Settings> = MicroStorage(
fileName: "settings.json")
/// The services performing the API calls /// The services performing the API calls
fileprivate var _services: Services? fileprivate var _services: Services?
/// The dictionary of registered StoredCollections /// The dictionary of registered StoredCollections
fileprivate var _apiCallCollections: [String : any SomeCallCollection] = [:] fileprivate var _apiCallCollections: [String: any SomeCallCollection] = [:]
/// A collection of DataLog objects, used for the synchronization /// A collection of DataLog objects, used for the synchronization
fileprivate var _dataLogs: StoredCollection<DataLog> fileprivate var _dataLogs: StoredCollection<DataLog>
/// A collection storing FailedAPICall objects /// A collection storing FailedAPICall objects
fileprivate var _failedAPICallsCollection: StoredCollection<FailedAPICall>? = nil fileprivate var _failedAPICallsCollection: StoredCollection<FailedAPICall>? = nil
/// A collection of Log objects /// A collection of Log objects
fileprivate var _logs: StoredCollection<Log>? = nil fileprivate var _logs: StoredCollection<Log>? = nil
/// A list of username that cannot synchronize with the server /// A list of username that cannot synchronize with the server
fileprivate var _blackListedUserName: [String] = [] fileprivate var _blackListedUserName: [String] = []
init() { init() {
self._dataLogs = Store.main.registerCollection() self._dataLogs = Store.main.registerCollection()
self._setupNotifications() self._setupNotifications()
} }
/// Returns the service instance /// Returns the service instance
public func service() throws -> Services { public func service() throws -> Services {
if let service = self._services { if let service = self._services {
@ -65,7 +66,7 @@ public class StoreCenter {
throw StoreError.missingService throw StoreError.missingService
} }
} }
private func _setupNotifications() { private func _setupNotifications() {
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
self, self,
@ -78,9 +79,9 @@ public class StoreCenter {
Logger.log("_willEnterForegroundNotification") Logger.log("_willEnterForegroundNotification")
self._launchSynchronization() self._launchSynchronization()
} }
@objc fileprivate func _launchSynchronization() { @objc fileprivate func _launchSynchronization() {
Task{ Task {
do { do {
try await self.synchronizeLastUpdates() try await self.synchronizeLastUpdates()
} catch { } catch {
@ -101,7 +102,7 @@ public class StoreCenter {
} }
self._stores[identifier] = store self._stores[identifier] = store
} }
/// Returns a store using its identifier, and registers it if it does not exists /// Returns a store using its identifier, and registers it if it does not exists
/// - Parameters: /// - Parameters:
/// - identifier: The store identifer /// - identifier: The store identifer
@ -115,9 +116,9 @@ public class StoreCenter {
return store return store
} }
} }
// MARK: - Settings // MARK: - Settings
/// Sets the user info given a user /// Sets the user info given a user
func setUserInfo(user: UserBase) { func setUserInfo(user: UserBase) {
self._settingsStorage.update { settings in self._settingsStorage.update { settings in
@ -125,37 +126,37 @@ public class StoreCenter {
settings.username = user.username settings.username = user.username
} }
} }
/// Returns the stored user Id /// Returns the stored user Id
public var userId: String? { public var userId: String? {
return self._settingsStorage.item.userId return self._settingsStorage.item.userId
} }
/// Returns the username /// Returns the username
public func userName() -> String? { public func userName() -> String? {
return self._settingsStorage.item.username return self._settingsStorage.item.username
} }
/// Returns the stored token /// Returns the stored token
public func token() -> String? { public func token() -> String? {
return try? self.service().keychainStore.getValue() return try? self.service().keychainStore.getValue()
} }
/// Disconnect the user from the storage and resets collection /// Disconnect the user from the storage and resets collection
public func disconnect() { public func disconnect() {
try? self.service().deleteToken() try? self.service().deleteToken()
self.resetApiCalls() self.resetApiCalls()
self._failedAPICallsCollection?.reset() self._failedAPICallsCollection?.reset()
self._settingsStorage.update { settings in self._settingsStorage.update { settings in
settings.username = nil settings.username = nil
settings.userId = nil settings.userId = nil
settings.lastSynchronization = nil settings.lastSynchronization = nil
} }
} }
/// Returns whether the system has a user token /// Returns whether the system has a user token
public func hasToken() -> Bool { public func hasToken() -> Bool {
do { do {
@ -165,7 +166,7 @@ public class StoreCenter {
return false return false
} }
} }
/// Returns a generated device id /// Returns a generated device id
/// If created, stores it inside the keychain to get a consistent value even if the app is deleted /// If created, stores it inside the keychain to get a consistent value even if the app is deleted
/// as UIDevice.current.identifierForVendor value changes when the app is deleted and installed again /// as UIDevice.current.identifierForVendor value changes when the app is deleted and installed again
@ -174,8 +175,8 @@ public class StoreCenter {
do { do {
return try keychainStore.getValue() return try keychainStore.getValue()
} catch { } catch {
let deviceId: String = UIDevice.current.identifierForVendor?.uuidString ?? let deviceId: String =
UUID().uuidString UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
do { do {
try keychainStore.add(value: deviceId) try keychainStore.add(value: deviceId)
} catch { } catch {
@ -184,9 +185,9 @@ public class StoreCenter {
return deviceId return deviceId
} }
} }
// MARK: - Api Calls management // MARK: - Api Calls management
/// Instantiates and loads an ApiCallCollection with the provided type /// Instantiates and loads an ApiCallCollection with the provided type
public func loadApiCallCollection<T: SyncedStorable>(type: T.Type) { public func loadApiCallCollection<T: SyncedStorable>(type: T.Type) {
if self._apiCallCollections[T.resourceName()] == nil { if self._apiCallCollections[T.resourceName()] == nil {
@ -201,7 +202,7 @@ public class StoreCenter {
} }
} }
} }
/// Returns the ApiCall collection using the resource name of the provided T type /// Returns the ApiCall collection using the resource name of the provided T type
func apiCallCollection<T: Storable>() throws -> ApiCallCollection<T> { func apiCallCollection<T: Storable>() throws -> ApiCallCollection<T> {
if let collection = self._apiCallCollections[T.resourceName()] as? ApiCallCollection<T> { if let collection = self._apiCallCollections[T.resourceName()] as? ApiCallCollection<T> {
@ -218,7 +219,7 @@ public class StoreCenter {
let apiCallCollection: ApiCallCollection<T> = try self.apiCallCollection() let apiCallCollection: ApiCallCollection<T> = try self.apiCallCollection()
await apiCallCollection.deleteByDataId(id) await apiCallCollection.deleteByDataId(id)
} }
/// Deletes an ApiCall by its id /// Deletes an ApiCall by its id
/// - Parameters: /// - Parameters:
/// - type: the subsequent type of the ApiCall /// - type: the subsequent type of the ApiCall
@ -227,7 +228,7 @@ public class StoreCenter {
let apiCallCollection: ApiCallCollection<T> = try self.apiCallCollection() let apiCallCollection: ApiCallCollection<T> = try self.apiCallCollection()
await apiCallCollection.deleteById(id) await apiCallCollection.deleteById(id)
} }
/// Deletes an ApiCall by its id /// Deletes an ApiCall by its id
/// - Parameters: /// - Parameters:
/// - id: the id of the ApiCall /// - id: the id of the ApiCall
@ -239,7 +240,7 @@ public class StoreCenter {
throw StoreError.collectionNotRegistered(type: collectionName) throw StoreError.collectionNotRegistered(type: collectionName)
} }
} }
/// Resets all the api call collections /// Resets all the api call collections
public func resetApiCalls() { public func resetApiCalls() {
Task { Task {
@ -248,7 +249,7 @@ public class StoreCenter {
} }
} }
} }
/// Resets the ApiCall whose type identifies with the provided collection /// Resets the ApiCall whose type identifies with the provided collection
/// - Parameters: /// - Parameters:
/// - collection: The collection identifying the Storable type /// - collection: The collection identifying the Storable type
@ -262,9 +263,9 @@ public class StoreCenter {
Logger.error(error) Logger.error(error)
} }
} }
// MARK: - Api call rescheduling // MARK: - Api call rescheduling
/// Reschedule an ApiCall by id /// Reschedule an ApiCall by id
func rescheduleApiCalls<T: SyncedStorable>(id: String, type: T.Type) async throws { func rescheduleApiCalls<T: SyncedStorable>(id: String, type: T.Type) async throws {
guard self.collectionsCanSynchronize else { guard self.collectionsCanSynchronize else {
@ -273,24 +274,27 @@ public class StoreCenter {
let collection: ApiCallCollection<T> = try self.apiCallCollection() let collection: ApiCallCollection<T> = try self.apiCallCollection()
await collection.rescheduleApiCallsIfNecessary() await collection.rescheduleApiCallsIfNecessary()
} }
/// Executes an ApiCall /// Executes an ApiCall
fileprivate func _executeApiCall<T: SyncedStorable, V: Decodable>(_ apiCall: ApiCall<T>) async throws -> V { fileprivate func _executeApiCall<T: SyncedStorable, V: Decodable>(_ apiCall: ApiCall<T>)
async throws -> V
{
return try await self.service().runApiCall(apiCall) return try await self.service().runApiCall(apiCall)
} }
/// Executes an API call /// Executes an API call
func execute<T: SyncedStorable, V: Decodable>(apiCall: ApiCall<T>) async throws -> V { func execute<T: SyncedStorable, V: Decodable>(apiCall: ApiCall<T>) async throws -> V {
return try await self._executeApiCall(apiCall) return try await self._executeApiCall(apiCall)
} }
// MARK: - Api calls // MARK: - Api calls
/// Returns whether the collection can synchronize /// Returns whether the collection can synchronize
fileprivate func _canSynchronise() -> Bool { fileprivate func _canSynchronise() -> Bool {
return !self.forceNoSynchronization && self.collectionsCanSynchronize && self.userIsAllowed() return !self.forceNoSynchronization && self.collectionsCanSynchronize
&& self.userIsAllowed()
} }
/// Transmit the insertion request to the ApiCall collection /// Transmit the insertion request to the ApiCall collection
/// - Parameters: /// - Parameters:
/// - instance: an object to insert /// - instance: an object to insert
@ -300,7 +304,7 @@ public class StoreCenter {
} }
return try await self.apiCallCollection().sendInsertion(instance) return try await self.apiCallCollection().sendInsertion(instance)
} }
/// Transmit the update request to the ApiCall collection /// Transmit the update request to the ApiCall collection
/// - Parameters: /// - Parameters:
/// - instance: an object to update /// - instance: an object to update
@ -310,7 +314,7 @@ public class StoreCenter {
} }
return try await self.apiCallCollection().sendUpdate(instance) return try await self.apiCallCollection().sendUpdate(instance)
} }
/// Transmit the deletion request to the ApiCall collection /// Transmit the deletion request to the ApiCall collection
/// - Parameters: /// - Parameters:
/// - instance: an object to delete /// - instance: an object to delete
@ -320,13 +324,17 @@ public class StoreCenter {
} }
try await self.apiCallCollection().sendDeletion(instance) try await self.apiCallCollection().sendDeletion(instance)
} }
/// Retrieves all the items on the server /// Retrieves all the items on the server
func getItems<T: SyncedStorable>(identifier: StoreIdentifier? = nil) async throws -> [T] { func getItems<T: SyncedStorable>(identifier: StoreIdentifier? = nil) async throws -> [T] {
return try await self.service().get(identifier: identifier) return try await self.service().get(identifier: identifier)
} }
// MARK: - Synchronization // MARK: - Synchronization
fileprivate func _createSyncApiCallCollection() {
self.loadApiCallCollection(type: GetSyncData.self)
}
public func initialSynchronization() { public func initialSynchronization() {
self._settingsStorage.update { settings in self._settingsStorage.update { settings in
@ -334,16 +342,17 @@ public class StoreCenter {
} }
Store.main.loadCollectionsFromServer() Store.main.loadCollectionsFromServer()
} }
public func synchronizeLastUpdates() async throws { public func synchronizeLastUpdates() async throws {
let lastSync: Date? = self._settingsStorage.item.lastSynchronization let lastSync: Date? = self._settingsStorage.item.lastSynchronization
try await self._services?.synchronizeLastUpdates(since: lastSync) try await self._services?.synchronizeLastUpdates(since: lastSync)
} }
func synchronizeContent(_ data: Data, decoder: JSONDecoder) { func synchronizeContent(_ data: Data, decoder: JSONDecoder) {
do { do {
guard let json = try JSONSerialization.jsonObject(with: data, options: []) guard
let json = try JSONSerialization.jsonObject(with: data, options: [])
as? [String: Any] as? [String: Any]
else { else {
Logger.w("data unrecognized") Logger.w("data unrecognized")
@ -370,32 +379,32 @@ public class StoreCenter {
if let dateString: String = json["date"] as? String, if let dateString: String = json["date"] as? String,
let syncDate = Date.iso8601Formatter.date(from: dateString) { let syncDate = Date.iso8601Formatter.date(from: dateString) {
self._settingsStorage.update { settings in self._settingsStorage.update { settings in
settings.lastSynchronization = syncDate settings.lastSynchronization = syncDate
} }
} }
} catch { } catch {
Logger.error(error) Logger.error(error)
} }
} }
fileprivate func _parseSyncUpdates(_ updates: [String: Any], decoder: JSONDecoder) throws { fileprivate func _parseSyncUpdates(_ updates: [String: Any], decoder: JSONDecoder) throws {
for (className, updateData) in updates { for (className, updateData) in updates {
guard let updateArray = updateData as? [[String: Any]] else { guard let updateArray = updateData as? [[String: Any]] else {
Logger.w("Invalid update data for \(className)") Logger.w("Invalid update data for \(className)")
continue continue
} }
let type = try self._classFromClassName(className) let type = try self._classFromClassName(className)
for updateItem in updateArray { for updateItem in updateArray {
do { do {
let jsonData = try JSONSerialization.data(withJSONObject: updateItem, options: []) let jsonData = try JSONSerialization.data(
withJSONObject: updateItem, options: [])
let decodedObject = try decoder.decode(type, from: jsonData) let decodedObject = try decoder.decode(type, from: jsonData)
let storeId: String? = decodedObject.getStoreId() let storeId: String? = decodedObject.getStoreId()
StoreCenter.main.synchronizationAddOrUpdate(decodedObject, storeId: storeId) StoreCenter.main.synchronizationAddOrUpdate(decodedObject, storeId: storeId)
} catch { } catch {
@ -404,25 +413,27 @@ public class StoreCenter {
} }
} }
} }
fileprivate func _parseSyncDeletions(_ deletions: [String: Any], decoder: JSONDecoder) throws { fileprivate func _parseSyncDeletions(_ deletions: [String: Any], decoder: JSONDecoder) throws {
for (className, updateDeletions) in deletions { for (className, updateDeletions) in deletions {
guard let deletionArray = updateDeletions as? [[String: Any]] else { guard let deletionArray = updateDeletions as? [[String: Any]] else {
Logger.w("Invalid update data for \(className)") Logger.w("Invalid update data for \(className)")
continue continue
} }
let type = try self._classFromClassName(className) let type = try self._classFromClassName(className)
for updateItem in deletionArray { for updateItem in deletionArray {
if let object = updateItem["data"] { if let object = updateItem["data"] {
do { do {
let jsonData = try JSONSerialization.data(withJSONObject: object, options: []) let jsonData = try JSONSerialization.data(
withJSONObject: object, options: [])
let decodedObject = try decoder.decode(type, from: jsonData) let decodedObject = try decoder.decode(type, from: jsonData)
let storeId = updateItem["storeId"] as? String let storeId = updateItem["storeId"] as? String
StoreCenter.main.synchronizationDelete(instance: decodedObject, storeId: storeId) StoreCenter.main.synchronizationDelete(
instance: decodedObject, storeId: storeId)
} catch { } catch {
Logger.error(error) Logger.error(error)
} }
@ -430,9 +441,9 @@ public class StoreCenter {
} }
} }
} }
fileprivate func _classFromClassName(_ className: String) throws -> any SyncedStorable.Type { fileprivate func _classFromClassName(_ className: String) throws -> any SyncedStorable.Type {
let fullClassName = "PadelClub.\(className)" let fullClassName = "PadelClub.\(className)"
let modelClass: AnyClass? = NSClassFromString(fullClassName) let modelClass: AnyClass? = NSClassFromString(fullClassName)
if let type = modelClass as? any SyncedStorable.Type { if let type = modelClass as? any SyncedStorable.Type {
@ -440,9 +451,9 @@ public class StoreCenter {
} else { } else {
throw LeStorageError.cantFindClassFromName(name: className) throw LeStorageError.cantFindClassFromName(name: className)
} }
} }
fileprivate func _store(id: String?) -> Store? { fileprivate func _store(id: String?) -> Store? {
if let storeId = id { if let storeId = id {
return self._stores[storeId] return self._stores[storeId]
@ -450,47 +461,50 @@ public class StoreCenter {
return Store.main return Store.main
} }
} }
fileprivate func _hasAlreadyBeenDeleted<T: Storable>(_ instance: T) -> Bool { fileprivate func _hasAlreadyBeenDeleted<T: Storable>(_ instance: T) -> Bool {
return self._dataLogs.contains(where: { $0.dataId == instance.stringId && $0.operation == .delete }) return self._dataLogs.contains(where: {
$0.dataId == instance.stringId && $0.operation == .delete
})
} }
func synchronizationAddOrUpdate<T: SyncedStorable>(_ instance: T, storeId: String?) { func synchronizationAddOrUpdate<T: SyncedStorable>(_ instance: T, storeId: String?) {
let hasAlreadyBeenDeleted: Bool = self._hasAlreadyBeenDeleted(instance) let hasAlreadyBeenDeleted: Bool = self._hasAlreadyBeenDeleted(instance)
if !hasAlreadyBeenDeleted { if !hasAlreadyBeenDeleted {
DispatchQueue.main.async { DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self._store(id: storeId)?.addOrUpdateIfNewer(instance) self._store(id: storeId)?.addOrUpdateIfNewer(instance)
} }
} }
} }
func synchronizationDelete<T: Storable>(instance: T, storeId: String?) { func synchronizationDelete<T: Storable>(instance: T, storeId: String?) {
DispatchQueue.main.async { DispatchQueue.main.async {
self._store(id: storeId)?.deleteNoSync(instance: instance) self._store(id: storeId)?.deleteNoSync(instance: instance)
self._cleanupDataLog(dataId: instance.stringId) self._cleanupDataLog(dataId: instance.stringId)
} }
} }
fileprivate func _cleanupDataLog(dataId: String) { fileprivate func _cleanupDataLog(dataId: String) {
let logs = self._dataLogs.filter { $0.dataId == dataId } let logs = self._dataLogs.filter { $0.dataId == dataId }
self._dataLogs.delete(contentOfs: logs) self._dataLogs.delete(contentOfs: logs)
} }
// func createInsertLog<T: Storable>(_ instance: T) { // func createInsertLog<T: Storable>(_ instance: T) {
// self._addDataLog(instance, method: .post) // self._addDataLog(instance, method: .post)
// } // }
func createDeleteLog<T: Storable>(_ instance: T) { func createDeleteLog<T: Storable>(_ instance: T) {
self._addDataLog(instance, method: .delete) self._addDataLog(instance, method: .delete)
} }
fileprivate func _addDataLog<T: Storable>(_ instance: T, method: HTTPMethod) { fileprivate func _addDataLog<T: Storable>(_ instance: T, method: HTTPMethod) {
let dataLog = DataLog(dataId: instance.stringId, modelName: String(describing: T.self), operation: method) let dataLog = DataLog(
dataId: instance.stringId, modelName: String(describing: T.self), operation: method)
self._dataLogs.addOrUpdate(instance: dataLog) self._dataLogs.addOrUpdate(instance: dataLog)
} }
// MARK: - Miscellanous // MARK: - Miscellanous
public func apiCallCount<T: SyncedStorable>(type: T.Type) async -> Int { public func apiCallCount<T: SyncedStorable>(type: T.Type) async -> Int {
do { do {
let collection: ApiCallCollection<T> = try self.apiCallCollection() let collection: ApiCallCollection<T> = try self.apiCallCollection()
@ -499,7 +513,7 @@ public class StoreCenter {
return -1 return -1
} }
} }
/// Resets all registered collection /// Resets all registered collection
public func reset() { public func reset() {
Store.main.reset() Store.main.reset()
@ -507,7 +521,7 @@ public class StoreCenter {
store.reset() store.reset()
} }
} }
/// Returns whether any collection has pending API calls /// Returns whether any collection has pending API calls
public func hasPendingAPICalls() async -> Bool { public func hasPendingAPICalls() async -> Bool {
for collection in self._apiCallCollections.values { for collection in self._apiCallCollections.values {
@ -522,33 +536,39 @@ public class StoreCenter {
public func apiCallsFileContent(resourceName: String) async -> String { public func apiCallsFileContent(resourceName: String) async -> String {
return await self._apiCallCollections[resourceName]?.contentOfFile() ?? "" return await self._apiCallCollections[resourceName]?.contentOfFile() ?? ""
} }
/// This method triggers the framework to save and send failed api calls /// This method triggers the framework to save and send failed api calls
public func logsFailedAPICalls() { public func logsFailedAPICalls() {
self._failedAPICallsCollection = Store.main.registerCollection() self._failedAPICallsCollection = Store.main.registerCollection()
} }
/// If configured for, logs and send to the server a failed API call /// If configured for, logs and send to the server a failed API call
/// Logs a failed API call that has failed at least 5 times /// Logs a failed API call that has failed at least 5 times
func logFailedAPICall(_ apiCallId: String, request: URLRequest, collectionName: String, error: String) { func logFailedAPICall(
_ apiCallId: String, request: URLRequest, collectionName: String, error: String
) {
guard let failedAPICallsCollection = self._failedAPICallsCollection, guard let failedAPICallsCollection = self._failedAPICallsCollection,
let collection = self._apiCallCollections[collectionName], let collection = self._apiCallCollections[collectionName],
collectionName != FailedAPICall.resourceName() collectionName != FailedAPICall.resourceName()
else { else {
return return
} }
Task { Task {
if let apiCall = await collection.findCallById(apiCallId) { if let apiCall = await collection.findCallById(apiCallId) {
if !failedAPICallsCollection.contains(where: { $0.callId == apiCallId }) && apiCall.attemptsCount > 6 { if !failedAPICallsCollection.contains(where: { $0.callId == apiCallId })
&& apiCall.attemptsCount > 6
{
do { do {
let authValue = request.allHTTPHeaderFields?["Authorization"] let authValue = request.allHTTPHeaderFields?["Authorization"]
let string = try apiCall.jsonString() let string = try apiCall.jsonString()
let failedAPICall = FailedAPICall(callId: apiCall.id, type: collectionName, apiCall: string, error: error, authentication: authValue) let failedAPICall = FailedAPICall(
callId: apiCall.id, type: collectionName, apiCall: string, error: error,
authentication: authValue)
DispatchQueue.main.async { DispatchQueue.main.async {
failedAPICallsCollection.addOrUpdate(instance: failedAPICall) failedAPICallsCollection.addOrUpdate(instance: failedAPICall)
} }
@ -558,25 +578,28 @@ public class StoreCenter {
} }
} }
} }
} }
/// Logs a failed Api call with its request and error message /// Logs a failed Api call with its request and error message
func logFailedAPICall(request: URLRequest, error: String) { func logFailedAPICall(request: URLRequest, error: String) {
guard let failedAPICallsCollection = self._failedAPICallsCollection, guard let failedAPICallsCollection = self._failedAPICallsCollection,
let body: Data = request.httpBody, let body: Data = request.httpBody,
let bodyString = String(data: body, encoding: .utf8), let bodyString = String(data: body, encoding: .utf8),
let url = request.url?.absoluteString else { let url = request.url?.absoluteString
else {
return return
} }
let authValue = request.allHTTPHeaderFields?["Authorization"] let authValue = request.allHTTPHeaderFields?["Authorization"]
let failedAPICall = FailedAPICall(callId: request.hashValue.formatted(), type: url, apiCall: bodyString, error: error, authentication: authValue) let failedAPICall = FailedAPICall(
callId: request.hashValue.formatted(), type: url, apiCall: bodyString, error: error,
authentication: authValue)
failedAPICallsCollection.addOrUpdate(instance: failedAPICall) failedAPICallsCollection.addOrUpdate(instance: failedAPICall)
} }
/// Adds a userName to the black list /// Adds a userName to the black list
/// Black listed username cannot send data to the server /// Black listed username cannot send data to the server
/// - Parameters: /// - Parameters:
@ -584,15 +607,15 @@ public class StoreCenter {
public func blackListUserName(_ userName: String) { public func blackListUserName(_ userName: String) {
self._blackListedUserName.append(userName) self._blackListedUserName.append(userName)
} }
/// Returns whether the current userName is allowed to sync with the server /// Returns whether the current userName is allowed to sync with the server
func userIsAllowed() -> Bool { func userIsAllowed() -> Bool {
guard let userName = self.userName() else { guard let userName = self.userName() else {
return true return true
} }
return !self._blackListedUserName.contains(where: { $0 == userName } ) return !self._blackListedUserName.contains(where: { $0 == userName })
} }
/// Deletes the directory using its identifier /// Deletes the directory using its identifier
/// - Parameters: /// - Parameters:
/// - identifier: The name of the directory /// - identifier: The name of the directory
@ -601,9 +624,9 @@ public class StoreCenter {
FileManager.default.deleteDirectoryInDocuments(directoryName: directory) FileManager.default.deleteDirectoryInDocuments(directoryName: directory)
self._stores.removeValue(forKey: identifier) self._stores.removeValue(forKey: identifier)
} }
// MARK: - Instant update // MARK: - Instant update
/// Updates a local object with a server instance /// Updates a local object with a server instance
func updateFromServerInstance<T: SyncedStorable>(_ result: T) { func updateFromServerInstance<T: SyncedStorable>(_ result: T) {
if let storedCollection: StoredCollection<T> = self.collectionOfInstance(result) { if let storedCollection: StoredCollection<T> = self.collectionOfInstance(result) {
@ -612,7 +635,7 @@ public class StoreCenter {
} }
} }
} }
/// Returns the collection hosting an instance /// Returns the collection hosting an instance
func collectionOfInstance<T: Storable>(_ instance: T) -> StoredCollection<T>? { func collectionOfInstance<T: Storable>(_ instance: T) -> StoredCollection<T>? {
do { do {
@ -626,7 +649,7 @@ public class StoreCenter {
return self.collectionOfInstanceInSubStores(instance) return self.collectionOfInstanceInSubStores(instance)
} }
} }
/// Search inside the additional stores to find the collection hosting the instance /// Search inside the additional stores to find the collection hosting the instance
func collectionOfInstanceInSubStores<T: Storable>(_ instance: T) -> StoredCollection<T>? { func collectionOfInstanceInSubStores<T: Storable>(_ instance: T) -> StoredCollection<T>? {
for store in self._stores.values { for store in self._stores.values {
@ -637,9 +660,9 @@ public class StoreCenter {
} }
return nil return nil
} }
// MARK: - Logs // MARK: - Logs
/// Returns the logs collection and instantiates it if necessary /// Returns the logs collection and instantiates it if necessary
fileprivate func _logsCollection() -> StoredCollection<Log> { fileprivate func _logsCollection() -> StoredCollection<Log> {
if let logs = self._logs { if let logs = self._logs {
@ -650,15 +673,15 @@ public class StoreCenter {
return logsCollection return logsCollection
} }
} }
/// Logs a message in the logs collection /// Logs a message in the logs collection
public func log(message: String) { public func log(message: String) {
let log = Log(message: message) let log = Log(message: message)
self._logsCollection().addOrUpdate(instance: log) self._logsCollection().addOrUpdate(instance: log)
} }
// MARK: - Migration // MARK: - Migration
/// Migrates the token from the provided service to the main Services instance /// Migrates the token from the provided service to the main Services instance
public func migrateToken(_ services: Services) throws { public func migrateToken(_ services: Services) throws {
guard let userName = self.userName() else { guard let userName = self.userName() else {
@ -666,9 +689,9 @@ public class StoreCenter {
} }
try self.service().migrateToken(services, userName: userName) try self.service().migrateToken(services, userName: userName)
} }
deinit { deinit {
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
} }
} }

@ -15,7 +15,7 @@ enum StoredCollectionError: Error {
protocol CollectionHolder { protocol CollectionHolder {
associatedtype Item associatedtype Item
var items: [Item] { get } var items: [Item] { get }
func reset() func reset()
} }
@ -33,78 +33,82 @@ protocol SomeSyncedCollection: SomeCollection {
} }
extension Notification.Name { extension Notification.Name {
public static let CollectionDidLoad: Notification.Name = Notification.Name.init("notification.collectionDidLoad") public static let CollectionDidLoad: Notification.Name = Notification.Name.init(
public static let CollectionDidChange: Notification.Name = Notification.Name.init("notification.collectionDidChange") "notification.collectionDidLoad")
public static let CollectionDidChange: Notification.Name = Notification.Name.init(
"notification.collectionDidChange")
} }
public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollection, CollectionHolder { public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollection, CollectionHolder
{
/// Doesn't write the collection in a file /// Doesn't write the collection in a file
fileprivate(set) var inMemory: Bool = false fileprivate(set) var inMemory: Bool = false
/// The list of stored items /// The list of stored items
@Published public fileprivate(set) var items: [T] = [] @Published public fileprivate(set) var items: [T] = []
/// The reference to the Store /// The reference to the Store
fileprivate(set) var store: Store fileprivate(set) var store: Store
/// Provides fast access for instances if the collection has been instanced with [indexed] = true /// Provides fast access for instances if the collection has been instanced with [indexed] = true
fileprivate var _indexes: [T.ID : T]? = nil fileprivate var _indexes: [T.ID: T]? = nil
/// Indicates whether the collection has changed, thus requiring a write operation /// Indicates whether the collection has changed, thus requiring a write operation
fileprivate var _hasChanged: Bool = false { fileprivate var _hasChanged: Bool = false {
didSet { didSet {
if self._hasChanged == true { if self._hasChanged == true {
self._scheduleWrite() self._scheduleWrite()
DispatchQueue.main.async { DispatchQueue.main.async {
NotificationCenter.default.post(name: NSNotification.Name.CollectionDidChange, object: self) NotificationCenter.default.post(
name: NSNotification.Name.CollectionDidChange, object: self)
} }
self._hasChanged = false self._hasChanged = false
} }
} }
} }
/// Denotes a collection that loads and writes asynchronously /// Denotes a collection that loads and writes asynchronously
fileprivate var asynchronousIO: Bool = true fileprivate var asynchronousIO: Bool = true
/// Indicates if the collection has loaded locally, with or without a file /// Indicates if the collection has loaded locally, with or without a file
fileprivate(set) public var hasLoaded: Bool = false fileprivate(set) public var hasLoaded: Bool = false
init(store: Store, indexed: Bool = false, asynchronousIO: Bool = true, inMemory: Bool = false) { init(store: Store, indexed: Bool = false, asynchronousIO: Bool = true, inMemory: Bool = false) {
// self.synchronized = synchronized // self.synchronized = synchronized
self.asynchronousIO = asynchronousIO self.asynchronousIO = asynchronousIO
if indexed { if indexed {
self._indexes = [:] self._indexes = [:]
} }
self.inMemory = inMemory self.inMemory = inMemory
self.store = store self.store = store
self.load() self.load()
} }
fileprivate init() { fileprivate init() {
// self.synchronized = false // self.synchronized = false
self.store = Store.main self.store = Store.main
} }
public static func placeholder() -> StoredCollection<T> { public static func placeholder() -> StoredCollection<T> {
return StoredCollection<T>() return StoredCollection<T>()
} }
var resourceName: String { var resourceName: String {
return T.resourceName() return T.resourceName()
} }
// MARK: - Loading // MARK: - Loading
func setChanged() { func setChanged() {
self._hasChanged = true self._hasChanged = true
} }
/// Migrates if necessary and asynchronously decodes the json file /// Migrates if necessary and asynchronously decodes the json file
func load() { func load() {
do { do {
if !self.inMemory { if !self.inMemory {
try self.loadFromFile() try self.loadFromFile()
@ -112,12 +116,12 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
} catch { } catch {
Logger.error(error) Logger.error(error)
} }
} }
/// Starts the JSON file decoding synchronously or asynchronously /// Starts the JSON file decoding synchronously or asynchronously
func loadFromFile() throws { func loadFromFile() throws {
if self.asynchronousIO { if self.asynchronousIO {
Task(priority: .high) { Task(priority: .high) {
try self._decodeJSONFile() try self._decodeJSONFile()
@ -125,14 +129,14 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
} else { } else {
try self._decodeJSONFile() try self._decodeJSONFile()
} }
} }
/// Decodes the json file into the items array /// Decodes the json file into the items array
fileprivate func _decodeJSONFile() throws { fileprivate func _decodeJSONFile() throws {
let fileURL = try self.store.fileURL(type: T.self) let fileURL = try self.store.fileURL(type: T.self)
if FileManager.default.fileExists(atPath: fileURL.path()) { if FileManager.default.fileExists(atPath: fileURL.path()) {
let jsonString: String = try FileUtils.readFile(fileURL: fileURL) let jsonString: String = try FileUtils.readFile(fileURL: fileURL)
let decoded: [T] = try jsonString.decodeArray() ?? [] let decoded: [T] = try jsonString.decodeArray() ?? []
@ -152,51 +156,52 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
self.setAsLoaded() self.setAsLoaded()
} }
} }
/// Sets the collection as loaded /// Sets the collection as loaded
/// Send a CollectionDidLoad event /// Send a CollectionDidLoad event
func setAsLoaded() { func setAsLoaded() {
self.hasLoaded = true self.hasLoaded = true
DispatchQueue.main.async { DispatchQueue.main.async {
NotificationCenter.default.post(name: NSNotification.Name.CollectionDidLoad, object: self) NotificationCenter.default.post(
name: NSNotification.Name.CollectionDidLoad, object: self)
} }
} }
/// Sets a collection of items and indexes them /// Sets a collection of items and indexes them
fileprivate func _setItems(_ items: [T]) { fileprivate func _setItems(_ items: [T]) {
self.items = items self.items = items
self._updateIndexIfNecessary() self._updateIndexIfNecessary()
} }
/// Updates the whole index with the items array /// Updates the whole index with the items array
fileprivate func _updateIndexIfNecessary() { fileprivate func _updateIndexIfNecessary() {
if let _ = self._indexes { if self._indexes != nil {
self._indexes = self.items.dictionary { $0.id } self._indexes = self.items.dictionary { $0.id }
} }
} }
// MARK: - Basic operations // MARK: - Basic operations
/// Adds or updates the provided instance inside the collection /// Adds or updates the provided instance inside the collection
/// Adds it if its id is not found, and otherwise updates it /// Adds it if its id is not found, and otherwise updates it
public func addOrUpdate(instance: T) { public func addOrUpdate(instance: T) {
self.addOrUpdateItem(instance: instance) self.addOrUpdateItem(instance: instance)
} }
func addOrUpdateItem(instance: T) { func addOrUpdateItem(instance: T) {
defer { defer {
self._hasChanged = true self._hasChanged = true
} }
if let index = self.items.firstIndex(where: { $0.id == instance.id }) { if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
self.updateItem(instance, index: index) self.updateItem(instance, index: index)
} else { } else {
self.addItem(instance: instance) self.addItem(instance: instance)
} }
} }
/// A method the treat the collection as a single instance holder /// A method the treat the collection as a single instance holder
func setSingletonNoSync(instance: T) { func setSingletonNoSync(instance: T) {
defer { defer {
@ -205,7 +210,7 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
self.items.removeAll() self.items.removeAll()
self.addItem(instance: instance) self.addItem(instance: instance)
} }
/// Deletes the instance in the collection and sets the collection as changed to trigger a write /// Deletes the instance in the collection and sets the collection as changed to trigger a write
public func delete(instance: T) throws { public func delete(instance: T) throws {
defer { defer {
@ -213,51 +218,51 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
} }
self.deleteItem(instance) self.deleteItem(instance)
} }
/// Deletes all items of the sequence by id and sets the collection as changed to trigger a write /// Deletes all items of the sequence by id and sets the collection as changed to trigger a write
public func delete(contentOfs sequence: any Sequence<T>) { public func delete(contentOfs sequence: any Sequence<T>) {
defer { defer {
self._hasChanged = true self._hasChanged = true
} }
for instance in sequence { for instance in sequence {
self.deleteItem(instance) self.deleteItem(instance)
} }
} }
/// Deletes an instance in the collection. Also: /// Deletes an instance in the collection. Also:
/// - Removes its reference from the index /// - Removes its reference from the index
/// - Notifies the server of the deletion /// - Notifies the server of the deletion
/// - Calls `hasBeenDeleted` on the deleted instance /// - Calls `hasBeenDeleted` on the deleted instance
// fileprivate func _delete(_ instance: T) throws { // fileprivate func _delete(_ instance: T) throws {
// instance.deleteDependencies() // instance.deleteDependencies()
// self.items.removeAll { $0.id == instance.id } // self.items.removeAll { $0.id == instance.id }
// self._indexes?.removeValue(forKey: instance.id) // self._indexes?.removeValue(forKey: instance.id)
// instance.hasBeenDeleted() // instance.hasBeenDeleted()
// } // }
/// Adds or update a sequence of elements /// Adds or update a sequence of elements
public func addOrUpdate(contentOfs sequence: any Sequence<T>) { public func addOrUpdate(contentOfs sequence: any Sequence<T>) {
self.addSequence(sequence) self.addSequence(sequence)
// self._addOrUpdate(contentOfs: sequence) // self._addOrUpdate(contentOfs: sequence)
} }
func addSequence(_ sequence: any Sequence<T>) { func addSequence(_ sequence: any Sequence<T>) {
defer { defer {
self._hasChanged = true self._hasChanged = true
} }
for instance in sequence { for instance in sequence {
if let index = self.items.firstIndex(where: { $0.id == instance.id }) { if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
self.updateItem(instance, index: index) self.updateItem(instance, index: index)
} else { // insert } else { // insert
self.addItem(instance: instance) self.addItem(instance: instance)
} }
} }
} }
fileprivate func _affectStoreIdIfNecessary(instance: T) { fileprivate func _affectStoreIdIfNecessary(instance: T) {
if let storeId = self.store.identifier?.value { if let storeId = self.store.identifier?.value {
if var altStorable = instance as? SideStorable { if var altStorable = instance as? SideStorable {
@ -267,7 +272,7 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
} }
} }
} }
func addItem(instance: T) { func addItem(instance: T) {
self._affectStoreIdIfNecessary(instance: instance) self._affectStoreIdIfNecessary(instance: instance)
self.items.append(instance) self.items.append(instance)
@ -276,18 +281,21 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
} }
func updateItem(_ instance: T, index: Int) { func updateItem(_ instance: T, index: Int) {
self.items[index] = instance // var existingItem = self.items[index]
// existingItem.hasBeenDeleted()
// self._copy(instance, into: &existingItem) // we need to keep the instance alive for screen to refresh
self.items[index].copy(from: instance)
instance.store = self.store instance.store = self.store
self._indexes?[instance.id] = instance self._indexes?[instance.id] = instance
} }
func deleteItem(_ instance: T) { func deleteItem(_ instance: T) {
instance.deleteDependencies() instance.deleteDependencies()
self.items.removeAll { $0.id == instance.id } self.items.removeAll { $0.id == instance.id }
self._indexes?.removeValue(forKey: instance.id) self._indexes?.removeValue(forKey: instance.id)
instance.hasBeenDeleted() instance.hasBeenDeleted()
} }
/// Returns the instance corresponding to the provided [id] /// Returns the instance corresponding to the provided [id]
public func findById(_ id: T.ID) -> T? { public func findById(_ id: T.ID) -> T? {
if let index = self._indexes, let instance = index[id] { if let index = self._indexes, let instance = index[id] {
@ -295,98 +303,98 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
} }
return self.items.first(where: { $0.id == id }) return self.items.first(where: { $0.id == id })
} }
/// Deletes the instance corresponding to the provided [id] /// Deletes the instance corresponding to the provided [id]
public func deleteById(_ id: T.ID) throws { public func deleteById(_ id: T.ID) throws {
if let instance = self.findById(id) { if let instance = self.findById(id) {
self.deleteItem(instance) self.deleteItem(instance)
// try self.delete(instance: instance) // try self.delete(instance: instance)
} }
} }
/// Proceeds to "hard" delete the items without synchronizing them /// Proceeds to "hard" delete the items without synchronizing them
/// Also removes related API calls /// Also removes related API calls
public func deleteDependencies(_ items: any Sequence<T>) { public func deleteDependencies(_ items: any Sequence<T>) {
defer { defer {
self._hasChanged = true self._hasChanged = true
} }
let itemsArray = Array(items) // fix error if items is self.items let itemsArray = Array(items) // fix error if items is self.items
for item in itemsArray { for item in itemsArray {
if let index = self.items.firstIndex(where: { $0.id == item.id }) { if let index = self.items.firstIndex(where: { $0.id == item.id }) {
self.items.remove(at: index) self.items.remove(at: index)
} }
item.hasBeenDeleted() item.hasBeenDeleted()
// Task { // Task {
// do { // do {
// try await StoreCenter.main.deleteApiCallByDataId(type: T.self, id: item.stringId) // try await StoreCenter.main.deleteApiCallByDataId(type: T.self, id: item.stringId)
// } catch { // } catch {
// Logger.error(error) // Logger.error(error)
// } // }
// } // }
} }
} }
/// Proceeds to delete all instance of the collection, properly cleaning up dependencies and sending API calls /// Proceeds to delete all instance of the collection, properly cleaning up dependencies and sending API calls
// public func deleteAll() throws { // public func deleteAll() throws {
// try self.delete(contentOfs: self.items) // try self.delete(contentOfs: self.items)
// } // }
// MARK: - SomeCall // MARK: - SomeCall
/// Returns the collection items as [any Storable] /// Returns the collection items as [any Storable]
func allItems() -> [any Storable] { func allItems() -> [any Storable] {
return self.items return self.items
} }
// MARK: - File access // MARK: - File access
/// Schedules a write operation /// Schedules a write operation
fileprivate func _scheduleWrite() { fileprivate func _scheduleWrite() {
guard !self.inMemory else { return } guard !self.inMemory else { return }
if self.asynchronousIO { if self.asynchronousIO {
DispatchQueue(label: "lestorage.queue.write", qos: .utility).asyncAndWait { // sync to make sure we don't have writes performed at the same time DispatchQueue(label: "lestorage.queue.write", qos: .utility).asyncAndWait { // sync to make sure we don't have writes performed at the same time
self._write() self._write()
} }
} else { } else {
self._write() self._write()
} }
} }
/// Writes all the items as a json array inside a file /// Writes all the items as a json array inside a file
fileprivate func _write() { fileprivate func _write() {
// Logger.log("Start write to \(T.fileName())...") // Logger.log("Start write to \(T.fileName())...")
do { do {
let jsonString: String = try self.items.jsonString() let jsonString: String = try self.items.jsonString()
try self.store.write(content: jsonString, fileName: T.fileName()) try self.store.write(content: jsonString, fileName: T.fileName())
} catch { } catch {
Logger.error(error) // TODO how to notify the main project Logger.error(error) // TODO how to notify the main project
} }
// Logger.log("End write") // Logger.log("End write")
} }
/// Simply clears the items of the collection /// Simply clears the items of the collection
func clear() { func clear() {
self.items.removeAll() self.items.removeAll()
} }
/// Removes the items of the collection and deletes the corresponding file /// Removes the items of the collection and deletes the corresponding file
public func reset() { public func reset() {
self.items.removeAll() self.items.removeAll()
self.store.removeFile(type: T.self) self.store.removeFile(type: T.self)
} }
// MARK: - RandomAccessCollection // MARK: - RandomAccessCollection
public var startIndex: Int { return self.items.startIndex } public var startIndex: Int { return self.items.startIndex }
public var endIndex: Int { return self.items.endIndex } public var endIndex: Int { return self.items.endIndex }
public func index(after i: Int) -> Int { public func index(after i: Int) -> Int {
return self.items.index(after: i) return self.items.index(after: i)
} }
open subscript(index: Int) -> T { open subscript(index: Int) -> T {

@ -26,7 +26,7 @@ public enum ServiceError: Error {
case missingUserName case missingUserName
case missingUserId case missingUserId
case responseError(response: String) case responseError(response: String)
case cantDecodeData(content: String) case cantDecodeData(content: String?)
} }
public enum UUIDError: Error { public enum UUIDError: Error {

Loading…
Cancel
Save