Compare commits
1 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
22753ae230 | 1 year ago |
@ -1,55 +0,0 @@ |
||||
<?xml version="1.0" encoding="UTF-8"?> |
||||
<Scheme |
||||
LastUpgradeVersion = "1630" |
||||
version = "1.7"> |
||||
<BuildAction |
||||
parallelizeBuildables = "YES" |
||||
buildImplicitDependencies = "YES" |
||||
buildArchitectures = "Automatic"> |
||||
</BuildAction> |
||||
<TestAction |
||||
buildConfiguration = "Debug" |
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" |
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" |
||||
shouldUseLaunchSchemeArgsEnv = "YES" |
||||
shouldAutocreateTestPlan = "YES"> |
||||
<Testables> |
||||
<TestableReference |
||||
skipped = "NO" |
||||
parallelizable = "YES"> |
||||
<BuildableReference |
||||
BuildableIdentifier = "primary" |
||||
BlueprintIdentifier = "C4C33F6A2C9B06B7006316DE" |
||||
BuildableName = "LeStorageTests.xctest" |
||||
BlueprintName = "LeStorageTests" |
||||
ReferencedContainer = "container:LeStorage.xcodeproj"> |
||||
</BuildableReference> |
||||
</TestableReference> |
||||
</Testables> |
||||
</TestAction> |
||||
<LaunchAction |
||||
buildConfiguration = "Debug" |
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" |
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" |
||||
launchStyle = "0" |
||||
useCustomWorkingDirectory = "NO" |
||||
ignoresPersistentStateOnLaunch = "NO" |
||||
debugDocumentVersioning = "YES" |
||||
debugServiceExtension = "internal" |
||||
allowLocationSimulation = "YES"> |
||||
</LaunchAction> |
||||
<ProfileAction |
||||
buildConfiguration = "Release" |
||||
shouldUseLaunchSchemeArgsEnv = "YES" |
||||
savedToolIdentifier = "" |
||||
useCustomWorkingDirectory = "NO" |
||||
debugDocumentVersioning = "YES"> |
||||
</ProfileAction> |
||||
<AnalyzeAction |
||||
buildConfiguration = "Debug"> |
||||
</AnalyzeAction> |
||||
<ArchiveAction |
||||
buildConfiguration = "Release" |
||||
revealArchiveInOrganizer = "YES"> |
||||
</ArchiveAction> |
||||
</Scheme> |
||||
@ -1,11 +0,0 @@ |
||||
|
||||
### Le Storage |
||||
|
||||
LeStorage is used to store objects into json files, and it can also be used to synchronize those objects to a django server properly configured. |
||||
|
||||
Here are the most important classes: |
||||
- StoredCollection: stores object of one class in a json file |
||||
- SyncedCollection: stores object of one class in a json file and synchronizes changes with the server |
||||
- ApiCallCollection: provision HTTP calls and tries to execute them again |
||||
- StoreCenter: The central class to manages all collections through Store instances |
||||
|
||||
@ -1,83 +0,0 @@ |
||||
// |
||||
// DataAcces.swift |
||||
// LeStorage |
||||
// |
||||
// Created by Laurent Morvillier on 21/11/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
class DataAccess: SyncedModelObject, SyncedStorable { |
||||
|
||||
static func tokenExemptedMethods() -> [HTTPMethod] { return [] } |
||||
static func resourceName() -> String { return "data-access" } |
||||
static func relationships() -> [Relationship] { return [] } |
||||
public static func parentRelationships() -> [Relationship] { return [] } |
||||
public static func childrenRelationships() -> [Relationship] { return [] } |
||||
|
||||
static var copyServerResponse: Bool = false |
||||
static func storeParent() -> Bool { return false } |
||||
|
||||
override required init() { |
||||
super.init() |
||||
} |
||||
|
||||
var id: String = Store.randomId() |
||||
var sharedWith: [String] = [] |
||||
var modelName: String = "" |
||||
var modelId: String = "" |
||||
var grantedAt: Date = Date() |
||||
|
||||
init(owner: String, sharedWith: [String], modelName: String, modelId: String, storeId: String?) { |
||||
self.sharedWith = sharedWith |
||||
self.modelName = modelName |
||||
self.modelId = modelId |
||||
super.init() |
||||
self.relatedUser = owner |
||||
self.storeId = storeId |
||||
} |
||||
|
||||
// Codable implementation |
||||
enum CodingKeys: String, CodingKey { |
||||
case id |
||||
case sharedWith |
||||
case modelName |
||||
case modelId |
||||
case grantedAt |
||||
} |
||||
|
||||
required init(from decoder: Decoder) throws { |
||||
let container = try decoder.container(keyedBy: CodingKeys.self) |
||||
id = try container.decode(String.self, forKey: .id) |
||||
sharedWith = try container.decode([String].self, forKey: .sharedWith) |
||||
modelName = try container.decode(String.self, forKey: .modelName) |
||||
modelId = try container.decode(String.self, forKey: .modelId) |
||||
grantedAt = try container.decode(Date.self, forKey: .grantedAt) |
||||
try super.init(from: decoder) |
||||
} |
||||
|
||||
override func encode(to encoder: Encoder) throws { |
||||
var container = encoder.container(keyedBy: CodingKeys.self) |
||||
try container.encode(id, forKey: .id) |
||||
try container.encode(sharedWith, forKey: .sharedWith) |
||||
try container.encode(modelName, forKey: .modelName) |
||||
try container.encode(modelId, forKey: .modelId) |
||||
try container.encode(grantedAt, forKey: .grantedAt) |
||||
try super.encode(to: encoder) |
||||
} |
||||
|
||||
func copy(from other: any Storable) { |
||||
guard let dataAccess = other as? DataAccess else { return } |
||||
self.id = dataAccess.id |
||||
self.sharedWith = dataAccess.sharedWith |
||||
self.modelName = dataAccess.modelName |
||||
self.modelId = dataAccess.modelId |
||||
self.storeId = dataAccess.storeId |
||||
self.grantedAt = dataAccess.grantedAt |
||||
} |
||||
|
||||
public func copyForUpdate(from other: any Storable) { |
||||
self.copy(from: other) |
||||
} |
||||
|
||||
} |
||||
@ -1,44 +0,0 @@ |
||||
// |
||||
// DataLog.swift |
||||
// LeStorage |
||||
// |
||||
// Created by Laurent Morvillier on 11/10/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
class DataLog: ModelObject, Storable { |
||||
|
||||
static func resourceName() -> String { return "data-logs" } |
||||
static func tokenExemptedMethods() -> [HTTPMethod] { return [] } |
||||
static func relationships() -> [Relationship] { return [] } |
||||
public static func parentRelationships() -> [Relationship] { return [] } |
||||
public static func childrenRelationships() -> [Relationship] { return [] } |
||||
|
||||
static func storeParent() -> Bool { return false } |
||||
|
||||
var id: String = Store.randomId() |
||||
|
||||
/// The id of the underlying data |
||||
var dataId: String |
||||
|
||||
/// The name of class of the underlying data |
||||
var modelName: String |
||||
|
||||
/// The operation performed on the underlying data |
||||
var operation: HTTPMethod |
||||
|
||||
init(dataId: String, modelName: String, operation: HTTPMethod) { |
||||
self.dataId = dataId |
||||
self.modelName = modelName |
||||
self.operation = operation |
||||
} |
||||
|
||||
func copy(from other: any Storable) { |
||||
fatalError("should not happen") |
||||
} |
||||
public func copyForUpdate(from other: any Storable) { |
||||
fatalError("should not happen") |
||||
} |
||||
|
||||
} |
||||
@ -1,57 +0,0 @@ |
||||
// |
||||
// SyncData.swift |
||||
// LeStorage |
||||
// |
||||
// Created by Laurent Morvillier on 18/10/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
class GetSyncData: SyncedModelObject, SyncedStorable, URLParameterConvertible { |
||||
|
||||
static func tokenExemptedMethods() -> [HTTPMethod] { return [] } |
||||
static var copyServerResponse: Bool = false |
||||
static func storeParent() -> Bool { return false } |
||||
|
||||
var date: String = "" |
||||
|
||||
enum CodingKeys: String, CodingKey { |
||||
case date |
||||
} |
||||
|
||||
override required init() { |
||||
super.init() |
||||
} |
||||
|
||||
required public init(from decoder: Decoder) throws { |
||||
let container = try decoder.container(keyedBy: CodingKeys.self) |
||||
date = try container.decode(String.self, forKey: .date) |
||||
try super.init(from: decoder) |
||||
} |
||||
|
||||
static func resourceName() -> String { |
||||
return "sync-data" |
||||
} |
||||
|
||||
func copy(from other: any Storable) { |
||||
guard let getSyncData = other as? GetSyncData else { return } |
||||
self.date = getSyncData.date |
||||
} |
||||
public func copyForUpdate(from other: any Storable) { |
||||
fatalError("should not happen") |
||||
} |
||||
func queryParameters(storeCenter: StoreCenter) -> [String : String] { |
||||
return ["last_update" : self._formattedLastUpdate, |
||||
"device_id" : storeCenter.deviceId()] |
||||
} |
||||
|
||||
fileprivate var _formattedLastUpdate: String { |
||||
let encodedDate = self.date.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" |
||||
return encodedDate.replacingOccurrences(of: "+", with: "%2B") |
||||
} |
||||
|
||||
static func relationships() -> [Relationship] { return [] } |
||||
public static func parentRelationships() -> [Relationship] { return [] } |
||||
public static func childrenRelationships() -> [Relationship] { return [] } |
||||
|
||||
} |
||||
@ -1,34 +0,0 @@ |
||||
// |
||||
// WaitingOperation.swift |
||||
// LeStorage |
||||
// |
||||
// Created by Laurent Morvillier on 01/04/2025. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
enum StorageMethod: String, Codable { |
||||
case add |
||||
case update |
||||
case delete |
||||
case deleteUnusedShared |
||||
} |
||||
|
||||
class PendingOperation<T : Storable>: Codable, Equatable { |
||||
|
||||
var id: String = Store.randomId() |
||||
var method: StorageMethod |
||||
var data: T |
||||
var actionOption: ActionOption |
||||
|
||||
init(method: StorageMethod, data: T, actionOption: ActionOption) { |
||||
self.method = method |
||||
self.data = data |
||||
self.actionOption = actionOption |
||||
} |
||||
|
||||
static func == (lhs: PendingOperation, rhs: PendingOperation) -> Bool { |
||||
return lhs.id == rhs.id |
||||
} |
||||
|
||||
} |
||||
@ -1,83 +0,0 @@ |
||||
// |
||||
// SyncData.swift |
||||
// LeStorage |
||||
// |
||||
// Created by Laurent Morvillier on 02/05/2025. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
enum SyncDataError: Error { |
||||
case invalidFormat |
||||
} |
||||
|
||||
struct SyncedStorableArray { |
||||
var type: any SyncedStorable.Type |
||||
var items: [any SyncedStorable] |
||||
} |
||||
|
||||
struct ObjectIdentifierArray { |
||||
var type: any SyncedStorable.Type |
||||
var items: [ObjectIdentifier] |
||||
} |
||||
|
||||
class SyncData { |
||||
|
||||
var updates: [SyncedStorableArray] = [] |
||||
var deletions: [ObjectIdentifierArray] = [] |
||||
var shared: [SyncedStorableArray] = [] |
||||
var grants: [SyncedStorableArray] = [] |
||||
var revocations: [ObjectIdentifierArray] = [] |
||||
var revocationParents: [[ObjectIdentifierArray]] = [] |
||||
// var relationshipSets: [SyncedStorableArray] = [] |
||||
// var relationshipRemovals: [ObjectIdentifierArray] = [] |
||||
var sharedRelationshipSets: [SyncedStorableArray] = [] |
||||
var sharedRelationshipRemovals: [ObjectIdentifierArray] = [] |
||||
var date: String? |
||||
|
||||
init(data: Data, storeCenter: StoreCenter) throws { |
||||
guard let json = try JSONSerialization.jsonObject(with: data, options: []) |
||||
as? [String : Any] |
||||
else { |
||||
throw SyncDataError.invalidFormat |
||||
} |
||||
|
||||
if let updates = json["updates"] as? [String: Any] { |
||||
self.updates = try storeCenter.decodeDictionary(updates) |
||||
} |
||||
if let deletions = json["deletions"] as? [String: Any] { |
||||
self.deletions = try storeCenter.decodeObjectIdentifierDictionary(deletions) |
||||
} |
||||
if let shared = json["shared"] as? [String: Any] { |
||||
self.shared = try storeCenter.decodeDictionary(shared) |
||||
} |
||||
if let grants = json["grants"] as? [String: Any] { |
||||
self.grants = try storeCenter.decodeDictionary(grants) |
||||
} |
||||
if let revocations = json["revocations"] as? [String: Any] { |
||||
self.revocations = try storeCenter.decodeObjectIdentifierDictionary(revocations) |
||||
} |
||||
if let revocationParents = json["revocated_relations"] as? [[String: Any]] { |
||||
for level in revocationParents { |
||||
let decodedLevel = try storeCenter.decodeObjectIdentifierDictionary(level) |
||||
self.revocationParents.append(decodedLevel) |
||||
} |
||||
} |
||||
|
||||
// if let relationshipSets = json["relationship_sets"] as? [String: Any] { |
||||
// self.relationshipSets = try storeCenter.decodeDictionary(relationshipSets) |
||||
// } |
||||
// if let relationshipRemovals = json["relationship_removals"] as? [String: Any] { |
||||
// self.relationshipRemovals = try storeCenter.decodeObjectIdentifierDictionary(relationshipRemovals) |
||||
// } |
||||
if let sharedRelationshipSets = json["shared_relationship_sets"] as? [String: Any] { |
||||
self.sharedRelationshipSets = try storeCenter.decodeDictionary(sharedRelationshipSets) |
||||
} |
||||
if let sharedRelationshipRemovals = json["shared_relationship_removals"] as? [String: Any] { |
||||
self.sharedRelationshipRemovals = try storeCenter.decodeObjectIdentifierDictionary(sharedRelationshipRemovals) |
||||
} |
||||
|
||||
self.date = json["date"] as? String |
||||
} |
||||
|
||||
} |
||||
@ -1,60 +0,0 @@ |
||||
// |
||||
// NetworkMonitor.swift |
||||
// LeStorage |
||||
// |
||||
// Created by Laurent Morvillier on 25/10/2024. |
||||
// |
||||
|
||||
import Network |
||||
import Foundation |
||||
|
||||
public class NetworkMonitor { |
||||
|
||||
public static let shared = NetworkMonitor() |
||||
|
||||
private var _monitor: NWPathMonitor |
||||
private var _queue = DispatchQueue(label: "lestorage.queue.network_monitor") |
||||
|
||||
public var isConnected: Bool { |
||||
get { |
||||
return status == .satisfied |
||||
} |
||||
} |
||||
|
||||
private(set) var status: NWPath.Status = .requiresConnection |
||||
|
||||
// Closure to be called when connection is established |
||||
var onConnectionEstablished: (() -> Void)? |
||||
|
||||
private init() { |
||||
_monitor = NWPathMonitor() |
||||
self._startMonitoring() |
||||
} |
||||
|
||||
private func _startMonitoring() { |
||||
_monitor.pathUpdateHandler = { [weak self] path in |
||||
guard let self = self else { return } |
||||
|
||||
// Update status |
||||
self.status = path.status |
||||
|
||||
// Print status for debugging |
||||
Logger.log("Network Status: \(path.status)") |
||||
|
||||
// Handle connection established |
||||
if path.status == .satisfied { |
||||
DispatchQueue.main.async { |
||||
self.onConnectionEstablished?() |
||||
} |
||||
} |
||||
|
||||
} |
||||
|
||||
self._monitor.start(queue: self._queue) |
||||
} |
||||
|
||||
func stopMonitoring() { |
||||
self._monitor.cancel() |
||||
} |
||||
|
||||
} |
||||
@ -1,18 +0,0 @@ |
||||
// |
||||
// Notification+Name.swift |
||||
// LeStorage |
||||
// |
||||
// Created by Laurent Morvillier on 15/01/2025. |
||||
// |
||||
|
||||
|
||||
|
||||
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 LeStorageDidSynchronize: Notification.Name = Notification.Name.init( |
||||
"notification.leStorageDidSynchronize") |
||||
|
||||
} |
||||
@ -1,66 +0,0 @@ |
||||
// |
||||
// PendingOperationManager.swift |
||||
// LeStorage |
||||
// |
||||
// Created by Laurent Morvillier on 01/04/2025. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
class PendingOperationManager<T: Storable> { |
||||
|
||||
fileprivate(set) var items: [PendingOperation<T>] = [] |
||||
|
||||
fileprivate var _fileName: String |
||||
|
||||
fileprivate var _inMemory: Bool = false |
||||
|
||||
init(store: Store, inMemory: Bool) { |
||||
self._fileName = "\(store.storeCenter.directoryName)/pending_\(T.resourceName()).json" |
||||
|
||||
self._inMemory = inMemory |
||||
if !inMemory { |
||||
do { |
||||
let url = try store.fileURL(fileName: self._fileName) |
||||
if FileManager.default.fileExists(atPath: url.path()) { |
||||
let jsonString = try FileUtils.readDocumentFile(fileName: self._fileName) |
||||
if let decoded: [PendingOperation<T>] = try jsonString.decode() { |
||||
self.items = decoded |
||||
} |
||||
} |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
} |
||||
|
||||
var typeName: String { return String(describing: T.self) } |
||||
|
||||
func addPendingOperation(method: StorageMethod, instance: T, actionOption: ActionOption) { |
||||
Logger.log("addPendingOperation: \(method), \(instance)") |
||||
|
||||
let operation = PendingOperation<T>(method: method, data: instance, actionOption: actionOption) |
||||
self.items.append(operation) |
||||
|
||||
self._writeIfNecessary() |
||||
} |
||||
|
||||
func reset() { |
||||
self.items.removeAll() |
||||
self._writeIfNecessary() |
||||
} |
||||
|
||||
fileprivate func _writeIfNecessary() { |
||||
guard !self._inMemory else { return } |
||||
do { |
||||
let jsonString: String = try self.items.jsonString() |
||||
Task(priority: .background) { |
||||
let _ = try FileUtils.writeToDocumentDirectory(content: jsonString, fileName: self._fileName) |
||||
} |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
|
||||
} |
||||
|
||||
} |
||||
@ -1,31 +0,0 @@ |
||||
// |
||||
// Relationship.swift |
||||
// LeStorage |
||||
// |
||||
// Created by Laurent Morvillier on 27/11/2024. |
||||
// |
||||
|
||||
public enum StoreLookup { |
||||
case same |
||||
case main |
||||
case child |
||||
} |
||||
|
||||
public struct Relationship { |
||||
|
||||
public init(type: any Storable.Type, keyPath: AnyKeyPath, storeLookup: StoreLookup) { |
||||
self.type = type |
||||
self.keyPath = keyPath |
||||
self.storeLookup = storeLookup |
||||
} |
||||
|
||||
/// The type of the relationship |
||||
var type: any Storable.Type |
||||
|
||||
/// the keyPath to access the relationship |
||||
var keyPath: AnyKeyPath |
||||
|
||||
/// Indicates whether the linked object is on the main Store |
||||
var storeLookup: StoreLookup |
||||
|
||||
} |
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,64 +0,0 @@ |
||||
// |
||||
// StoreLibrary.swift |
||||
// LeStorage |
||||
// |
||||
// Created by Laurent Morvillier on 02/06/2025. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
class StoreLibrary { |
||||
|
||||
private let storeCenter: StoreCenter |
||||
|
||||
/// A dictionary of Stores associated to their id |
||||
fileprivate var _stores: [String: Store] = [:] |
||||
|
||||
init(storeCenter: StoreCenter) { |
||||
self.storeCenter = storeCenter |
||||
} |
||||
|
||||
subscript(identifier: String) -> Store? { |
||||
get { |
||||
return self._stores[identifier] |
||||
} |
||||
} |
||||
|
||||
/// Registers a store into the list of stores |
||||
/// - Parameters: |
||||
/// - store: A store to save |
||||
fileprivate func _registerStore(store: Store) { |
||||
guard let identifier = store.identifier else { |
||||
fatalError("The store has no identifier") |
||||
} |
||||
if self._stores[identifier] != nil { |
||||
fatalError("A store with this identifier has already been registered: \(identifier)") |
||||
} |
||||
self._stores[identifier] = store |
||||
} |
||||
|
||||
/// Returns a store using its identifier, and registers it if it does not exists |
||||
/// - Parameters: |
||||
/// - identifier: The store identifer |
||||
/// - parameter: The parameter name used to filter data on the server |
||||
func requestStore(identifier: String) -> Store { |
||||
if let store = self._stores[identifier] { |
||||
return store |
||||
} else { |
||||
let store = Store(storeCenter: self.storeCenter, identifier: identifier) |
||||
self._registerStore(store: store) |
||||
return store |
||||
} |
||||
} |
||||
|
||||
public func destroyStore(identifier: String) { |
||||
let directory = "\(self.storeCenter.directoryName)/\(identifier)" |
||||
FileManager.default.deleteDirectoryInDocuments(directoryName: directory) |
||||
self._stores[identifier]?.reset() |
||||
self._stores.removeValue(forKey: identifier) |
||||
} |
||||
|
||||
func reset() { |
||||
self._stores.removeAll() |
||||
} |
||||
} |
||||
@ -1,478 +0,0 @@ |
||||
// |
||||
// SyncedCollection.swift |
||||
// LeStorage |
||||
// |
||||
// Created by Laurent Morvillier on 11/10/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
protocol SomeSyncedCollection: SomeCollection { |
||||
func loadDataFromServerIfAllowed(clear: Bool) async throws |
||||
func loadCollectionsFromServerIfNoFile() async throws |
||||
} |
||||
|
||||
public class SyncedCollection<T : SyncedStorable>: SomeSyncedCollection, CollectionDelegate { |
||||
|
||||
public typealias Item = T |
||||
|
||||
let store: Store |
||||
let collection: StoredCollection<T> |
||||
|
||||
init(store: Store, indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil, noLoad: Bool = false) { |
||||
|
||||
self.store = store |
||||
self.collection = StoredCollection<T>(store: store, indexed: indexed, inMemory: inMemory, limit: limit, noLoad: noLoad) |
||||
|
||||
} |
||||
|
||||
init(store: Store, inMemory: Bool) async { |
||||
self.store = store |
||||
self.collection = await StoredCollection(store: store, inMemory: inMemory) |
||||
} |
||||
|
||||
var storeCenter: StoreCenter { return self.store.storeCenter } |
||||
|
||||
public var storeId: String? { |
||||
return self.store.identifier |
||||
} |
||||
|
||||
/// Returns a dummy SyncedCollection instance |
||||
public static func placeholder() -> SyncedCollection<T> { |
||||
return SyncedCollection<T>(store: Store(storeCenter: StoreCenter.main)) |
||||
} |
||||
|
||||
/// Loads the collection using the server data only if the collection file doesn't exists |
||||
func loadCollectionsFromServerIfNoFile() async throws { |
||||
let fileURL: URL = try self.store.fileURL(type: T.self) |
||||
if !FileManager.default.fileExists(atPath: fileURL.path()) { |
||||
try await self.loadDataFromServerIfAllowed() |
||||
} |
||||
} |
||||
|
||||
/// Retrieves the data from the server and loads it into the items array |
||||
public func loadDataFromServerIfAllowed(clear: Bool = false) async throws { |
||||
do { |
||||
try await self.storeCenter.sendGetRequest(T.self, storeId: self.storeId, clear: clear) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
|
||||
func loadOnceAsync() async throws { |
||||
let items: [T] = try await self.storeCenter.service().get() |
||||
await self.loadItems(items, clear: true) |
||||
} |
||||
|
||||
/// Updates a local item from a server instance. This method is typically used when the server makes update |
||||
/// to an object when it's inserted. The SyncedCollection possibly needs to update its own copy with new values. |
||||
/// - serverInstance: the instance of the object on the server |
||||
func updateFromServerInstance(_ serverInstance: T) { |
||||
|
||||
guard T.copyServerResponse else { |
||||
return |
||||
} |
||||
|
||||
Task { |
||||
await self.collection.updateLocalInstance(serverInstance) |
||||
} |
||||
|
||||
} |
||||
|
||||
@MainActor |
||||
func loadItems(_ items: [T], clear: Bool = false) { |
||||
self.collection.loadAndWrite(items, clear: clear) |
||||
} |
||||
|
||||
// MARK: - Basic operations with sync |
||||
|
||||
/// Adds or update an instance synchronously, dispatching network operations to background tasks |
||||
public func addOrUpdate(instance: T) { |
||||
let result = _addOrUpdateCore(instance: instance) |
||||
if result.method == .insert { |
||||
Task { await self._sendInsertion(instance) } |
||||
} else { |
||||
Task { await self._sendUpdate(instance) } |
||||
} |
||||
} |
||||
|
||||
/// Private helper function that contains the shared logic |
||||
private func _addOrUpdateCore(instance: T) -> ActionResult<T> { |
||||
instance.lastUpdate = Date() |
||||
|
||||
let result = self.collection.addOrUpdate(instance: instance) |
||||
if result.method == .update { |
||||
if instance.sharing != nil { |
||||
self._cleanUpSharedDependencies() |
||||
} |
||||
} |
||||
|
||||
return result |
||||
} |
||||
|
||||
fileprivate func _addOrUpdateCore(contentOfs sequence: any Sequence<T>) -> OperationBatch<T> { |
||||
|
||||
let date = Date() |
||||
let batch = OperationBatch<T>() |
||||
|
||||
for instance in sequence { |
||||
|
||||
instance.lastUpdate = date |
||||
let result = self.collection.addOrUpdate(instance: instance) |
||||
|
||||
if result.method == .insert { |
||||
batch.addInsert(instance) |
||||
} else { |
||||
batch.addUpdate(instance) |
||||
} |
||||
} |
||||
|
||||
self._cleanUpSharedDependencies() |
||||
|
||||
return batch |
||||
|
||||
} |
||||
|
||||
/// Adds or update a sequence and writes |
||||
public func addOrUpdate(contentOfs sequence: any Sequence<T>) { |
||||
let batch = self._addOrUpdateCore(contentOfs: sequence) |
||||
Task { await self._sendOperationBatch(batch) } |
||||
} |
||||
|
||||
/// Deletes an instance and writes |
||||
public func delete(instance: T) { |
||||
|
||||
self.collection.delete(instance: instance, actionOption: .syncedCascade) |
||||
self.storeCenter.createDeleteLog(instance) |
||||
Task { await self._sendDeletion(instance) } |
||||
|
||||
} |
||||
|
||||
/// Deletes all items of the sequence by id and sets the collection as changed to trigger a write |
||||
public func delete(contentOfs sequence: any RandomAccessCollection<T>) { |
||||
self.delete(contentOfs: sequence, actionOption: .syncedCascade) |
||||
} |
||||
|
||||
func delete(contentOfs sequence: any RandomAccessCollection<T>, actionOption: ActionOption) { |
||||
guard sequence.isNotEmpty else { return } |
||||
let batch = self._deleteCore(contentOfs: sequence, actionOption: actionOption) |
||||
if actionOption.synchronize { |
||||
Task { await self._sendOperationBatch(batch) } |
||||
} |
||||
} |
||||
|
||||
/// Deletes all items of the sequence by id and sets the collection as changed to trigger a write |
||||
fileprivate func _deleteCore(contentOfs sequence: any RandomAccessCollection<T>, actionOption: ActionOption) -> OperationBatch<T> { |
||||
|
||||
var deleted: [T] = [] |
||||
self.collection.delete(contentOfs: sequence, actionOption: actionOption) { result in |
||||
self.storeCenter.createDeleteLog(result.instance) |
||||
if !result.pending { |
||||
deleted.append(result.instance) |
||||
} |
||||
} |
||||
|
||||
let batch = OperationBatch<T>() |
||||
batch.deletes = deleted |
||||
return batch |
||||
} |
||||
|
||||
fileprivate func _cleanUpSharedDependencies() { |
||||
for relationship in T.relationships() { |
||||
if let syncedType = relationship.type as? (any SyncedStorable.Type) { |
||||
do { |
||||
try self._deleteUnusedSharedInstances(relationship: relationship, type: syncedType, originStoreId: self.storeId) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
fileprivate func _deleteUnusedSharedInstances<S: SyncedStorable>(relationship: Relationship, type: S.Type, originStoreId: String?) throws { |
||||
|
||||
let store: Store |
||||
switch relationship.storeLookup { |
||||
case .main: store = self.store.storeCenter.mainStore |
||||
case .same: store = self.store |
||||
case .child: |
||||
throw StoreError.invalidStoreLookup(from: type, to: relationship.type) |
||||
} |
||||
|
||||
let collection: SyncedCollection<S> = try store.syncedCollection() |
||||
collection._deleteUnusedGrantedInstances(originStoreId: originStoreId) |
||||
} |
||||
|
||||
fileprivate func _deleteUnusedGrantedInstances(originStoreId: String?) { |
||||
|
||||
let sharedItems = self.collection.items.filter { $0.sharing == .granted } |
||||
for sharedItem in sharedItems { |
||||
self.store.deleteUnusedGrantedIfNecessary(sharedItem, originStoreId: originStoreId |
||||
) |
||||
} |
||||
} |
||||
|
||||
public func deleteAllItemsAndDependencies(actionOption: ActionOption) { |
||||
if actionOption.synchronize { |
||||
self.delete(contentOfs: self.items, actionOption: actionOption) |
||||
} else { |
||||
self.collection.deleteAllItemsAndDependencies(actionOption: actionOption) |
||||
} |
||||
} |
||||
|
||||
public func deleteDependencies(actionOption: ActionOption, _ isIncluded: (T) -> Bool) { |
||||
let items = self.items.filter(isIncluded) |
||||
if actionOption.synchronize { |
||||
self.delete(contentOfs: items, actionOption: actionOption) |
||||
} else { |
||||
self.collection.delete(contentOfs: items) |
||||
} |
||||
} |
||||
|
||||
// MARK: - Asynchronous operations |
||||
|
||||
/// Adds or update an instance asynchronously and waits for network operations |
||||
public func addOrUpdateAsync(instance: T) async throws { |
||||
let result = _addOrUpdateCore(instance: instance) |
||||
if result.method == .insert { |
||||
try await self._executeBatchOnce(OperationBatch(insert: instance)) |
||||
} else { |
||||
try await self._executeBatchOnce(OperationBatch(update: instance)) |
||||
} |
||||
} |
||||
|
||||
public func addOrUpdateAsync(contentOfs sequence: any Sequence<T>) async throws { |
||||
let batch = self._addOrUpdateCore(contentOfs: sequence) |
||||
try await self._executeBatchOnce(batch) |
||||
} |
||||
|
||||
/// Deletes all items of the sequence by id and sets the collection as changed to trigger a write |
||||
public func deleteAsync(contentOfs sequence: any RandomAccessCollection<T>) async throws { |
||||
guard sequence.isNotEmpty else { return } |
||||
let batch = self._deleteCore(contentOfs: sequence, actionOption: .syncedCascade) |
||||
try await self._executeBatchOnce(batch) |
||||
} |
||||
|
||||
/// Deletes an instance and writes |
||||
public func deleteAsync(instance: T) async throws { |
||||
self.collection.delete(instance: instance, actionOption: .syncedCascade) |
||||
self.storeCenter.createDeleteLog(instance) |
||||
try await self._executeBatchOnce(OperationBatch(delete: instance)) |
||||
} |
||||
|
||||
// MARK: - Basic operations without sync |
||||
|
||||
/// Adds or update an instance without synchronizing it |
||||
func addOrUpdateNoSync(_ instance: T) { |
||||
self.collection.addOrUpdate(instance: instance) |
||||
// self.addOrUpdateItem(instance: instance) |
||||
} |
||||
|
||||
/// Adds or update a sequence of elements without synchronizing it |
||||
func addOrUpdateNoSync(contentOfs sequence: any Sequence<T>) { |
||||
self.collection.addOrUpdate(contentOfs: sequence) |
||||
} |
||||
|
||||
public func deleteNoSync(contentOfs sequence: any RandomAccessCollection<T>) { |
||||
self.collection.delete(contentOfs: sequence) |
||||
} |
||||
|
||||
/// Deletes the instance in the collection without synchronization |
||||
public func deleteNoSync(instance: T, cascading: Bool = false) { |
||||
self.collection.delete(instance: instance, actionOption: .cascade) |
||||
} |
||||
|
||||
func deleteUnusedGranted(instance: T) { |
||||
guard instance.sharing != nil else { return } |
||||
self.deleteByStringId(instance.stringId) |
||||
instance.deleteUnusedSharedDependencies(store: self.store) |
||||
} |
||||
|
||||
func deleteByStringId(_ id: String, actionOption: ActionOption = .standard) { |
||||
self.collection.deleteByStringId(id, actionOption: actionOption) |
||||
} |
||||
|
||||
// MARK: - Collection Delegate |
||||
|
||||
func loadingForMemoryCollection() async { |
||||
do { |
||||
try await self.loadDataFromServerIfAllowed() |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
|
||||
func itemMerged(_ pendingOperation: PendingOperation<T>) { |
||||
|
||||
let batch = OperationBatch<T>() |
||||
switch pendingOperation.method { |
||||
case .add: |
||||
batch.inserts.append(pendingOperation.data) |
||||
case .update: |
||||
batch.updates.append(pendingOperation.data) |
||||
case .delete: |
||||
batch.deletes.append(pendingOperation.data) |
||||
case .deleteUnusedShared: |
||||
break |
||||
} |
||||
|
||||
Task { await self._sendOperationBatch(batch) } |
||||
} |
||||
|
||||
// MARK: - Send requests |
||||
|
||||
fileprivate func _sendInsertion(_ instance: T) async { |
||||
await self._sendOperationBatch(OperationBatch(insert: instance)) |
||||
} |
||||
|
||||
fileprivate func _sendUpdate(_ instance: T) async { |
||||
await self._sendOperationBatch(OperationBatch(update: instance)) |
||||
} |
||||
|
||||
fileprivate func _sendDeletion(_ instance: T) async { |
||||
await self._sendOperationBatch(OperationBatch(delete: instance)) |
||||
} |
||||
|
||||
fileprivate func _sendOperationBatch(_ batch: OperationBatch<T>) async { |
||||
do { |
||||
try await self.storeCenter.sendOperationBatch(batch) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
|
||||
fileprivate func _executeBatchOnce(_ batch: OperationBatch<T>) async throws { |
||||
try await self.storeCenter.singleBatchExecution(batch) |
||||
} |
||||
|
||||
// MARK: Single calls |
||||
|
||||
public func addsIfPostSucceeds(_ instance: T) async throws { |
||||
if let result = try await self.storeCenter.service().post(instance) { |
||||
self.addOrUpdateNoSync(result) |
||||
} |
||||
} |
||||
|
||||
public func updateIfPutSucceeds(_ instance: T) async throws { |
||||
if let result = try await self.storeCenter.service().put(instance) { |
||||
self.addOrUpdateNoSync(result) |
||||
} |
||||
} |
||||
|
||||
// MARK: - Synchronization |
||||
|
||||
/// Adds or update an instance if it is newer than the local instance |
||||
func addOrUpdateIfNewer(_ instance: T, shared: SharingStatus?) { |
||||
|
||||
if let index = self.collection.items.firstIndex(where: { $0.id == instance.id }) { |
||||
let localInstance = self.collection.items[index] |
||||
if instance.lastUpdate > localInstance.lastUpdate { |
||||
self.collection.update(instance, index: index, actionOption: .standard) |
||||
} else { |
||||
// print("do not update \(T.resourceName()): \(instance.lastUpdate.timeIntervalSince1970) / local: \(localInstance.lastUpdate.timeIntervalSince1970)") |
||||
} |
||||
} else { // insert |
||||
instance.sharing = shared |
||||
self.collection.add(instance: instance, actionOption: .standard) |
||||
} |
||||
|
||||
} |
||||
|
||||
// MARK: - Others |
||||
|
||||
/// Sends a POST request for the instance, and changes the collection to perform a write |
||||
public func writeChangeAndInsertOnServer(instance: T) { |
||||
|
||||
self.collection.addOrUpdate(instance: instance) |
||||
Task { |
||||
await self._sendInsertion(instance) |
||||
} |
||||
} |
||||
|
||||
// MARK: - SomeCollection |
||||
|
||||
public var hasLoaded: Bool { return self.collection.hasLoaded} |
||||
|
||||
public var inMemory: Bool { return self.collection.inMemory } |
||||
|
||||
public var type: any Storable.Type { return T.self } |
||||
|
||||
public func hasParentReferences<S>(type: S.Type, id: String) -> Bool where S : Storable { |
||||
return self.collection.hasParentReferences(type: type, id: id) |
||||
} |
||||
|
||||
public func reset() { |
||||
self.collection.reset() |
||||
} |
||||
|
||||
public func findById(_ id: T.ID) -> T? { |
||||
return self.collection.findById(id) |
||||
} |
||||
|
||||
public var items: [T] { |
||||
return self.collection.items |
||||
} |
||||
|
||||
public func requestWriteIfNecessary() { |
||||
self.collection.requestWriteIfNecessary() |
||||
} |
||||
|
||||
// MARK: - Cached queries |
||||
|
||||
public func cached<Result>( |
||||
key: AnyHashable, |
||||
compute: ([T]) -> Result |
||||
) -> Result { |
||||
return self.collection.cached(key: key, compute: compute) |
||||
} |
||||
|
||||
} |
||||
|
||||
class OperationBatch<T> { |
||||
var inserts: [T] = [] |
||||
var updates: [T] = [] |
||||
var deletes: [T] = [] |
||||
|
||||
init() { |
||||
|
||||
} |
||||
init(insert: T) { |
||||
self.inserts = [insert] |
||||
} |
||||
init(update: T) { |
||||
self.updates = [update] |
||||
} |
||||
init(delete: T) { |
||||
self.deletes = [delete] |
||||
} |
||||
|
||||
func addInsert(_ instance: T) { |
||||
self.inserts.append(instance) |
||||
} |
||||
func addUpdate(_ instance: T) { |
||||
self.updates.append(instance) |
||||
} |
||||
func addDelete(_ instance: T) { |
||||
self.deletes.append(instance) |
||||
} |
||||
} |
||||
|
||||
extension SyncedCollection: RandomAccessCollection { |
||||
|
||||
public var startIndex: Int { return self.collection.items.startIndex } |
||||
|
||||
public var endIndex: Int { return self.collection.items.endIndex } |
||||
|
||||
public func index(after i: Int) -> Int { |
||||
return self.collection.items.index(after: i) |
||||
} |
||||
|
||||
public subscript(index: Int) -> T { |
||||
get { |
||||
return self.collection.items[index] |
||||
} |
||||
set(newValue) { |
||||
self.collection.update(newValue, index: index, actionOption: .standard) |
||||
} |
||||
} |
||||
} |
||||
@ -1,57 +0,0 @@ |
||||
// |
||||
// SyncedStorable.swift |
||||
// LeStorage |
||||
// |
||||
// Created by Laurent Morvillier on 11/10/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
public enum SharingStatus: Int, Codable { |
||||
case shared = 1 |
||||
case granted |
||||
} |
||||
|
||||
public protocol SyncedStorable: Storable { |
||||
|
||||
var lastUpdate: Date { get set } |
||||
var sharing: SharingStatus? { get set } |
||||
|
||||
init() |
||||
|
||||
/// Returns HTTP methods that do not need to pass the token to the request |
||||
static func tokenExemptedMethods() -> [HTTPMethod] |
||||
|
||||
/// Returns whether we should copy the server response into the local instance |
||||
static var copyServerResponse: Bool { get } |
||||
|
||||
} |
||||
|
||||
protocol URLParameterConvertible { |
||||
func queryParameters(storeCenter: StoreCenter) -> [String : String] |
||||
} |
||||
|
||||
public protocol SideStorable { |
||||
var storeId: String? { get set } |
||||
} |
||||
|
||||
extension Storable { |
||||
|
||||
func getStoreId() -> String? { |
||||
if let alt = self as? SideStorable { |
||||
return alt.storeId |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
} |
||||
|
||||
public extension SyncedStorable { |
||||
|
||||
func copy() -> Self { |
||||
let copy = Self() |
||||
copy.copy(from: self) |
||||
return copy |
||||
} |
||||
|
||||
} |
||||
@ -1,50 +0,0 @@ |
||||
// |
||||
// ClassLoader.swift |
||||
// LeStorage |
||||
// |
||||
// Created by Laurent Morvillier on 22/11/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
class ClassLoader { |
||||
static var classCache: [String : AnyClass] = [:] |
||||
|
||||
static func getClass(_ className: String, classProject: String? = nil) -> AnyClass? { |
||||
if let cachedClass = classCache[className] { |
||||
return cachedClass |
||||
} |
||||
|
||||
if let bundleName = Bundle.main.infoDictionary?["CFBundleName"] as? String { |
||||
let sanitizedBundleName = bundleName.replacingOccurrences(of: " ", with: "_") |
||||
let fullName = "\(sanitizedBundleName).\(className)" |
||||
if let projectClass = _getClass(fullName) { |
||||
return projectClass |
||||
} |
||||
} |
||||
|
||||
if let classProject { |
||||
let sanitizedBundleName = classProject.replacingOccurrences(of: " ", with: "_") |
||||
let fullName = "\(sanitizedBundleName).\(className)" |
||||
if let projectClass = _getClass(fullName) { |
||||
return projectClass |
||||
} |
||||
} |
||||
|
||||
let leStorageClassName = "LeStorage.\(className)" |
||||
if let projectClass = _getClass(leStorageClassName) { |
||||
return projectClass |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
static func _getClass(_ className: String) -> AnyClass? { |
||||
if let loadedClass = NSClassFromString(className) { |
||||
classCache[className] = loadedClass |
||||
return loadedClass |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
} |
||||
@ -1,33 +0,0 @@ |
||||
// |
||||
// Date+Extensions.swift |
||||
// LeStorage |
||||
// |
||||
// Created by Laurent Morvillier on 09/10/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
extension Date { |
||||
|
||||
static var iso8601Formatter: ISO8601DateFormatter = { |
||||
let iso8601Formatter = ISO8601DateFormatter() |
||||
iso8601Formatter.timeZone = TimeZone(abbreviation: "CET") |
||||
iso8601Formatter.formatOptions = [.withInternetDateTime, .withTimeZone] |
||||
return iso8601Formatter |
||||
}() |
||||
|
||||
public static var iso8601FractionalFormatter: ISO8601DateFormatter = { |
||||
let iso8601Formatter = ISO8601DateFormatter() |
||||
iso8601Formatter.timeZone = TimeZone(abbreviation: "CET") |
||||
iso8601Formatter.formatOptions = [.withInternetDateTime, .withTimeZone, .withFractionalSeconds] |
||||
return iso8601Formatter |
||||
}() |
||||
|
||||
public static var microSecondFormatter: DateFormatter = { |
||||
let formatter = DateFormatter() |
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'" // puts 000 for the last decimals |
||||
formatter.timeZone = TimeZone(abbreviation: "UTC") |
||||
return formatter |
||||
}() |
||||
|
||||
} |
||||
@ -1,26 +0,0 @@ |
||||
// |
||||
// Dictionary+Extensions.swift |
||||
// LeStorage |
||||
// |
||||
// Created by Laurent Morvillier on 03/12/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
extension Dictionary where Key == String, Value == String { |
||||
|
||||
func toQueryString() -> String { |
||||
guard !self.isEmpty else { |
||||
return "" |
||||
} |
||||
|
||||
let pairs = self.map { key, value in |
||||
let escapedKey = key.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? key |
||||
let escapedValue = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value |
||||
return "\(escapedKey)=\(escapedValue)" |
||||
} |
||||
|
||||
return "?" + pairs.joined(separator: "&") |
||||
} |
||||
|
||||
} |
||||
@ -1,12 +0,0 @@ |
||||
// |
||||
// Formatter.swift |
||||
// LeStorage |
||||
// |
||||
// Created by Laurent Morvillier on 30/10/2024. |
||||
// |
||||
|
||||
class Formatter { |
||||
|
||||
static let number: NumberFormatter = NumberFormatter() |
||||
|
||||
} |
||||
@ -1,44 +0,0 @@ |
||||
// |
||||
// MockKeychainStore.swift |
||||
// LeStorage |
||||
// |
||||
// Created by Laurent Morvillier on 17/04/2025. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
class TokenStore: MicroStorable { |
||||
required init() { |
||||
|
||||
} |
||||
var token: String? |
||||
} |
||||
|
||||
class MockKeychainStore: MicroStorage<TokenStore>, KeychainService { |
||||
|
||||
let key = "store" |
||||
|
||||
func add(username: String, value: String) throws { |
||||
try self.add(value: value) |
||||
} |
||||
|
||||
func add(value: String) throws { |
||||
self.update { tokenStore in |
||||
tokenStore.token = value |
||||
} |
||||
} |
||||
|
||||
func getValue() throws -> String { |
||||
if let value = self.item.token { |
||||
return value |
||||
} |
||||
throw KeychainError.keychainItemNotFound(serverId: "mock") |
||||
} |
||||
|
||||
func deleteValue() throws { |
||||
self.update { tokenStore in |
||||
tokenStore.token = nil |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -1,17 +0,0 @@ |
||||
// |
||||
// String+Extensions.swift |
||||
// LeStorage |
||||
// |
||||
// Created by Laurent Morvillier on 19/05/2025. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
public extension String { |
||||
|
||||
static func random(length: Int = 10) -> String { |
||||
let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" |
||||
return String((0..<length).map{ _ in letters.randomElement()! }) |
||||
} |
||||
|
||||
} |
||||
@ -1,24 +0,0 @@ |
||||
// |
||||
// UIDevice+Extensions.swift |
||||
// LeStorage |
||||
// |
||||
// Created by Laurent Morvillier on 20/03/2025. |
||||
// |
||||
|
||||
import Foundation |
||||
import UIKit |
||||
|
||||
extension UIDevice { |
||||
|
||||
func deviceModel() -> String { |
||||
var systemInfo = utsname() |
||||
uname(&systemInfo) |
||||
let modelCode = withUnsafePointer(to: &systemInfo.machine) { |
||||
$0.withMemoryRebound(to: CChar.self, capacity: 1) { |
||||
ptr in String(validatingUTF8: ptr) |
||||
} |
||||
} |
||||
return modelCode ?? "unknown" |
||||
} |
||||
|
||||
} |
||||
@ -1,39 +0,0 @@ |
||||
// |
||||
// URLManager.swift |
||||
// LeStorage |
||||
// |
||||
// Created by Laurent Morvillier on 18/11/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
struct URLManager { |
||||
|
||||
var secureScheme: Bool |
||||
var domain: String |
||||
private let apiPath: String = "roads" |
||||
|
||||
var api: String { |
||||
return "\(self.httpScheme)\(self.domain)/\(self.apiPath)/" |
||||
} |
||||
|
||||
func websocket(userId: String) -> String { |
||||
return "\(self.wsScheme)\(self.domain)/ws/user/\(userId)/" |
||||
} |
||||
|
||||
var httpScheme: String { |
||||
if self.secureScheme { |
||||
return "https://" |
||||
} else { |
||||
return "http://" |
||||
} |
||||
} |
||||
|
||||
var wsScheme: String { |
||||
if self.secureScheme { |
||||
return "wss://" |
||||
} else { |
||||
return "ws://" |
||||
} |
||||
} |
||||
} |
||||
@ -1,151 +0,0 @@ |
||||
// |
||||
// WebSocketManager.swift |
||||
// WebSocketTest |
||||
// |
||||
// Created by Laurent Morvillier on 30/08/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
import SwiftUI |
||||
import Combine |
||||
|
||||
class WebSocketManager: ObservableObject { |
||||
|
||||
fileprivate(set) var storeCenter: StoreCenter |
||||
|
||||
fileprivate var _webSocketTask: URLSessionWebSocketTask? |
||||
fileprivate var _timer: Timer? |
||||
fileprivate var _url: String |
||||
|
||||
fileprivate var _reconnectAttempts = 0 |
||||
fileprivate var _failure = false |
||||
fileprivate var _error: Error? = nil |
||||
fileprivate var _pingOk = false |
||||
|
||||
init(storeCenter: StoreCenter, urlString: String) { |
||||
self.storeCenter = storeCenter |
||||
self._url = urlString |
||||
_setupWebSocket() |
||||
} |
||||
|
||||
deinit { |
||||
disconnect() |
||||
} |
||||
|
||||
private func _setupWebSocket() { |
||||
|
||||
// guard let url = URL(string: "ws://127.0.0.1:8000/ws/user/test/") else { |
||||
guard let url = URL(string: self._url) else { |
||||
Logger.w("Invalid URL: \(self._url)") |
||||
return |
||||
} |
||||
|
||||
Logger.log(">>> configure websockets with: \(url)") |
||||
|
||||
let session = URLSession(configuration: .default) |
||||
_webSocketTask = session.webSocketTask(with: url) |
||||
_webSocketTask?.resume() |
||||
|
||||
self._receiveMessage() |
||||
|
||||
// Setup a ping timer to keep the connection alive |
||||
self._timer?.invalidate() |
||||
_timer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { _ in |
||||
self._ping() |
||||
} |
||||
} |
||||
|
||||
private func _receiveMessage() { |
||||
_webSocketTask?.receive { result in |
||||
switch result { |
||||
case .failure(let error): |
||||
self._failure = true |
||||
self._error = error |
||||
print("Error in receiving message: \(error)") |
||||
self._handleWebSocketError(error) |
||||
case .success(let message): |
||||
self._failure = false |
||||
self._error = nil |
||||
self._reconnectAttempts = 0 |
||||
switch message { |
||||
case .string(let deviceId): |
||||
// print("device id = \(StoreCenter.main.deviceId()), origin id: \(deviceId)") |
||||
guard self.storeCenter.deviceId() != deviceId else { |
||||
break |
||||
} |
||||
|
||||
Task { |
||||
await self.storeCenter.synchronizeLastUpdates() |
||||
} |
||||
|
||||
case .data(let data): |
||||
print("Received binary message: \(data)") |
||||
break |
||||
@unknown default: |
||||
print("received other = \(message)") |
||||
break |
||||
} |
||||
|
||||
self._receiveMessage() |
||||
} |
||||
} |
||||
} |
||||
|
||||
private func _handleWebSocketError(_ error: Error) { |
||||
// print("WebSocket error: \(error)") |
||||
|
||||
// up to 10 seconds of reconnection |
||||
let delay = min(Double(self._reconnectAttempts), 10.0) |
||||
self._reconnectAttempts += 1 |
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in |
||||
guard let self = self else { return } |
||||
Logger.log("Attempting to reconnect... (Attempt #\(self._reconnectAttempts))") |
||||
_setupWebSocket() |
||||
} |
||||
} |
||||
|
||||
func send(_ message: String) { |
||||
self._webSocketTask?.send(.string(message)) { error in |
||||
if let error = error { |
||||
print("Error in sending message: \(error)") |
||||
} |
||||
} |
||||
} |
||||
|
||||
private func _ping() { |
||||
self._webSocketTask?.sendPing { error in |
||||
|
||||
if let error: NSError = error as NSError?, |
||||
error.domain == NSPOSIXErrorDomain && error.code == 57 { |
||||
Logger.log("ping sent. Error?: \(error.localizedDescription) ") |
||||
self._setupWebSocket() |
||||
self._pingOk = false |
||||
} else { |
||||
self._pingOk = true |
||||
} |
||||
} |
||||
} |
||||
|
||||
func disconnect() { |
||||
self._webSocketTask?.cancel(with: .goingAway, reason: nil) |
||||
self._timer?.invalidate() |
||||
} |
||||
|
||||
var pingStatus: Bool { |
||||
return self._pingOk |
||||
} |
||||
|
||||
var failure: Bool { |
||||
return self._failure |
||||
} |
||||
|
||||
var error: Error? { |
||||
return self._error |
||||
} |
||||
|
||||
var reconnectAttempts: Int { |
||||
return self._reconnectAttempts |
||||
} |
||||
|
||||
} |
||||
@ -1,133 +0,0 @@ |
||||
// |
||||
// ApiCallTests.swift |
||||
// LeStorageTests |
||||
// |
||||
// Created by Laurent Morvillier on 15/02/2025. |
||||
// |
||||
|
||||
import Testing |
||||
@testable import LeStorage |
||||
|
||||
class Thing: SyncedModelObject, SyncedStorable, URLParameterConvertible { |
||||
|
||||
override required init() { |
||||
super.init() |
||||
} |
||||
|
||||
static func resourceName() -> String { return "thing" } |
||||
static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { return [] } |
||||
static func filterByStoreIdentifier() -> Bool { return false } |
||||
static var copyServerResponse: Bool = false |
||||
static func storeParent() -> Bool { return false } |
||||
|
||||
var id: String = Store.randomId() |
||||
var name: String = "" |
||||
|
||||
init(name: String) { |
||||
self.name = name |
||||
super.init() |
||||
} |
||||
|
||||
required init(from decoder: any Decoder) throws { |
||||
fatalError("init(from:) has not been implemented") |
||||
} |
||||
func copy(from other: any LeStorage.Storable) { |
||||
|
||||
} |
||||
|
||||
static func relationships() -> [LeStorage.Relationship] { return [] } |
||||
|
||||
func queryParameters(storeCenter: StoreCenter) -> [String : String] { |
||||
return ["yeah?" : "god!"] |
||||
} |
||||
|
||||
} |
||||
|
||||
struct ApiCallTests { |
||||
|
||||
@Test func testApiCallProvisioning1() async throws { |
||||
let collection = ApiCallCollection<Thing>(storeCenter: StoreCenter.main) |
||||
|
||||
let thing = Thing(name: "yeah") |
||||
|
||||
let _ = try await collection.sendInsertion(thing) |
||||
|
||||
await #expect(collection.items.count == 1) |
||||
if let apiCall = await collection.items.first { |
||||
#expect(apiCall.method == .post) |
||||
} |
||||
|
||||
thing.name = "woo" |
||||
let _ = try await collection.sendUpdate(thing) |
||||
await #expect(collection.items.count == 2) // one post and one put |
||||
if let apiCall = await collection.items.first { |
||||
#expect(apiCall.method == .post) |
||||
} |
||||
if let apiCall = await collection.items.last { |
||||
#expect(apiCall.method == .put) |
||||
} |
||||
|
||||
let _ = try await collection.sendDeletion(thing) |
||||
await #expect(collection.items.count == 1) |
||||
} |
||||
|
||||
@Test func testApiCallProvisioning2() async throws { |
||||
let collection = ApiCallCollection<Thing>(storeCenter: StoreCenter.main) |
||||
|
||||
let thing = Thing(name: "yeah") |
||||
|
||||
let _ = try await collection.sendUpdate(thing) |
||||
|
||||
await #expect(collection.items.count == 1) |
||||
if let apiCall = await collection.items.first { |
||||
#expect(apiCall.method == .put) |
||||
} |
||||
|
||||
thing.name = "woo" |
||||
let _ = try await collection.sendUpdate(thing) |
||||
let _ = try await collection.sendUpdate(thing) |
||||
let _ = try await collection.sendUpdate(thing) |
||||
await #expect(collection.items.count == 1) |
||||
if let apiCall = await collection.items.first { |
||||
#expect(apiCall.method == .put) |
||||
} |
||||
|
||||
let _ = try await collection.sendDeletion(thing) |
||||
await #expect(collection.items.count == 1) |
||||
} |
||||
|
||||
@Test func testApiCallProvisioning3() async throws { |
||||
let collection = ApiCallCollection<Thing>(storeCenter: StoreCenter.main) |
||||
|
||||
let thing = Thing(name: "yeah") |
||||
|
||||
let _ = try await collection.sendDeletion(thing) |
||||
await #expect(collection.items.count == 1) |
||||
let _ = try await collection.sendDeletion(thing) |
||||
await #expect(collection.items.count == 1) |
||||
let _ = try await collection.sendDeletion(thing) |
||||
await #expect(collection.items.count == 1) |
||||
} |
||||
|
||||
@Test func testGetProvisioning() async throws { |
||||
let collection = ApiCallCollection<Thing>(storeCenter: StoreCenter.main) |
||||
|
||||
try await collection.sendGetRequest(storeId: "1") |
||||
await #expect(collection.items.count == 1) |
||||
try await collection.sendGetRequest(storeId: "1") |
||||
await #expect(collection.items.count == 1) |
||||
|
||||
try await collection.sendGetRequest(storeId: "2") |
||||
await #expect(collection.items.count == 2) |
||||
|
||||
try await collection.sendGetRequest(instance: Thing(name: "man!")) |
||||
await #expect(collection.items.count == 3) |
||||
|
||||
try await collection.sendGetRequest(storeId: nil) |
||||
await #expect(collection.items.count == 4) |
||||
try await collection.sendGetRequest(storeId: nil) |
||||
await #expect(collection.items.count == 4) |
||||
|
||||
} |
||||
|
||||
} |
||||
@ -1,87 +0,0 @@ |
||||
// |
||||
// CollectionsTests.swift |
||||
// LeStorageTests |
||||
// |
||||
// Created by Laurent Morvillier on 15/10/2024. |
||||
// |
||||
|
||||
import Testing |
||||
@testable import LeStorage |
||||
|
||||
class Car: ModelObject, Storable { |
||||
|
||||
var id: String = Store.randomId() |
||||
|
||||
static func resourceName() -> String { return "car" } |
||||
func copy(from other: any LeStorage.Storable) { |
||||
|
||||
} |
||||
static func relationships() -> [LeStorage.Relationship] { return [] } |
||||
static func storeParent() -> Bool { return false } |
||||
|
||||
} |
||||
|
||||
class Boat: ModelObject, SyncedStorable { |
||||
|
||||
var id: String = Store.randomId() |
||||
var lastUpdate: Date = Date() |
||||
var sharing: LeStorage.SharingStatus? |
||||
|
||||
override required init() { |
||||
super.init() |
||||
} |
||||
|
||||
static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { return [] } |
||||
static func resourceName() -> String { return "boat" } |
||||
static var copyServerResponse: Bool = false |
||||
static func storeParent() -> Bool { return false } |
||||
|
||||
var storeId: String? { return nil } |
||||
func copy(from other: any LeStorage.Storable) { |
||||
|
||||
} |
||||
static func relationships() -> [LeStorage.Relationship] { return [] } |
||||
|
||||
} |
||||
|
||||
struct CollectionsTests { |
||||
|
||||
var cars: StoredCollection<Car> |
||||
var boats: SyncedCollection<Boat> |
||||
|
||||
init() async { |
||||
cars = await StoreCenter.main.mainStore.asyncLoadingStoredCollection(inMemory: true) |
||||
boats = await StoreCenter.main.mainStore.asyncLoadingSynchronizedCollection(inMemory: true) |
||||
|
||||
} |
||||
|
||||
@Test func testLoading() async { |
||||
#expect(self.cars.hasLoaded) |
||||
#expect(self.boats.hasLoaded) |
||||
} |
||||
|
||||
@Test func differentiationTest() async throws { |
||||
|
||||
// Cars |
||||
#expect(cars.count == 0) |
||||
cars.addOrUpdate(instance: Car()) |
||||
#expect(cars.count == 1) |
||||
|
||||
// Boats |
||||
|
||||
#expect(boats.count == 0) |
||||
let oldApiCallCount = await StoreCenter.main.apiCallCount(type: Boat.self) |
||||
#expect(oldApiCallCount == 0) |
||||
|
||||
boats.addOrUpdate(instance: Boat()) |
||||
#expect(boats.count == 1) |
||||
|
||||
// Cars and boats |
||||
cars.reset() |
||||
boats.reset() |
||||
#expect(cars.count == 0) |
||||
#expect(boats.count == 0) |
||||
|
||||
} |
||||
|
||||
} |
||||
@ -1,110 +0,0 @@ |
||||
// |
||||
// LeStorageTests.swift |
||||
// LeStorageTests |
||||
// |
||||
// Created by Laurent Morvillier on 18/09/2024. |
||||
// |
||||
|
||||
import Testing |
||||
@testable import LeStorage |
||||
|
||||
class IntObject: ModelObject, Storable { |
||||
|
||||
static func resourceName() -> String { "int" } |
||||
static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { [] } |
||||
static func storeParent() -> Bool { return false } |
||||
|
||||
var id: Int |
||||
var name: String |
||||
|
||||
init(id: Int, name: String) { |
||||
self.id = id |
||||
self.name = name |
||||
} |
||||
func copy(from other: any LeStorage.Storable) { |
||||
} |
||||
|
||||
static func relationships() -> [LeStorage.Relationship] { |
||||
return [] |
||||
} |
||||
|
||||
} |
||||
|
||||
class StringObject: ModelObject, Storable { |
||||
|
||||
static func resourceName() -> String { "string" } |
||||
static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { [] } |
||||
static func storeParent() -> Bool { return false } |
||||
|
||||
var id: String |
||||
var name: String |
||||
|
||||
init(id: String, name: String) { |
||||
self.id = id |
||||
self.name = name |
||||
} |
||||
func copy(from other: any LeStorage.Storable) { |
||||
} |
||||
|
||||
static func relationships() -> [LeStorage.Relationship] { |
||||
return [] |
||||
} |
||||
|
||||
} |
||||
|
||||
struct IdentifiableTests { |
||||
|
||||
let intObjects: StoredCollection<IntObject> |
||||
let stringObjects: StoredCollection<StringObject> |
||||
|
||||
init() { |
||||
let dir = "test_" + String.random() |
||||
let storeCenter: StoreCenter = StoreCenter(directoryName:dir) |
||||
intObjects = storeCenter.mainStore.registerCollection() |
||||
stringObjects = storeCenter.mainStore.registerCollection() |
||||
} |
||||
|
||||
func ensureCollectionLoaded(_ collection: any SomeCollection) async throws { |
||||
// Wait for the collection to finish loading |
||||
// Adjust the timeout as needed |
||||
let timeout = 5.0 // seconds |
||||
let startTime = Date() |
||||
|
||||
while !collection.hasLoaded { |
||||
// Check for timeout |
||||
if Date().timeIntervalSince(startTime) > timeout { |
||||
throw Error("Collection loading timed out") |
||||
} |
||||
// Wait a bit before checking again |
||||
try await Task.sleep(for: .milliseconds(100)) |
||||
} |
||||
collection.reset() |
||||
} |
||||
|
||||
@Test func testIntIds() async throws { |
||||
|
||||
try await ensureCollectionLoaded(self.intObjects) |
||||
|
||||
let int = IntObject(id: 12, name: "test") |
||||
self.intObjects.addOrUpdate(instance: int) |
||||
|
||||
if let search = intObjects.findById(12) { |
||||
#expect(search.id == 12) |
||||
} else { |
||||
Issue.record("object is missing") |
||||
} |
||||
} |
||||
|
||||
@Test func testStringIds() async throws { |
||||
|
||||
try await ensureCollectionLoaded(self.stringObjects) |
||||
let string = StringObject(id: "coco", name: "name") |
||||
self.stringObjects.addOrUpdate(instance: string) |
||||
|
||||
if let search = stringObjects.findById("coco") { |
||||
#expect(search.id == "coco") |
||||
} else { |
||||
Issue.record("object is missing") |
||||
} |
||||
} |
||||
} |
||||
@ -1,141 +0,0 @@ |
||||
// |
||||
// StoredCollectionTests.swift |
||||
// LeStorageTests |
||||
// |
||||
// Created by Laurent Morvillier on 16/10/2024. |
||||
// |
||||
|
||||
import Testing |
||||
@testable import LeStorage |
||||
|
||||
struct Error: Swift.Error, CustomStringConvertible { |
||||
let description: String |
||||
|
||||
init(_ description: String) { |
||||
self.description = description |
||||
} |
||||
} |
||||
|
||||
struct StoredCollectionTests { |
||||
|
||||
var collection: StoredCollection<MockStorable> |
||||
|
||||
init() async { |
||||
collection = await StoreCenter.main.mainStore.asyncLoadingStoredCollection(inMemory: true) |
||||
collection.reset() |
||||
} |
||||
|
||||
@Test func testInitialization() async throws { |
||||
#expect(self.collection.hasLoaded) |
||||
#expect(collection.items.count == 0) |
||||
} |
||||
|
||||
@Test func testAddOrUpdate() async throws { |
||||
let item = MockStorable(id: "1", name: "Test") |
||||
collection.addOrUpdate(instance: item) |
||||
|
||||
#expect(collection.items.count == 1) |
||||
if let first = collection.items.first { |
||||
#expect(first.id == "1") |
||||
} else { |
||||
Issue.record("missing record") |
||||
} |
||||
|
||||
} |
||||
|
||||
@Test func testDelete() async throws { |
||||
let item = MockStorable(id: "1", name: "Test") |
||||
collection.addOrUpdate(instance: item) |
||||
#expect(collection.items.count == 1) |
||||
|
||||
collection.delete(instance: item) |
||||
#expect(collection.items.isEmpty) |
||||
} |
||||
|
||||
@Test func testFindById() async throws { |
||||
let item = MockStorable(id: "1", name: "Test") |
||||
collection.addOrUpdate(instance: item) |
||||
|
||||
if let foundItem = collection.findById("1") { |
||||
#expect(foundItem.id == "1") |
||||
} else { |
||||
Issue.record("missing item") |
||||
} |
||||
} |
||||
|
||||
@Test func testDeleteById() async throws { |
||||
let item = MockStorable(id: "1", name: "Test") |
||||
collection.addOrUpdate(instance: item) |
||||
|
||||
collection.deleteByStringId("1") |
||||
let search = collection.findById("1") |
||||
#expect(search == nil) |
||||
} |
||||
|
||||
@Test func testAddOrUpdateMultiple() async throws { |
||||
let items = [ |
||||
MockStorable(id: "1", name: "Test1"), |
||||
MockStorable(id: "2", name: "Test2"), |
||||
] |
||||
|
||||
collection.addOrUpdate(contentOfs: items) |
||||
#expect(collection.items.count == 2) |
||||
} |
||||
|
||||
@Test func testDeleteAll() async throws { |
||||
let items = [ |
||||
MockStorable(id: "1", name: "Test1"), |
||||
MockStorable(id: "2", name: "Test2"), |
||||
] |
||||
|
||||
collection.addOrUpdate(contentOfs: items) |
||||
#expect(collection.items.count == 2) |
||||
|
||||
collection.clear() |
||||
#expect(collection.items.isEmpty) |
||||
} |
||||
|
||||
@Test func testRandomAccessCollection() async throws { |
||||
let items = [ |
||||
MockStorable(id: "1", name: "Test1"), |
||||
MockStorable(id: "2", name: "Test2"), |
||||
MockStorable(id: "3", name: "Test3"), |
||||
] |
||||
|
||||
collection.addOrUpdate(contentOfs: items) |
||||
|
||||
#expect(collection.startIndex == 0) |
||||
#expect(collection.endIndex == 3) |
||||
|
||||
if collection.count > 2 { |
||||
#expect(collection[1].name == "Test2") |
||||
} else { |
||||
Issue.record("count not good") |
||||
} |
||||
|
||||
} |
||||
} |
||||
|
||||
// Mock Storable for testing purposes |
||||
class MockStorable: ModelObject, Storable { |
||||
|
||||
var id: String = Store.randomId() |
||||
var name: String |
||||
|
||||
init(id: String, name: String) { |
||||
self.id = id |
||||
self.name = name |
||||
} |
||||
|
||||
static func resourceName() -> String { |
||||
return "mocks" |
||||
} |
||||
func copy(from other: any LeStorage.Storable) { |
||||
} |
||||
|
||||
static func relationships() -> [LeStorage.Relationship] { |
||||
return [] |
||||
} |
||||
static func storeParent() -> Bool { return false } |
||||
|
||||
} |
||||
Loading…
Reference in new issue