Compare commits
81 Commits
@ -0,0 +1,11 @@ |
||||
|
||||
### 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 |
||||
|
||||
@ -0,0 +1,34 @@ |
||||
// |
||||
// 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 |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,83 @@ |
||||
// |
||||
// 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 |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,66 @@ |
||||
// |
||||
// 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) |
||||
} |
||||
|
||||
} |
||||
|
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,64 @@ |
||||
// |
||||
// 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,342 +0,0 @@ |
||||
// |
||||
// StoredCollection.swift |
||||
// LeStorage |
||||
// |
||||
// Created by Laurent Morvillier on 11/10/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
extension StoredCollection: SomeSyncedCollection where T : SyncedStorable { |
||||
|
||||
/// Migrates if necessary and asynchronously decodes the json file |
||||
func load() async { |
||||
do { |
||||
if self.inMemory { |
||||
try await self.loadDataFromServerIfAllowed() |
||||
} else { |
||||
try self.loadFromFile() |
||||
} |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
|
||||
/// Loads the collection using the server data only if the collection file doesn't exists |
||||
func loadCollectionsFromServerIfNoFile() async throws { |
||||
let fileURL: URL = try self.store.fileURL(type: T.self) |
||||
if !FileManager.default.fileExists(atPath: fileURL.path()) { |
||||
try await self.loadDataFromServerIfAllowed() |
||||
} |
||||
} |
||||
|
||||
func loadDataFromServerIfAllowed() async throws { |
||||
try await self.loadDataFromServerIfAllowed(clear: false) |
||||
} |
||||
|
||||
/// Retrieves the data from the server and loads it into the items array |
||||
public func loadDataFromServerIfAllowed(clear: Bool = false) async throws { |
||||
guard !(self is StoredSingleton<T>) else { |
||||
throw StoreError.cannotSyncCollection(name: self.resourceName) |
||||
} |
||||
do { |
||||
try await StoreCenter.main.sendGetRequest(T.self, storeId: self.storeId) |
||||
|
||||
|
||||
|
||||
// let items: [T] = try await self.store.getItems() |
||||
// if items.count > 0 { |
||||
// DispatchQueue.main.async { |
||||
// if clear { |
||||
// self.clear() |
||||
// } |
||||
// self.addOrUpdateNoSync(contentOfs: items) |
||||
// } |
||||
// } |
||||
// self.setAsLoaded() |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
|
||||
/// 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 StoredCollection 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 |
||||
} |
||||
|
||||
DispatchQueue.main.async { |
||||
if let localInstance = self.findById(serverInstance.id) { |
||||
localInstance.copy(from: serverInstance) |
||||
self.setChanged() |
||||
// let modified = localInstance.copyFromServerInstance(serverInstance) |
||||
// if modified { |
||||
// self.setChanged() |
||||
// } |
||||
} |
||||
} |
||||
} |
||||
|
||||
// MARK: - Basic operations with sync |
||||
|
||||
/// Adds or update an instance and writes |
||||
public func addOrUpdate(instance: T) { |
||||
// Logger.log("\(T.resourceName()) : one item") |
||||
defer { |
||||
self.setChanged() |
||||
} |
||||
|
||||
instance.lastUpdate = Date() |
||||
if let index = self.items.firstIndex(where: { $0.id == instance.id }) { |
||||
self.updateItem(instance, index: index) |
||||
self._sendUpdate(instance) |
||||
} else { |
||||
self.addItem(instance: instance) |
||||
self._sendInsertion(instance) |
||||
} |
||||
} |
||||
|
||||
/// Adds or update a sequence and writes |
||||
public func addOrUpdate(contentOfs sequence: any Sequence<T>) { |
||||
// Logger.log("\(T.resourceName()) : \(sequence.underestimatedCount) items") |
||||
defer { |
||||
self.setChanged() |
||||
} |
||||
|
||||
let date = Date() |
||||
let batch = OperationBatch<T>() |
||||
|
||||
for instance in sequence { |
||||
instance.lastUpdate = date |
||||
if let index = self.items.firstIndex(where: { $0.id == instance.id }) { |
||||
self.updateItem(instance, index: index) |
||||
batch.addUpdate(instance) |
||||
// self._sendUpdateIfNecessary(instance) |
||||
} else { // insert |
||||
self.addItem(instance: instance) |
||||
batch.addInsert(instance) |
||||
// self._sendInsertionIfNecessary(instance) |
||||
} |
||||
} |
||||
|
||||
self._sendOperationBatch(batch) |
||||
|
||||
} |
||||
|
||||
/// Proceeds to delete all instance of the collection, properly cleaning up dependencies and sending API calls |
||||
public func deleteAll() throws { |
||||
self.delete(contentOfs: self.items) |
||||
} |
||||
|
||||
/// 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>) { |
||||
|
||||
defer { |
||||
self.setChanged() |
||||
} |
||||
|
||||
guard sequence.isNotEmpty else { return } |
||||
|
||||
for instance in sequence { |
||||
// print(">>> SEND DELETE for \(instance.id)") |
||||
self.deleteItem(instance) |
||||
StoreCenter.main.createDeleteLog(instance) |
||||
} |
||||
|
||||
let batch = OperationBatch<T>() |
||||
batch.deletes = Array(sequence) |
||||
self._sendOperationBatch(batch) |
||||
} |
||||
|
||||
/// Deletes an instance and writes |
||||
public func delete(instance: T) { |
||||
defer { |
||||
self.setChanged() |
||||
} |
||||
self._deleteNoWrite(instance: instance) |
||||
} |
||||
|
||||
/// Deletes an instance without writing, logs the operation and sends an API call |
||||
fileprivate func _deleteNoWrite(instance: T) { |
||||
self.deleteItem(instance) |
||||
StoreCenter.main.createDeleteLog(instance) |
||||
|
||||
self._sendDeletion(instance) |
||||
} |
||||
|
||||
public func deleteDependencies(_ items: any RandomAccessCollection<T>) { |
||||
guard items.isNotEmpty else { return } |
||||
delete(contentOfs: items) // MUST NOT ADD "self" before delete, otherwise it will call the delete method of StoredCollection without sync |
||||
} |
||||
|
||||
// MARK: - Basic operations without sync |
||||
|
||||
/// Adds or update an instance without synchronizing it |
||||
func addOrUpdateNoSync(_ instance: T) throws { |
||||
self.addOrUpdateItem(instance: instance) |
||||
} |
||||
|
||||
/// Adds or update a sequence of elements without synchronizing it |
||||
func addOrUpdateNoSync(contentOfs sequence: any Sequence<T>) { |
||||
self.addSequence(sequence) |
||||
} |
||||
|
||||
/// Deletes the instance in the collection without synchronization |
||||
func deleteNoSync(instance: T) throws { |
||||
defer { |
||||
self.setChanged() |
||||
} |
||||
self.deleteItem(instance) |
||||
} |
||||
|
||||
/// Deletes the instance in the collection without synchronization |
||||
func deleteByStringIdNoSync(_ id: String) { |
||||
defer { |
||||
self.setChanged() |
||||
} |
||||
let realId = T.buildRealId(id: id) |
||||
if let instance = self.findById(realId) { |
||||
self.deleteItem(instance) |
||||
} |
||||
} |
||||
|
||||
// MARK: - Send requests |
||||
|
||||
fileprivate func _sendInsertion(_ instance: T) { |
||||
self._sendOperationBatch(OperationBatch(insert: instance)) |
||||
} |
||||
|
||||
fileprivate func _sendUpdate(_ instance: T) { |
||||
self._sendOperationBatch(OperationBatch(update: instance)) |
||||
} |
||||
|
||||
fileprivate func _sendDeletion(_ instance: T) { |
||||
self._sendOperationBatch(OperationBatch(delete: instance)) |
||||
} |
||||
|
||||
fileprivate func _sendOperationBatch(_ batch: OperationBatch<T>) { |
||||
Task { |
||||
do { |
||||
try await StoreCenter.main.sendOperationBatch(batch) |
||||
// let success = try await StoreCenter.main.sendOperationBatch(batch) |
||||
// for item in success { |
||||
// if let data = item.data { |
||||
// self.updateFromServerInstance(data) |
||||
// } |
||||
// } |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
} |
||||
|
||||
/// Sends an insert api call for the provided |
||||
/// Calls copyFromServerInstance on the instance with the result of the HTTP call |
||||
/// - Parameters: |
||||
/// - instance: the object to POST |
||||
// fileprivate func _sendInsertionIfNecessary(_ instance: T) { |
||||
// |
||||
// Task { |
||||
// do { |
||||
// if let result = try await self.store.sendInsertion(instance) { |
||||
// self.updateFromServerInstance(result) |
||||
// } |
||||
// } catch { |
||||
// Logger.error(error) |
||||
// } |
||||
// } |
||||
// } |
||||
// |
||||
// /// Sends an update api call for the provided [instance] |
||||
// /// - Parameters: |
||||
// /// - instance: the object to PUT |
||||
// fileprivate func _sendUpdateIfNecessary(_ instance: T) { |
||||
// Task { |
||||
// do { |
||||
// try await self.store.sendUpdate(instance) |
||||
// } catch { |
||||
// Logger.error(error) |
||||
// } |
||||
// } |
||||
// } |
||||
// |
||||
// /// Sends an delete api call for the provided [instance] |
||||
// /// - Parameters: |
||||
// /// - instance: the object to DELETE |
||||
// fileprivate func _sendDeletionIfNecessary(_ instance: T) { |
||||
// Task { |
||||
// do { |
||||
// try await self.store.sendDeletion(instance) |
||||
// } catch { |
||||
// Logger.error(error) |
||||
// } |
||||
// } |
||||
// } |
||||
|
||||
// MARK: - Synchronization |
||||
|
||||
/// Adds or update an instance if it is newer than the local instance |
||||
func addOrUpdateIfNewer(_ instance: T, shared: Bool) { |
||||
defer { |
||||
self.setChanged() |
||||
} |
||||
|
||||
if let index = self.items.firstIndex(where: { $0.id == instance.id }) { |
||||
let localInstance = self.items[index] |
||||
if instance.lastUpdate > localInstance.lastUpdate { |
||||
self.updateItem(instance, index: index) |
||||
} else { |
||||
print("do not update \(T.resourceName()): \(instance.lastUpdate.timeIntervalSince1970) / local: \(localInstance.lastUpdate.timeIntervalSince1970)") |
||||
} |
||||
} else { // insert |
||||
if shared { |
||||
instance.shared = true |
||||
} |
||||
self.addItem(instance: instance) |
||||
} |
||||
|
||||
} |
||||
|
||||
// MARK: - Migrations |
||||
|
||||
/// Sends a POST request for the instance, and changes the collection to perform a write |
||||
public func writeChangeAndInsertOnServer(instance: T) { |
||||
defer { |
||||
self.setChanged() |
||||
} |
||||
self._sendInsertion(instance) |
||||
} |
||||
|
||||
} |
||||
|
||||
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) |
||||
} |
||||
} |
||||
@ -0,0 +1,478 @@ |
||||
// |
||||
// 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) |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,44 @@ |
||||
// |
||||
// 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 |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,17 @@ |
||||
// |
||||
// 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()! }) |
||||
} |
||||
|
||||
} |
||||
Loading…
Reference in new issue