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 = {
/* 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 */; };
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 */; };
@ -49,6 +51,8 @@
/* End PBXContainerItemProxy 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; };
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>"; };
@ -177,6 +181,8 @@
C45D35902C0A1DB5000F379F /* FailedAPICall.swift */,
C4FC2E302C353E7B0021F3BF /* Log.swift */,
C4A47D9A2B7CFFC500ADC637 /* Settings.swift */,
C400D7222CC2AF560092237C /* GetSyncData.swift */,
C400D7242CC2B5CF0092237C /* SyncResponse.swift */,
);
path = Codables;
sourceTree = "<group>";
@ -305,11 +311,13 @@
C425D4392B6D24E1002A7B48 /* LeStorage.docc in Sources */,
C4A47DAF2B85FD3800ADC637 /* Errors.swift in Sources */,
C4A47D612B6D3C1300ADC637 /* Services.swift in Sources */,
C400D7252CC2B5CF0092237C /* SyncResponse.swift in Sources */,
C4A47D552B6D2DBF00ADC637 /* FileUtils.swift in Sources */,
C456EFE22BE52379007388E2 /* StoredSingleton.swift in Sources */,
C4A47D652B6E92FE00ADC637 /* Storable.swift in Sources */,
C4D477972CB66EEA0077713D /* Date+Extensions.swift in Sources */,
C4A47D6D2B71364600ADC637 /* ModelObject.swift in Sources */,
C400D7232CC2AF560092237C /* GetSyncData.swift in Sources */,
C4A47D4F2B6D280200ADC637 /* StoredCollection.swift in Sources */,
C4A47D9C2B7CFFE000ADC637 /* Settings.swift in Sources */,
C4FC2E292C2B2EC30021F3BF /* StoreCenter.swift in Sources */,

@ -190,7 +190,13 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
case .delete:
let _: Empty = try await self._executeApiCall(apiCall)
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 {
// 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...
var method: HTTPMethod
/// The id of the underlying data
var dataId: String
/// 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
var attemptsCount: Int = 0
@ -45,4 +45,8 @@ class ApiCall<T: Storable>: ModelObject, Storable, SomeCall {
self.body = body
}
func copy(from other: any Storable) {
fatalError("should not happen")
}
}

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

@ -41,4 +41,16 @@ class FailedAPICall: SyncedModelObject, SyncedStorable {
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
}
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>
) async throws -> V {
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)
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 {
let statusCode = response.statusCode
print("\(debugURL) ended, status code = \(statusCode)")
switch statusCode {
case 200..<300: // success
case 200..<300: // success
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
Logger.log(
"Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
@ -120,22 +125,27 @@ public class Services {
Logger.w(message)
}
if !(V.self is Empty?.Type) {
return try jsonDecoder.decode(V.self, from: task.0)
return try self._decode(data: 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 {
return try jsonDecoder.decode(V.self, from: "{}".data(using: .utf8)!)
}
}
/// Runs a request using a traditional URLRequest
/// - Parameters:
/// - 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
fileprivate func _runRequest<V: Decodable>(_ request: URLRequest) async throws -> V {
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)
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 {
let statusCode = response.statusCode
@ -158,7 +168,7 @@ public class Services {
StoreCenter.main.log(message: 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
@ -190,31 +200,31 @@ public class Services {
/// Returns a POST request for the resource
/// - Parameters:
/// - type: the type of the request resource
fileprivate func _postRequest<T: SyncedStorable>(type: T.Type) throws -> URLRequest {
let requiresToken = self._isTokenRequired(type: T.self, method: .post)
return try self._baseRequest(
servicePath: T.path(), method: .post, requiresToken: requiresToken)
}
/// Returns a PUT request for the resource
/// - Parameters:
/// - type: the type of the request resource
fileprivate func _putRequest<T: SyncedStorable>(type: T.Type, id: String) throws -> URLRequest {
let requiresToken = self._isTokenRequired(type: T.self, method: .put)
return try self._baseRequest(
servicePath: T.path(id: id), method: .put, requiresToken: requiresToken)
}
/// Returns a DELETE request for the resource
/// - Parameters:
/// - type: the type of the request resource
fileprivate func _deleteRequest<T: SyncedStorable>(type: T.Type, id: String) throws
-> URLRequest
{
let requiresToken = self._isTokenRequired(type: T.self, method: .delete)
return try self._baseRequest(
servicePath: T.path(id: id), method: .delete, requiresToken: requiresToken)
}
// fileprivate func _postRequest<T: SyncedStorable>(type: T.Type) throws -> URLRequest {
// let requiresToken = self._isTokenRequired(type: T.self, method: .post)
// return try self._baseRequest(
// servicePath: T.path(), method: .post, requiresToken: requiresToken)
// }
//
// /// Returns a PUT request for the resource
// /// - Parameters:
// /// - type: the type of the request resource
// fileprivate func _putRequest<T: SyncedStorable>(type: T.Type, id: String) throws -> URLRequest {
// let requiresToken = self._isTokenRequired(type: T.self, method: .put)
// return try self._baseRequest(
// servicePath: T.path(id: id), method: .put, requiresToken: requiresToken)
// }
//
// /// Returns a DELETE request for the resource
// /// - Parameters:
// /// - type: the type of the request resource
// fileprivate func _deleteRequest<T: SyncedStorable>(type: T.Type, id: String) throws
// -> URLRequest
// {
// let requiresToken = self._isTokenRequired(type: T.self, method: .delete)
// return try self._baseRequest(
// servicePath: T.path(id: id), method: .delete, requiresToken: requiresToken)
// }
/// Returns the base URLRequest for a ServiceConf instance
/// - Parameters:
@ -286,7 +296,7 @@ public class Services {
guard let url = URL(string: urlString) else {
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)
}
@ -352,9 +362,9 @@ public class Services {
/// - request: The synchronization request
fileprivate func _runGetSyncLogRequest(_ request: URLRequest) async throws {
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)
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 {
let statusCode = response.statusCode
@ -422,7 +432,7 @@ public class Services {
/// Executes an ApiCall
func runApiCall<T: SyncedStorable, V: Decodable>(_ apiCall: ApiCall<T>) async throws -> V {
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)
}
@ -433,7 +443,7 @@ public class Services {
let url = try self._url(from: apiCall)
var request = URLRequest(url: url)
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")
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
public protocol Storable: Codable, Identifiable, NSObjectProtocol {
/// The store containing a reference to the instance
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
func deleteDependencies()
static var relationshipNames: [String] { get }
// static var relationshipNames: [String] { get }
/// A method called after the instance has been deleted from its StoredCollection
func hasBeenDeleted()
func copy(from other: any Storable)
}
extension Storable {

@ -147,7 +147,7 @@ open class Store {
/// Loads all collection with the data from the server
public func loadCollectionsFromServer() {
for collection in self._StoredCollections() {
for collection in self._syncedCollections() {
Task {
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
public func loadCollectionsFromServerIfNoFile() {
for collection in self._StoredCollections() {
for collection in self._syncedCollections() {
Task {
do {
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 }
}

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

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

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

Loading…
Cancel
Save