Compare commits

..

No commits in common. 'main' and 'sync2' have entirely different histories.
main ... sync2

  1. 46
      LeStorage.xcodeproj/project.pbxproj
  2. 2
      LeStorage.xcodeproj/xcshareddata/xcschemes/LeStorage.xcscheme
  3. 2
      LeStorage.xcodeproj/xcshareddata/xcschemes/LeStorageTests.xcscheme
  4. 209
      LeStorage/ApiCallCollection.swift
  5. 11
      LeStorage/CLAUDE.md
  6. 30
      LeStorage/Codables/ApiCall.swift
  7. 23
      LeStorage/Codables/DataAccess.swift
  8. 7
      LeStorage/Codables/DataLog.swift
  9. 16
      LeStorage/Codables/FailedAPICall.swift
  10. 29
      LeStorage/Codables/GetSyncData.swift
  11. 26
      LeStorage/Codables/Log.swift
  12. 34
      LeStorage/Codables/PendingOperation.swift
  13. 13
      LeStorage/Codables/Settings.swift
  14. 83
      LeStorage/Codables/SyncData.swift
  15. 21
      LeStorage/ModelObject.swift
  16. 66
      LeStorage/PendingOperationManager.swift
  17. 13
      LeStorage/Relationship.swift
  18. 142
      LeStorage/Services.swift
  19. 34
      LeStorage/Storable.swift
  20. 318
      LeStorage/Store.swift
  21. 765
      LeStorage/StoreCenter.swift
  22. 64
      LeStorage/StoreLibrary.swift
  23. 342
      LeStorage/StoredCollection+Sync.swift
  24. 489
      LeStorage/StoredCollection.swift
  25. 27
      LeStorage/StoredSingleton.swift
  26. 478
      LeStorage/SyncedCollection.swift
  27. 25
      LeStorage/SyncedStorable.swift
  28. 12
      LeStorage/Utils/ClassLoader.swift
  29. 6
      LeStorage/Utils/Codable+Extensions.swift
  30. 4
      LeStorage/Utils/Collection+Extension.swift
  31. 8
      LeStorage/Utils/Date+Extensions.swift
  32. 9
      LeStorage/Utils/KeychainStore.swift
  33. 44
      LeStorage/Utils/MockKeychainStore.swift
  34. 17
      LeStorage/Utils/String+Extensions.swift
  35. 30
      LeStorage/WebSocketManager.swift
  36. 53
      LeStorageTests/ApiCallTests.swift
  37. 49
      LeStorageTests/CollectionsTests.swift
  38. 58
      LeStorageTests/IdentifiableTests.swift
  39. 95
      LeStorageTests/StoredCollectionTests.swift

@ -8,7 +8,6 @@
/* Begin PBXBuildFile section */
C400D7232CC2AF560092237C /* GetSyncData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C400D7222CC2AF560092237C /* GetSyncData.swift */; };
C40EC3E52D9BDFA3007372D7 /* PendingOperationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40EC3E42D9BDFA3007372D7 /* PendingOperationManager.swift */; };
C425D4392B6D24E1002A7B48 /* LeStorage.docc in Sources */ = {isa = PBXBuildFile; fileRef = C425D4382B6D24E1002A7B48 /* LeStorage.docc */; };
C425D4452B6D24E1002A7B48 /* LeStorage.h in Headers */ = {isa = PBXBuildFile; fileRef = C425D4372B6D24E1002A7B48 /* LeStorage.h */; settings = {ATTRIBUTES = (Public, ); }; };
C425D4582B6D2519002A7B48 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = C425D4572B6D2519002A7B48 /* Store.swift */; };
@ -17,12 +16,7 @@
C45D35912C0A1DB5000F379F /* FailedAPICall.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45D35902C0A1DB5000F379F /* FailedAPICall.swift */; };
C462E0DC2D37B61100F3E6E4 /* Notification+Name.swift in Sources */ = {isa = PBXBuildFile; fileRef = C462E0DB2D37B61100F3E6E4 /* Notification+Name.swift */; };
C467AAE32CD2467500D76CD2 /* Formatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C467AAE22CD2466400D76CD2 /* Formatter.swift */; };
C471F2582DB10649006317F4 /* MockKeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C471F2572DB10649006317F4 /* MockKeychainStore.swift */; };
C48638B32D9BC6A8007E3E06 /* PendingOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C48638B22D9BC6A8007E3E06 /* PendingOperation.swift */; };
C488C8802CCBDC210082001F /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C488C87F2CCBDC210082001F /* NetworkMonitor.swift */; };
C49774DF2DC4B3D7005CD239 /* SyncData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49774DE2DC4B3D7005CD239 /* SyncData.swift */; };
C49779FC2DDB5D89005CD239 /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49779FB2DDB5D89005CD239 /* String+Extensions.swift */; };
C4977BA92DEDFE6D005CD239 /* StoreLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4977BA82DEDFE6D005CD239 /* StoreLibrary.swift */; };
C49B6E502C2089B6002BDE1B /* ApiCallCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */; };
C49EF0242BD6BDC50077B5AA /* FileManager+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */; };
C4A47D4F2B6D280200ADC637 /* StoredCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */; };
@ -48,8 +42,7 @@
C4D477972CB66EEA0077713D /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D477962CB66EEA0077713D /* Date+Extensions.swift */; };
C4D4779D2CB923720077713D /* DataLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D4779C2CB923720077713D /* DataLog.swift */; };
C4D4779F2CB92FD80077713D /* SyncedStorable.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D4779E2CB92FD80077713D /* SyncedStorable.swift */; };
C4D477A12CB9586A0077713D /* SyncedCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D477A02CB9586A0077713D /* SyncedCollection.swift */; };
C4E2A7D12E8D6A4D007E5186 /* CLAUDE.md in Resources */ = {isa = PBXBuildFile; fileRef = C4E2A7D02E8D6A48007E5186 /* CLAUDE.md */; };
C4D477A12CB9586A0077713D /* StoredCollection+Sync.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D477A02CB9586A0077713D /* StoredCollection+Sync.swift */; };
C4FAE69A2CEB84B300790446 /* WebSocketManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FAE6992CEB84B300790446 /* WebSocketManager.swift */; };
C4FAE69C2CEB8E9500790446 /* URLManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FAE69B2CEB8E9500790446 /* URLManager.swift */; };
C4FC2E292C2B2EC30021F3BF /* StoreCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FC2E282C2B2EC30021F3BF /* StoreCenter.swift */; };
@ -68,7 +61,6 @@
/* Begin PBXFileReference section */
C400D7222CC2AF560092237C /* GetSyncData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetSyncData.swift; sourceTree = "<group>"; };
C40EC3E42D9BDFA3007372D7 /* PendingOperationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingOperationManager.swift; sourceTree = "<group>"; };
C425D4342B6D24E1002A7B48 /* LeStorage.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = LeStorage.framework; sourceTree = BUILT_PRODUCTS_DIR; };
C425D4372B6D24E1002A7B48 /* LeStorage.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = LeStorage.h; sourceTree = "<group>"; };
C425D4382B6D24E1002A7B48 /* LeStorage.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = LeStorage.docc; sourceTree = "<group>"; };
@ -78,12 +70,7 @@
C45D35902C0A1DB5000F379F /* FailedAPICall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedAPICall.swift; sourceTree = "<group>"; };
C462E0DB2D37B61100F3E6E4 /* Notification+Name.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Name.swift"; sourceTree = "<group>"; };
C467AAE22CD2466400D76CD2 /* Formatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formatter.swift; sourceTree = "<group>"; };
C471F2572DB10649006317F4 /* MockKeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockKeychainStore.swift; sourceTree = "<group>"; };
C48638B22D9BC6A8007E3E06 /* PendingOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingOperation.swift; sourceTree = "<group>"; };
C488C87F2CCBDC210082001F /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = "<group>"; };
C49774DE2DC4B3D7005CD239 /* SyncData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncData.swift; sourceTree = "<group>"; };
C49779FB2DDB5D89005CD239 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = "<group>"; };
C4977BA82DEDFE6D005CD239 /* StoreLibrary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreLibrary.swift; sourceTree = "<group>"; };
C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCallCollection.swift; sourceTree = "<group>"; };
C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Extensions.swift"; sourceTree = "<group>"; };
C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredCollection.swift; sourceTree = "<group>"; };
@ -109,8 +96,7 @@
C4D477962CB66EEA0077713D /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = "<group>"; };
C4D4779C2CB923720077713D /* DataLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLog.swift; sourceTree = "<group>"; };
C4D4779E2CB92FD80077713D /* SyncedStorable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncedStorable.swift; sourceTree = "<group>"; };
C4D477A02CB9586A0077713D /* SyncedCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncedCollection.swift; sourceTree = "<group>"; };
C4E2A7D02E8D6A48007E5186 /* CLAUDE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CLAUDE.md; sourceTree = "<group>"; };
C4D477A02CB9586A0077713D /* StoredCollection+Sync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StoredCollection+Sync.swift"; sourceTree = "<group>"; };
C4FAE6992CEB84B300790446 /* WebSocketManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocketManager.swift; sourceTree = "<group>"; };
C4FAE69B2CEB8E9500790446 /* URLManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLManager.swift; sourceTree = "<group>"; };
C4FC2E282C2B2EC30021F3BF /* StoreCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreCenter.swift; sourceTree = "<group>"; };
@ -161,7 +147,6 @@
C425D4362B6D24E1002A7B48 /* LeStorage */ = {
isa = PBXGroup;
children = (
C4E2A7D02E8D6A48007E5186 /* CLAUDE.md */,
C4A47D6E2B7154F600ADC637 /* README.md */,
C425D4372B6D24E1002A7B48 /* LeStorage.h */,
C425D4382B6D24E1002A7B48 /* LeStorage.docc */,
@ -170,15 +155,13 @@
C4A47D6C2B71364600ADC637 /* ModelObject.swift */,
C488C87F2CCBDC210082001F /* NetworkMonitor.swift */,
C462E0DB2D37B61100F3E6E4 /* Notification+Name.swift */,
C40EC3E42D9BDFA3007372D7 /* PendingOperationManager.swift */,
C4AC9CE92CF754CC00CC13DF /* Relationship.swift */,
C4A47D602B6D3C1300ADC637 /* Services.swift */,
C425D4572B6D2519002A7B48 /* Store.swift */,
C4977BA82DEDFE6D005CD239 /* StoreLibrary.swift */,
C4FC2E282C2B2EC30021F3BF /* StoreCenter.swift */,
C4A47D642B6E92FE00ADC637 /* Storable.swift */,
C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */,
C4D477A02CB9586A0077713D /* SyncedCollection.swift */,
C4D477A02CB9586A0077713D /* StoredCollection+Sync.swift */,
C456EFE12BE52379007388E2 /* StoredSingleton.swift */,
C4D4779E2CB92FD80077713D /* SyncedStorable.swift */,
C4FAE6992CEB84B300790446 /* WebSocketManager.swift */,
@ -202,11 +185,9 @@
C4A47D542B6D2DBF00ADC637 /* FileUtils.swift */,
C467AAE22CD2466400D76CD2 /* Formatter.swift */,
C4A47D832B7B97F000ADC637 /* KeychainStore.swift */,
C471F2572DB10649006317F4 /* MockKeychainStore.swift */,
C4A47D522B6D2C5F00ADC637 /* Logger.swift */,
C4B96E1C2D8C53D700C2955F /* UIDevice+Extensions.swift */,
C4FAE69B2CEB8E9500790446 /* URLManager.swift */,
C49779FB2DDB5D89005CD239 /* String+Extensions.swift */,
);
path = Utils;
sourceTree = "<group>";
@ -229,8 +210,6 @@
C4A47D9A2B7CFFC500ADC637 /* Settings.swift */,
C400D7222CC2AF560092237C /* GetSyncData.swift */,
C4AC9CE42CEFB12100CC13DF /* DataAccess.swift */,
C48638B22D9BC6A8007E3E06 /* PendingOperation.swift */,
C49774DE2DC4B3D7005CD239 /* SyncData.swift */,
);
path = Codables;
sourceTree = "<group>";
@ -298,7 +277,7 @@
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1600;
LastUpgradeCheck = 1630;
LastUpgradeCheck = 1600;
TargetAttributes = {
C425D4332B6D24E1002A7B48 = {
CreatedOnToolsVersion = 15.2;
@ -332,7 +311,6 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
C4E2A7D12E8D6A4D007E5186 /* CLAUDE.md in Resources */,
C4A47D6F2B7154F600ADC637 /* README.md in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -354,9 +332,8 @@
C4A47D532B6D2C5F00ADC637 /* Logger.swift in Sources */,
C4A47D842B7B97F000ADC637 /* KeychainStore.swift in Sources */,
C4FC2E312C353E7B0021F3BF /* Log.swift in Sources */,
C4D477A12CB9586A0077713D /* SyncedCollection.swift in Sources */,
C4D477A12CB9586A0077713D /* StoredCollection+Sync.swift in Sources */,
C4A47D512B6D2C4E00ADC637 /* Codable+Extensions.swift in Sources */,
C40EC3E52D9BDFA3007372D7 /* PendingOperationManager.swift in Sources */,
C4AC9CE52CEFB12100CC13DF /* DataAccess.swift in Sources */,
C4FAE69A2CEB84B300790446 /* WebSocketManager.swift in Sources */,
C4D4779F2CB92FD80077713D /* SyncedStorable.swift in Sources */,
@ -373,12 +350,9 @@
C4D477972CB66EEA0077713D /* Date+Extensions.swift in Sources */,
C488C8802CCBDC210082001F /* NetworkMonitor.swift in Sources */,
C4A47D6D2B71364600ADC637 /* ModelObject.swift in Sources */,
C4977BA92DEDFE6D005CD239 /* StoreLibrary.swift in Sources */,
C49779FC2DDB5D89005CD239 /* String+Extensions.swift in Sources */,
C400D7232CC2AF560092237C /* GetSyncData.swift in Sources */,
C4A47D4F2B6D280200ADC637 /* StoredCollection.swift in Sources */,
C4A47D9C2B7CFFE000ADC637 /* Settings.swift in Sources */,
C49774DF2DC4B3D7005CD239 /* SyncData.swift in Sources */,
C4FC2E292C2B2EC30021F3BF /* StoreCenter.swift in Sources */,
C462E0DC2D37B61100F3E6E4 /* Notification+Name.swift in Sources */,
C4A47D812B7665AD00ADC637 /* Migration.swift in Sources */,
@ -388,10 +362,8 @@
C4FAE69C2CEB8E9500790446 /* URLManager.swift in Sources */,
C49EF0242BD6BDC50077B5AA /* FileManager+Extensions.swift in Sources */,
C425D4582B6D2519002A7B48 /* Store.swift in Sources */,
C48638B32D9BC6A8007E3E06 /* PendingOperation.swift in Sources */,
C4D4779D2CB923720077713D /* DataLog.swift in Sources */,
C45D35912C0A1DB5000F379F /* FailedAPICall.swift in Sources */,
C471F2582DB10649006317F4 /* MockKeychainStore.swift in Sources */,
C49B6E502C2089B6002BDE1B /* ApiCallCollection.swift in Sources */,
C4A47D6B2B71244100ADC637 /* Collection+Extension.swift in Sources */,
);
@ -451,7 +423,6 @@
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 526E96RFNP;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@ -518,7 +489,6 @@
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 526E96RFNP;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@ -545,11 +515,11 @@
C425D4492B6D24E1002A7B48 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUILD_LIBRARY_FOR_DISTRIBUTION = YES;
CODE_SIGN_IDENTITY = "";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 526E96RFNP;
DYLIB_COMPATIBILITY_VERSION = 1;
DYLIB_CURRENT_VERSION = 1;
DYLIB_INSTALL_NAME_BASE = "@rpath";
@ -578,11 +548,11 @@
C425D44A2B6D24E1002A7B48 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUILD_LIBRARY_FOR_DISTRIBUTION = YES;
CODE_SIGN_IDENTITY = "";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 526E96RFNP;
DYLIB_COMPATIBILITY_VERSION = 1;
DYLIB_CURRENT_VERSION = 1;
DYLIB_INSTALL_NAME_BASE = "@rpath";
@ -613,6 +583,7 @@
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 526E96RFNP;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MARKETING_VERSION = 1.0;
@ -629,6 +600,7 @@
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 526E96RFNP;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MARKETING_VERSION = 1.0;

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1630"
LastUpgradeVersion = "1600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1630"
LastUpgradeVersion = "1600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"

@ -39,8 +39,6 @@ enum ApiCallError: Error, LocalizedError {
/// Failing Api calls are stored forever and will be executed again later
actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
fileprivate var storeCenter: StoreCenter
/// The list of api calls
fileprivate(set) var items: [ApiCall<T>] = []
@ -63,10 +61,6 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
}
}
init(storeCenter: StoreCenter) {
self.storeCenter = storeCenter
}
/// Starts the JSON file decoding synchronously or asynchronously
/// Reschedule Api calls if not empty
func loadFromFile() throws {
@ -75,7 +69,7 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
/// Returns the file URL of the collection
fileprivate func _urlForJSONFile() throws -> URL {
return try self.storeCenter.jsonFileURL(for: ApiCall<T>.self)
return try ApiCall<T>.urlForJSONFile()
}
/// Decodes the json file into the items array
@ -98,12 +92,14 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
fileprivate func _write() {
let fileName = ApiCall<T>.fileName()
DispatchQueue(label: "lestorage.queue.write", qos: .utility).asyncAndWait {
// Logger.log("Start write to \(fileName)...")
do {
let jsonString: String = try self.items.jsonString()
try self.storeCenter.write(content: jsonString, fileName: fileName)
try T.writeToStorageDirectory(content: jsonString, fileName: fileName)
} catch {
Logger.error(error)
}
// Logger.log("End write")
}
}
@ -145,9 +141,7 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
/// Removes all objects in memory and deletes the JSON file
func reset() {
self._isExecutingCalls = false
self._schedulingTask?.cancel()
self.items.removeAll()
self._hasChanged = true
do {
let url: URL = try self._urlForJSONFile()
@ -188,7 +182,8 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
/// Reschedule the execution of API calls
fileprivate func _waitAndExecuteApiCalls() async {
guard !self._isExecutingCalls, self.storeCenter.forceNoSynchronization == false else { return }
// Logger.log("\(T.resourceName()) > RESCHED")
guard !self._isExecutingCalls, StoreCenter.main.collectionsCanSynchronize else { return }
guard self.items.isNotEmpty else { return }
self._isExecutingCalls = true
@ -197,37 +192,7 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
await self._wait()
await self._batchExecution()
// Logger.log("\(T.resourceName()) > EXECUTE CALLS: \(self.items.count)")
// let batches = Dictionary(grouping: self.items, by: { $0.transactionId })
//
// for batch in batches.values {
// do {
// if batch.count == 1, let apiCall = batch.first, apiCall.method == .get {
// try await self._executeGetCall(apiCall: apiCall)
// } else {
// let results = try await self._executeApiCalls(batch)
// if T.copyServerResponse {
// let instances = results.compactMap { $0.data }
// StoreCenter.main.updateLocalInstances(instances)
// }
// }
// } catch {
// Logger.error(error)
// }
// }
// Logger.log("\(T.resourceName()) > EXECUTE CALLS ENDED !")
self._isExecutingCalls = false
if self.items.isNotEmpty {
await self._waitAndExecuteApiCalls()
}
// Logger.log("\(T.resourceName()) > isRescheduling = \(self._isRescheduling)")
}
fileprivate func _batchExecution() async {
let batches = Dictionary(grouping: self.items, by: { $0.transactionId })
for batch in batches.values {
@ -235,38 +200,32 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
if batch.count == 1, let apiCall = batch.first, apiCall.method == .get {
try await self._executeGetCall(apiCall: apiCall)
} else {
let results: [OperationResult<T>] = try await self._executeApiCalls(batch)
let results = try await self._executeApiCalls(batch)
if T.copyServerResponse {
let instances: [T] = results.compactMap { $0.data }
self.storeCenter.updateLocalInstances(instances)
let instances = results.compactMap { $0.data }
StoreCenter.main.updateLocalInstances(instances)
}
}
} catch {
Logger.error(error)
}
}
}
@discardableResult func _executeGetCall(apiCall: ApiCall<T>) async throws -> Data {
let data = try await self.storeCenter.executeGet(apiCall: apiCall)
// Logger.log("GET received = \(T.resourceName())")
if T.self == GetSyncData.self {
let syncData = try SyncData(data: data, storeCenter: self.storeCenter)
await self.storeCenter.synchronizeContent(syncData)
} else {
let results: [T] = try self._decode(data: data)
await self.storeCenter.itemsRetrieved(results, storeId: apiCall.storeId, clear: apiCall.option != .additive)
// Logger.log("\(T.resourceName()) > EXECUTE CALLS ENDED !")
self._isExecutingCalls = false
if self.items.isNotEmpty {
await self._waitAndExecuteApiCalls()
}
return data
// Logger.log("\(T.resourceName()) > isRescheduling = \(self._isRescheduling)")
}
fileprivate func _decode<V: Decodable>(data: Data) throws -> V {
if !(V.self is Empty?.Type || V.self is Empty.Type) {
return try JSON.decoder.decode(V.self, from: data)
fileprivate func _executeGetCall(apiCall: ApiCall<T>) async throws {
if T.self == GetSyncData.self {
let _: Empty = try await StoreCenter.main.executeGet(apiCall: apiCall)
} else {
return try JSON.decoder.decode(V.self, from: "{}".data(using: .utf8)!)
let results: [T] = try await StoreCenter.main.executeGet(apiCall: apiCall)
await StoreCenter.main.itemsRetrieved(results, storeId: apiCall.storeId)
}
}
@ -296,7 +255,7 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
/// The method makes some clean up when necessary:
/// - When deleting, we delete other calls as they are unecessary
/// - When updating, we delete other PUT as we don't want them to be executed in random orders
fileprivate func _prepareCall(instance: T, method: HTTPMethod, transactionId: String? = nil) {
func callForInstance(_ instance: T, method: HTTPMethod, transactionId: String? = nil) throws -> ApiCall<T> {
// cleanup if necessary
switch method {
@ -310,118 +269,94 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
break
}
let call: ApiCall<T> = self._createCall(method, instance: instance, transactionId: transactionId)
self._addCallToWaitingList(call)
let call: ApiCall<T> = try self._createCall(method, instance: instance, transactionId: transactionId)
self._prepareCall(apiCall: call)
return call
}
/// deletes an array of ApiCall by id
fileprivate func _deleteCalls(_ calls: [ApiCall<T>]) {
for call in calls {
self.deleteById(call.id)
}
}
/// we want to avoid sending the same GET twice
fileprivate func _createGetCallIfNonExistent(_ parameters: [String : String]?, clear: Bool) -> ApiCall<T>? {
if let _ = self.items.first(where: { $0.method == .get && $0.urlParameters == parameters }) {
return nil
}
let option: CallOption? = !clear ? .additive : nil
let call = self._createCall(.get, instance: nil, option: option)
call.urlParameters = parameters
return call
fileprivate func _createGetCall() throws -> ApiCall<T> {
return try self._createCall(.get, instance: nil)
}
/// Creates an API call for the Storable [instance] and an HTTP [method]
fileprivate func _createCall(_ method: HTTPMethod, instance: T?, transactionId: String? = nil, option: CallOption? = nil) -> ApiCall<T> {
fileprivate func _createCall(_ method: HTTPMethod, instance: T?, transactionId: String? = nil) throws -> ApiCall<T> {
if let instance {
return ApiCall(method: method, data: instance, transactionId: transactionId, option: option)
return ApiCall(method: method, data: instance, transactionId: transactionId)
} else {
return ApiCall(method: .get, data: nil, option: option)
return ApiCall(method: .get, data: nil)
}
}
/// Prepares a call for execution by updating its properties and adding it to its collection for storage
fileprivate func _addCallToWaitingList(_ apiCall: ApiCall<T>) {
fileprivate func _prepareCall(apiCall: ApiCall<T>) {
apiCall.lastAttemptDate = Date()
apiCall.attemptsCount += 1
self.addOrUpdate(apiCall)
}
/// Sends a GET request with an URLParameterConvertible [instance]
func sendGetRequest(instance: URLParameterConvertible) async throws {
let parameters = instance.queryParameters(storeCenter: self.storeCenter)
try await self._sendGetRequest(parameters: parameters)
/// Sends an insert api call for the provided [instance]
func sendGetRequest(instance: T? = nil, storeId: String? = nil) async throws {
do {
let apiCall = ApiCall<T>(method: .get, data: nil)
if let parameteredInstance = instance as? URLParameterConvertible {
apiCall.urlParameters = parameteredInstance.queryParameters()
}
/// Sends a GET request with an optional [storeId]
func sendGetRequest(storeId: String?, clear: Bool = true) async throws {
var parameters: [String : String]? = nil
if let storeId {
parameters = [Services.storeIdURLParameter : storeId]
apiCall.urlParameters = [Services.storeIdURLParameter : storeId]
}
try await self._sendGetRequest(parameters: parameters, clear: clear)
}
/// Sends an insert api call for the provided [instance]
fileprivate func _sendGetRequest(parameters: [String : String]?, clear: Bool = true) async throws {
if let getCall = self._createGetCallIfNonExistent(parameters, clear: clear) {
do {
try await self._prepareAndSendGetCall(getCall)
try await self._prepareAndSendGetCall(apiCall)
} catch {
self.rescheduleApiCallsIfNecessary()
Logger.error(error)
}
} else {
self.rescheduleImmediately()
}
}
/// Creates and execute the ApiCalls corresponding to the [batch]
func executeBatch(_ batch: OperationBatch<T>) {
self._prepareCalls(batch: batch)
self.rescheduleImmediately()
}
func singleBatchExecution(_ batch: OperationBatch<T>) async {
self._prepareCalls(batch: batch)
await self._batchExecution()
}
func executeBatch(_ batch: OperationBatch<T>) async throws {
func executeSingleGet(instance: T) async throws -> Data where T : URLParameterConvertible {
let call = self._createCall(.get, instance: instance, option: .none)
call.urlParameters = instance.queryParameters(storeCenter: self.storeCenter)
self._addCallToWaitingList(call)
return try await self._executeGetCall(apiCall: call)
}
fileprivate func _prepareCalls(batch: OperationBatch<T>) {
var apiCalls: [ApiCall<T>] = []
let transactionId = Store.randomId()
for insert in batch.inserts {
self._prepareCall(instance: insert, method: .post, transactionId: transactionId)
let call = try self.callForInstance(insert, method: .post, transactionId: transactionId)
apiCalls.append(call)
}
for update in batch.updates {
self._prepareCall(instance: update, method: .put, transactionId: transactionId)
let call = try self.callForInstance(update, method: .put, transactionId: transactionId)
apiCalls.append(call)
}
for delete in batch.deletes {
self._prepareCall(instance: delete, method: .delete, transactionId: transactionId)
let call = try self.callForInstance(delete, method: .delete, transactionId: transactionId)
apiCalls.append(call)
}
self.rescheduleImmediately()
// return try await self._executeApiCalls(apiCalls)
}
/// Prepares and executes a GET call
fileprivate func _prepareAndSendGetCall(_ apiCall: ApiCall<T>) async throws {
self._addCallToWaitingList(apiCall)
self._prepareCall(apiCall: apiCall)
try await self._executeGetCall(apiCall: apiCall)
}
/// Executes an API call
/// For POST requests, potentially copies additional data coming from the server during the insert
fileprivate func _executeApiCalls(_ apiCalls: [ApiCall<T>]) async throws -> [OperationResult<T>] {
// Logger.log("/// \(T.resourceName()) > Start \(apiCalls.count) calls execution...")
// fileprivate func _executeGetCall<V: Decodable>(_ apiCall: ApiCall<T>) async throws -> V {
// return try await StoreCenter.main.executeGet(apiCall: apiCall)
// }
let results = try await self.storeCenter.execute(apiCalls: apiCalls)
/// Executes an API call
/// For POST requests, potentially copies additional data coming from the server during the insert
fileprivate func _executeApiCalls(_ apiCalls: [ApiCall<T>]) async throws -> [OperationResult<T>] {
// for call in apiCalls {
// Logger.log("execute call = \(call.id)")
// }
let results = try await StoreCenter.main.execute(apiCalls: apiCalls)
for result in results {
switch result.status {
case 200..<300:
@ -445,11 +380,9 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
/// Returns if the API call collection is not empty
func hasPendingCalls() -> Bool {
// print("\(T.resourceName()) calls = \(self.items.count)")
return self.items.isNotEmpty
}
/// returns the list of API calls in the collection
func apiCalls() -> [ApiCall<T>] {
return self.items
}
@ -457,24 +390,4 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
func type() async -> any Storable.Type { return T.self }
func resourceName() async -> String { return T.resourceName() }
// MARK: - Testing
func sendInsertion(_ instance: T) async throws {
let batch = OperationBatch<T>()
batch.addInsert(instance)
self.executeBatch(batch)
}
func sendUpdate(_ instance: T) async throws {
let batch = OperationBatch<T>()
batch.addUpdate(instance)
self.executeBatch(batch)
}
func sendDeletion(_ instance: T) async throws {
let batch = OperationBatch<T>()
batch.addDelete(instance)
self.executeBatch(batch)
}
}

@ -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

@ -17,24 +17,14 @@ public protocol SomeCall: Identifiable, Storable {
var dataContent: String? { get }
}
public enum CallOption: String, Codable {
case additive // keeps the content of the current collection
}
public class ApiCall<T: Storable>: ModelObject, Storable, SomeCall {
public required override init() {
self.method = .get
super.init()
}
public static func resourceName() -> String { return "apicalls_" + T.resourceName() }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
public static func storeParent() -> Bool { return false }
public var id: String = Store.randomId()
/// The transactionId serves to group calls together
/// The transactionId to group calls together
var transactionId: String = Store.randomId()
/// Creation date of the call
@ -55,24 +45,17 @@ public class ApiCall<T: Storable>: ModelObject, Storable, SomeCall {
/// The parameters to add in the URL to obtain : "?p1=v1&p2=v2"
var urlParameters: [String : String]? = nil
/// The option for the call
var option: CallOption? = nil
init(method: HTTPMethod, data: T?, transactionId: String? = nil, option: CallOption? = nil) {
init(method: HTTPMethod, data: T?, transactionId: String? = nil) {
self.method = method
self.data = data
if let transactionId {
self.transactionId = transactionId
}
self.option = option
}
public func copy(from other: any Storable) {
fatalError("should not happen")
}
public func copyForUpdate(from other: any Storable) {
fatalError("should not happen")
}
func formattedURLParameters() -> String? {
return self.urlParameters?.toQueryString()
@ -107,16 +90,12 @@ public class ApiCall<T: Storable>: ModelObject, Storable, SomeCall {
var storeId: String? { return self.urlParameters?[Services.storeIdURLParameter] }
public static func relationships() -> [Relationship] { return [] }
public static func parentRelationships() -> [Relationship] { return [] }
public static func childrenRelationships() -> [Relationship] { return [] }
}
class OldApiCall<T: Storable>: ModelObject, Storable, SomeCall {
static func resourceName() -> String { return "apicalls_" + T.resourceName() }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
static func storeParent() -> Bool { return false }
var id: String = Store.randomId()
@ -165,9 +144,6 @@ class OldApiCall<T: Storable>: ModelObject, Storable, SomeCall {
func copy(from other: any Storable) {
fatalError("should not happen")
}
public func copyForUpdate(from other: any Storable) {
fatalError("should not happen")
}
func formattedURLParameters() -> String? {
return self.urlParameters?.toQueryString()
@ -191,8 +167,6 @@ class OldApiCall<T: Storable>: ModelObject, Storable, SomeCall {
var dataContent: String? { return self.body }
static func relationships() -> [Relationship] { return [] }
public static func parentRelationships() -> [Relationship] { return [] }
public static func childrenRelationships() -> [Relationship] { return [] }
func toNewApiCall() -> ApiCall<T>? {
if let instance: T = try? self.body?.decode() {

@ -12,29 +12,19 @@ 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 sharedWith: [String]
var modelName: String
var modelId: String
var grantedAt: Date = Date()
init(owner: String, sharedWith: [String], modelName: String, modelId: String, storeId: String?) {
init(owner: String, sharedWith: [String], modelName: String, modelId: String) {
self.sharedWith = sharedWith
self.modelName = modelName
self.modelId = modelId
super.init()
self.relatedUser = owner
self.storeId = storeId
}
// Codable implementation
@ -72,12 +62,7 @@ class DataAccess: SyncedModelObject, SyncedStorable {
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)
}
}

@ -12,10 +12,6 @@ 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()
@ -37,8 +33,5 @@ class DataLog: ModelObject, Storable {
func copy(from other: any Storable) {
fatalError("should not happen")
}
public func copyForUpdate(from other: any Storable) {
fatalError("should not happen")
}
}

@ -12,19 +12,6 @@ class FailedAPICall: SyncedModelObject, SyncedStorable {
static func resourceName() -> String { return "failed-api-calls" }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
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() {
self.callId = ""
self.type = ""
self.apiCall = ""
self.error = ""
super.init()
}
var id: String = Store.randomId()
@ -106,8 +93,5 @@ class FailedAPICall: SyncedModelObject, SyncedStorable {
self.error = fac.error
self.authentication = fac.authentication
}
public func copyForUpdate(from other: any Storable) {
self.copy(from: other)
}
}

@ -9,25 +9,9 @@ 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 tokenExemptedMethods() -> [HTTPMethod] { return [] }
static func resourceName() -> String {
return "sync-data"
@ -37,12 +21,10 @@ class GetSyncData: SyncedModelObject, SyncedStorable, URLParameterConvertible {
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] {
func queryParameters() -> [String : String] {
return ["last_update" : self._formattedLastUpdate,
"device_id" : storeCenter.deviceId()]
"device_id" : StoreCenter.main.deviceId()]
}
fileprivate var _formattedLastUpdate: String {
@ -51,7 +33,4 @@ class GetSyncData: SyncedModelObject, SyncedStorable, URLParameterConvertible {
}
static func relationships() -> [Relationship] { return [] }
public static func parentRelationships() -> [Relationship] { return [] }
public static func childrenRelationships() -> [Relationship] { return [] }
}

@ -12,33 +12,22 @@ class Log: SyncedModelObject, SyncedStorable {
static func resourceName() -> String { return "logs" }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
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 date: Date = Date()
var user: String? = nil
var message: String = ""
init(message: String, user: String?) {
var message: String
init(message: String) {
self.message = message
self.user = user
super.init()
}
// MARK: - Codable
enum CodingKeys: String, CodingKey {
case id
case date
case user
case message
}
@ -46,7 +35,6 @@ class Log: SyncedModelObject, SyncedStorable {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
date = try container.decode(Date.self, forKey: .date)
user = try container.decodeIfPresent(String.self, forKey: .user)
message = try container.decode(String.self, forKey: .message)
try super.init(from: decoder)
}
@ -55,19 +43,15 @@ class Log: SyncedModelObject, SyncedStorable {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(date, forKey: .date)
try container.encodeIfPresent(user, forKey: .user)
try container.encode(message, forKey: .message)
try super.encode(to: encoder)
}
func copy(from other: any Storable) {
guard let log = other as? Log else { return }
self.date = log.date
self.message = log.message
}
public func copyForUpdate(from other: any Storable) {
fatalError("should not happen")
}
}

@ -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
}
}

@ -18,17 +18,4 @@ class Settings: MicroStorable {
var deviceId: String? = nil
var lastSynchronization: String = "2000-01-01T00:00:00.000000Z"
// WARNING: adding mandatory variable is not without consequences !!!
// Please add to the decoder below
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
userId = try container.decodeIfPresent(String.self, forKey: .userId)
username = try container.decodeIfPresent(String.self, forKey: .username)
deviceId = try container.decodeIfPresent(String.self, forKey: .deviceId)
lastSynchronization = try container.decodeIfPresent(String.self, forKey: .lastSynchronization) ?? "2000-01-01T00:00:00.000000Z"
}
}

@ -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
}
}

@ -15,15 +15,10 @@ open class ModelObject: NSObject {
public override init() { }
open func deleteDependencies(store: Store, actionOption: ActionOption) {
open func deleteDependencies() {
}
open func deleteUnusedSharedDependencies(store: Store) {
// Default implementation does nothing
// Subclasses should override this to handle their specific dependencies
}
static var relationshipNames: [String] = []
}
@ -56,7 +51,11 @@ open class SyncedModelObject: BaseModelObject {
public var relatedUser: String? = nil
public var lastUpdate: Date = Date()
public var sharing: SharingStatus?
public var shared: Bool?
open func copyFromServerInstance(_ instance: any Storable) -> Bool {
return false
}
public override init() {
super.init()
@ -65,7 +64,7 @@ open class SyncedModelObject: BaseModelObject {
enum CodingKeys: String, CodingKey {
case relatedUser
case lastUpdate
case sharing = "_sharing"
case shared = "_shared"
}
// Required initializer for Decodable
@ -73,7 +72,7 @@ open class SyncedModelObject: BaseModelObject {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.relatedUser = try container.decodeIfPresent(String.self, forKey: .relatedUser)
self.lastUpdate = try container.decodeIfPresent(Date.self, forKey: .lastUpdate) ?? Date()
self.sharing = try container.decodeIfPresent(SharingStatus.self, forKey: .sharing)
self.shared = try container.decodeIfPresent(Bool.self, forKey: .shared)
try super.init(from: decoder)
}
@ -83,8 +82,8 @@ open class SyncedModelObject: BaseModelObject {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(relatedUser, forKey: .relatedUser)
try container.encode(lastUpdate, forKey: .lastUpdate)
if self.sharing != nil {
try container.encodeIfPresent(sharing, forKey: .sharing)
if self.shared == true {
try container.encodeIfPresent(shared, forKey: .shared)
}
try super.encode(to: encoder)

@ -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)
}
}
}

@ -5,18 +5,11 @@
// 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) {
public init(type: any Storable.Type, keyPath: AnyKeyPath) {
self.type = type
self.keyPath = keyPath
self.storeLookup = storeLookup
}
/// The type of the relationship
@ -24,8 +17,4 @@ public struct Relationship {
/// the keyPath to access the relationship
var keyPath: AnyKeyPath
/// Indicates whether the linked object is on the main Store
var storeLookup: StoreLookup
}

@ -33,21 +33,18 @@ let changePasswordCall: ServiceCall = ServiceCall(
path: "change-password/", method: .put, requiresToken: true)
let postDeviceTokenCall: ServiceCall = ServiceCall(
path: "device-token/", method: .post, requiresToken: true)
let getUserDataAccessCallContent: ServiceCall = ServiceCall(
path: "data-access-content/", method: .get, requiresToken: true)
let userAgentsCall: ServiceCall = ServiceCall(
path: "user-supervisors/", method: .get, requiresToken: true)
let getUserDataAccessCall: ServiceCall = ServiceCall(
path: "data-access/", method: .get, requiresToken: true)
let userNamesCall: ServiceCall = ServiceCall(
path: "user-names/", method: .get, requiresToken: true)
/// A class used to send HTTP request to the django server
public class Services {
fileprivate let storeCenter: StoreCenter
/// The base API URL to send requests
fileprivate(set) var baseURL: String
public init(storeCenter: StoreCenter, url: String) {
self.storeCenter = storeCenter
public init(url: String) {
self.baseURL = url
}
@ -55,15 +52,6 @@ public class Services {
// MARK: - Base
/// Runs a request on the API and returns the appropriate response
/// - Parameters:
/// - path: the path of the service in the api, ie. "create-users/"
/// - method: the HTTP method to call
/// - requiresToken: whether the token must be included in the request
public func run<U: Decodable>(path: String, method: HTTPMethod, requiresToken: Bool) async throws -> U {
return try await self._runRequest(serviceCall: ServiceCall(path: path, method: method, requiresToken: requiresToken))
}
/// Runs a request using a configuration object
/// - Parameters:
/// - serviceConf: A instance of ServiceConf
@ -90,9 +78,9 @@ public class Services {
/// - Parameters:
/// - request: the URLRequest to run
/// - apiCallId: the id of the ApiCall to delete in case of success, or to schedule for a rerun in case of failure
fileprivate func _runGetApiCallRequest<T: SyncedStorable>(
fileprivate func _runGetApiCallRequest<T: SyncedStorable, V: Decodable>(
_ request: URLRequest, apiCall: ApiCall<T>
) async throws -> Data {
) async throws -> V {
let debugURL = request.url?.absoluteString ?? ""
// print("Run \(request.httpMethod ?? "") \(debugURL)")
let task: (Data, URLResponse) = try await URLSession.shared.data(for: request)
@ -100,11 +88,16 @@ public class Services {
if let response = task.1 as? HTTPURLResponse {
let statusCode = response.statusCode
print("\(debugURL) ended, status code = \(statusCode)")
switch statusCode {
case 200..<300: // success
try await self.storeCenter.deleteApiCallById(type: T.self, id: apiCall.id)
try await StoreCenter.main.deleteApiCallById(type: T.self, id: apiCall.id)
if T.self == GetSyncData.self {
await StoreCenter.main.synchronizeContent(task.0)
}
default: // error
print("\(debugURL) ended, status code = \(statusCode)")
Logger.log(
"Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
let errorString: String = String(data: task.0, encoding: .utf8) ?? ""
@ -114,8 +107,8 @@ public class Services {
errorMessage = message
}
try await self.storeCenter.rescheduleApiCalls(type: T.self)
self.storeCenter.logFailedAPICall(
try await StoreCenter.main.rescheduleApiCalls(type: T.self)
StoreCenter.main.logFailedAPICall(
apiCall.id, request: request, collectionName: T.resourceName(),
error: errorMessage.message)
@ -123,11 +116,11 @@ public class Services {
}
} else {
let message: String = "Unexpected and unmanaged URL Response \(task.1)"
self.storeCenter.log(message: message)
StoreCenter.main.log(message: message)
Logger.w(message)
}
return task.0 //try self._decode(data: task.0)
return try self._decode(data: task.0)
}
@ -151,11 +144,11 @@ public class Services {
if let response = task.1 as? HTTPURLResponse {
let statusCode = response.statusCode
print("\(debugURL) ended, status code = \(statusCode)")
switch statusCode {
case 200..<300: // success
break
default: // error
print("\(debugURL) ended, status code = \(statusCode)")
Logger.log(
"Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
let errorString: String = String(data: task.0, encoding: .utf8) ?? ""
@ -167,7 +160,7 @@ public class Services {
}
} else {
let message: String = "Unexpected and unmanaged URL Response \(task.1)"
self.storeCenter.log(message: message)
StoreCenter.main.log(message: message)
Logger.w(message)
}
return try self._decode(data: task.0)
@ -245,7 +238,7 @@ public class Services {
/// - method: the HTTP method to execute
/// - requiresToken: An optional boolean to indicate if the token is required
/// - identifier: an optional StoreIdentifier that allows to filter GET requests with the StoreIdentifier values
public func _baseRequest(
fileprivate func _baseRequest(
servicePath: String, method: HTTPMethod, requiresToken: Bool? = nil,
identifier: String? = nil, getArguments: [String : String]? = nil
) throws -> URLRequest {
@ -265,7 +258,7 @@ public class Services {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.addAppVersion()
if !(requiresToken == false) {
let token = try self.storeCenter.token()
let token = try StoreCenter.main.token()
request.addValue("Token \(token)", forHTTPHeaderField: "Authorization")
}
return request
@ -289,6 +282,7 @@ public class Services {
if let response = task.1 as? HTTPURLResponse {
let statusCode = response.statusCode
print("\(String(describing: T.self))> \(debugURL) ended, status code = \(statusCode)")
switch statusCode {
case 200..<300: // success
@ -303,7 +297,7 @@ public class Services {
default:
if let message = result.message {
let type = String(describing: T.self)
print("*** \(type) - \(result.data?.stringId ?? ""): \(result.status) > \(message)")
print("\(type) - \(result.apiCallId): \(result.status) > \(message)")
}
rescheduleApiCalls = true
break
@ -311,7 +305,6 @@ public class Services {
}
default: // error
print("\(String(describing: T.self))> \(debugURL) ended, status code = \(statusCode)")
Logger.log(
"Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
let errorString: String = String(data: task.0, encoding: .utf8) ?? ""
@ -320,9 +313,9 @@ public class Services {
if let message = self.errorMessageFromResponse(data: task.0) {
errorMessage = message
}
try await self.storeCenter.rescheduleApiCalls(type: T.self)
try await StoreCenter.main.rescheduleApiCalls(type: T.self)
// self.storeCenter.logFailedAPICall(
// StoreCenter.main.logFailedAPICall(
// apiCall.id, request: request, collectionName: T.resourceName(),
// error: errorMessage.message)
@ -330,12 +323,12 @@ public class Services {
}
} else {
let message: String = "Unexpected and unmanaged URL Response \(task.1)"
self.storeCenter.log(message: message)
StoreCenter.main.log(message: message)
Logger.w(message)
}
if rescheduleApiCalls {
try? await self.storeCenter.rescheduleApiCalls(type: T.self)
try? await StoreCenter.main.rescheduleApiCalls(type: T.self)
}
return results
@ -358,10 +351,9 @@ public class Services {
var request = URLRequest(url: url)
request.httpMethod = HTTPMethod.get.rawValue
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.addAppVersion()
if self._isTokenRequired(type: T.self, method: apiCall.method), self.storeCenter.isAuthenticated {
let token = try self.storeCenter.token()
if self._isTokenRequired(type: T.self, method: apiCall.method), StoreCenter.main.isAuthenticated {
let token = try StoreCenter.main.token()
request.addValue("Token \(token)", forHTTPHeaderField: "Authorization")
}
@ -403,9 +395,8 @@ public class Services {
var request = URLRequest(url: url)
request.httpMethod = HTTPMethod.post.rawValue
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let token = try self.storeCenter.token()
let token = try StoreCenter.main.token()
request.addValue("Token \(token)", forHTTPHeaderField: "Authorization")
request.addAppVersion()
let modelName = String(describing: T.self)
let operations = apiCalls.map { apiCall in
@ -417,27 +408,22 @@ public class Services {
storeId: apiCall.data?.getStoreId())
}
// let posts = apiCalls.filter({ $0.method == .post })
// for post in posts {
// print("POST \(T.resourceName()): id = \(post.dataId ?? "")")
// }
let payload = SyncPayload(operations: operations,
deviceId: self.storeCenter.deviceId())
deviceId: StoreCenter.main.deviceId())
request.httpBody = try JSON.encoder.encode(payload)
return request
}
// /// Starts a request to retrieve the synchronization updates
// /// - Parameters:
// /// - since: The date from which updates are retrieved
// func synchronizeLastUpdates(since: Date?) async throws {
// let request = try self._getSyncLogRequest(since: since)
// if let data = try await self._runRequest(request) {
// await self.storeCenter.synchronizeContent(data)
// }
// }
/// Starts a request to retrieve the synchronization updates
/// - Parameters:
/// - since: The date from which updates are retrieved
func synchronizeLastUpdates(since: Date?) async throws {
let request = try self._getSyncLogRequest(since: since)
if let data = try await self._runRequest(request) {
await StoreCenter.main.synchronizeContent(data)
}
}
/// Returns the URLRequest for an ApiCall
/// - Parameters:
@ -459,7 +445,7 @@ public class Services {
request.httpMethod = HTTPMethod.get.rawValue
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let token = try self.storeCenter.token()
let token = try StoreCenter.main.token()
request.addValue("Token \(token)", forHTTPHeaderField: "Authorization")
return request
@ -477,12 +463,12 @@ public class Services {
if let response = task.1 as? HTTPURLResponse {
let statusCode = response.statusCode
print("\(debugURL) ended, status code = \(statusCode)")
switch statusCode {
case 200..<300: // success
return task.0
// success(task.0)
default: // error
print("\(debugURL) ended, status code = \(statusCode)")
Logger.log(
"Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
let errorString: String = String(data: task.0, encoding: .utf8) ?? ""
@ -494,7 +480,7 @@ public class Services {
}
} else {
let message: String = "Unexpected and unmanaged URL Response \(task.1)"
self.storeCenter.log(message: message)
StoreCenter.main.log(message: message)
Logger.w(message)
}
return nil
@ -508,28 +494,28 @@ public class Services {
return try await self._runRequest(getRequest)
}
/// Executes a POST request on the generated DRF services corresponding to T
public func rawPost<T: Storable>(_ instance: T) async throws -> T {
/// Executes a POST request
public func post<T: Storable>(_ instance: T) async throws -> T {
var postRequest = try self._postRequest(type: T.self)
postRequest.httpBody = try JSON.encoder.encode(instance)
return try await self._runRequest(postRequest)
}
/// Executes a PUT request on the generated DRF services corresponding to T
public func rawPut<T: Storable>(_ instance: T) async throws -> T {
/// Executes a PUT request
public func put<T: Storable>(_ instance: T) async throws -> T {
var postRequest = try self._putRequest(type: T.self, id: instance.stringId)
postRequest.httpBody = try JSON.encoder.encode(instance)
return try await self._runRequest(postRequest)
}
/// Executes a DELETE request on the generated DRF services corresponding to T
public func rawDelete<T: Storable>(_ instance: T) async throws -> T {
public func delete<T: Storable>(_ instance: T) async throws -> T {
let deleteRequest = try self._deleteRequest(type: T.self, id: instance.stringId)
return try await self._runRequest(deleteRequest)
}
/// Executes an ApiCall
func runGetApiCall<T: SyncedStorable>(_ apiCall: ApiCall<T>) async throws -> Data {
func runGetApiCall<T: SyncedStorable, V: Decodable>(_ apiCall: ApiCall<T>) async throws -> V {
let request = try self._syncGetRequest(from: apiCall)
return try await self._runGetApiCallRequest(request, apiCall: apiCall)
}
@ -552,7 +538,7 @@ public class Services {
request.addAppVersion()
if self._isTokenRequired(type: T.self, method: apiCall.method) {
do {
let token = try self.storeCenter.token()
let token = try StoreCenter.main.token()
request.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
} catch {
Logger.log("missing token")
@ -578,8 +564,8 @@ public class Services {
// MARK: - Others
public func getUserAgents() async throws -> [ShortUser] {
return try await self._runRequest(serviceCall: userAgentsCall)
public func getUserNames() async throws -> [ShortUser] {
return try await self._runRequest(serviceCall: userNamesCall)
}
// MARK: - Authentication
@ -597,14 +583,14 @@ public class Services {
/// - password: the account's password
public func requestToken(username: String, password: String) async throws -> String {
var postRequest = try self._baseRequest(call: requestTokenCall)
let deviceId = self.storeCenter.deviceId()
let deviceId = StoreCenter.main.deviceId()
let deviceModel = await UIDevice.current.deviceModel()
let credentials = Credentials(username: username, password: password, deviceId: deviceId, deviceModel: deviceModel)
postRequest.httpBody = try JSON.encoder.encode(credentials)
let response: AuthResponse = try await self._runRequest(postRequest)
try self.storeCenter.storeToken(username: username, token: response.token)
try StoreCenter.main.storeToken(username: username, token: response.token)
return response.token
}
@ -618,7 +604,7 @@ public class Services {
let postRequest = try self._baseRequest(call: getUserCall)
let loggingDate = Date() // ideally we want the date of the latest retrieved object when loading collection objects
let user: U = try await self._runRequest(postRequest)
self.storeCenter.userDidLogIn(user: user, at: loggingDate)
StoreCenter.main.userDidLogIn(user: user, at: loggingDate)
return user
}
@ -627,7 +613,7 @@ public class Services {
/// - username: the account's username
/// - password: the account's password
public func logout() async throws {
let deviceId: String = self.storeCenter.deviceId()
let deviceId: String = StoreCenter.main.deviceId()
let _: Empty = try await self._runRequest(
serviceCall: logoutCall, payload: Logout(deviceId: deviceId))
}
@ -644,10 +630,10 @@ public class Services {
}
/// Returns the list of DataAccess
func getUserDataAccessContent() async throws {
let request = try self._baseRequest(call: getUserDataAccessCallContent)
public func getUserDataAccess() async throws {
let request = try self._baseRequest(call: getUserDataAccessCall)
if let data = try await self._runRequest(request) {
await self.storeCenter.userDataAccessRetrieved(data)
await StoreCenter.main.userDataAccessRetrieved(data)
}
}
@ -660,7 +646,7 @@ public class Services {
async throws
{
guard let username = self.storeCenter.userName else {
guard let username = StoreCenter.main.userName else {
throw ServiceError.missingUserName
}
@ -675,7 +661,7 @@ public class Services {
let response: Token = try await self._runRequest(
serviceCall: changePasswordCall, payload: params)
try self.storeCenter.storeToken(username: username, token: response.token)
try StoreCenter.main.storeToken(username: username, token: response.token)
}
/// The method send a request to reset the user's password
@ -693,7 +679,7 @@ public class Services {
/// - username: the account's username
/// - password: the account's password
public func deleteAccount() async throws {
guard let userId = self.storeCenter.userId else {
guard let userId = StoreCenter.main.userId else {
throw StoreError.missingUserId
}
let path = "users/\(userId)/"

@ -21,12 +21,7 @@ public protocol Storable: Codable, Identifiable, NSObjectProtocol {
/// Mimics the behavior of the cascading delete on the django server
/// Typically when we delete a resource, we automatically delete items that depends on it,
/// so when we do that on the server, we also need to do it locally
func deleteDependencies(store: Store, actionOption: ActionOption)
/// A method that deletes dependencies of shared resources, but only if they are themselves shared
/// and not referenced by other objects in the store
/// This is used when cleaning up shared objects that are no longer in use
func deleteUnusedSharedDependencies(store: Store)
func deleteDependencies()
/// Copies the content of another item into the instance
/// This behavior has been made to get live updates when looking at properties in SwiftUI screens
@ -34,12 +29,6 @@ public protocol Storable: Codable, Identifiable, NSObjectProtocol {
/// This method returns RelationShips objects of the type
static func relationships() -> [Relationship]
static func parentRelationships() -> [Relationship]
static func childrenRelationships() -> [Relationship]
/// Denotes a data that own its own store
/// Effectively used to trigger directory creation when adding an item to the collection
static func storeParent() -> Bool
}
@ -72,6 +61,27 @@ extension Storable {
return path
}
/// Returns the local URL of the storage directory
public static func storageDirectoryPath() throws -> URL {
return try FileUtils.pathForDirectoryInDocuments(directory: Store.storageDirectory)
}
/// Writes some content to a file inside the storage directory
/// - content: the string to write inside the file
/// - fileName: the name of the file inside the storage directory
static func writeToStorageDirectory(content: String, fileName: String) throws {
var fileURL = try self.storageDirectoryPath()
fileURL.append(component: fileName)
try content.write(to: fileURL, atomically: false, encoding: .utf8)
}
/// Returns the URL of the Storable json file
static func urlForJSONFile() throws -> URL {
var storageDirectory = try self.storageDirectoryPath()
storageDirectory.append(component: self.fileName())
return storageDirectory
}
static func buildRealId(id: String) -> ID {
switch ID.self {
case is String.Type:

@ -15,38 +15,8 @@ public enum StoreError: Error, LocalizedError {
case missingToken
case missingKeychainStore
case collectionNotRegistered(type: String)
case cannotSyncCollection(name: String)
case apiCallCollectionNotRegistered(type: String)
case synchronizationInactive
case storeNotRegistered(id: String)
case castIssue(type: String)
case invalidStoreLookup(from: any Storable.Type, to: any Storable.Type)
public var localizedDescription: String {
switch self {
case .missingService:
return "L'instance des services est nulle"
case .missingUsername:
return "Le nom d'utilisateur est manquant"
case .missingUserId:
return "L'identifiant utilisateur est manquant"
case .missingToken:
return "Aucun token n'est stocké"
case .missingKeychainStore:
return "Aucun magasin de trousseau n'est disponible"
case .collectionNotRegistered(let type):
return "La collection \(type) n'est pas enregistrée"
case .apiCallCollectionNotRegistered(let type):
return "La collection d'appels API n'a pas été enregistrée pour \(type)"
case .synchronizationInactive:
return "La synchronisation n'est pas active sur ce StoreCenter"
case .storeNotRegistered(let id):
return "Le magasin avec l'identifiant \(id) n'est pas enregistré"
case .castIssue(let type):
return "Problème de typage: \(type)"
case .invalidStoreLookup(let from, let to):
return "Mauvaise recherche dans le magasin de \(from) à \(to)"
}
}
public var errorDescription: String? {
switch self {
@ -62,16 +32,10 @@ public enum StoreError: Error, LocalizedError {
return "There is no keychain store"
case .collectionNotRegistered(let type):
return "The collection \(type) is not registered"
case .cannotSyncCollection(let name):
return "Tries to load the collection \(name) from the server while it's not authorized"
case .apiCallCollectionNotRegistered(let type):
return "The api call collection has not been registered for \(type)"
case .synchronizationInactive:
return "The synchronization is not active on this StoreCenter"
case .storeNotRegistered(let id):
return "The store with identifier \(id) is not registered"
case .castIssue(let type):
return "Can't cast to \(type)"
case .invalidStoreLookup(let from, let to):
return "invalid store lookup from \(from) to \(to)"
}
}
@ -79,35 +43,28 @@ public enum StoreError: Error, LocalizedError {
final public class Store {
public fileprivate(set) var storeCenter: StoreCenter
/// The Store singleton
public static let main = Store()
/// The dictionary of registered collections
/// The dictionary of registered StoredCollections
fileprivate var _collections: [String : any SomeCollection] = [:]
/// The dictionary of all StoredCollection
fileprivate var _baseCollections: [String : any SomeCollection] = [:]
/// The name of the directory to store the json files
static let storageDirectory = "storage"
/// The store identifier, used to name the store directory, and to perform filtering requests to the server
public fileprivate(set) var identifier: String? = nil
public init(storeCenter: StoreCenter) {
self.storeCenter = storeCenter
public init() {
self._createDirectory(directory: Store.storageDirectory)
}
public required init(storeCenter: StoreCenter, identifier: String) {
self.storeCenter = storeCenter
public required init(identifier: String) {
self.identifier = identifier
let directory = "\(storeCenter.directoryName)/\(identifier)"
let directory = "\(Store.storageDirectory)/\(identifier)"
self._createDirectory(directory: directory)
}
public static var main: Store { return StoreCenter.main.mainStore }
public func alternateStore(identifier: String) throws -> Store {
return try self.storeCenter.store(identifier: identifier)
}
/// Creates the store directory
/// - Parameters:
/// - directory: the name of the directory
@ -126,14 +83,12 @@ final public class Store {
/// - inMemory: Indicates if the collection should only live in memory, and not write into a file
public func registerCollection<T : Storable>(indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil) -> StoredCollection<T> {
if let _ = try? self.someCollection(type: T.self) {
fatalError("collection already registered")
// return collection
if let collection: StoredCollection<T> = try? self.collection() {
return collection
}
let collection = StoredCollection<T>(store: self, indexed: indexed, inMemory: inMemory, limit: limit)
self._collections[T.resourceName()] = collection
self._baseCollections[T.resourceName()] = collection
return collection
}
@ -142,34 +97,15 @@ final public class Store {
/// - Parameters:
/// - indexed: Creates an index to quickly access the data
/// - inMemory: Indicates if the collection should only live in memory, and not write into a file
public func registerSynchronizedCollection<T : SyncedStorable>(indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil, noLoad: Bool = false) -> SyncedCollection<T> {
if let collection: SyncedCollection<T> = try? self.syncedCollection() {
return collection
}
public func registerSynchronizedCollection<T : SyncedStorable>(indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil) -> StoredCollection<T> {
let collection = SyncedCollection<T>(store: self, indexed: indexed, inMemory: inMemory, limit: limit, noLoad: noLoad)
self._collections[T.resourceName()] = collection
self._baseCollections[T.resourceName()] = collection.collection
self.storeCenter.loadApiCallCollection(type: T.self)
if let collection: StoredCollection<T> = try? self.collection() {
return collection
}
func asyncLoadingSynchronizedCollection<T : SyncedStorable>(inMemory: Bool = false) async -> SyncedCollection<T> {
let collection = await SyncedCollection<T>(store: self, inMemory: inMemory)
self._collections[T.resourceName()] = collection
self._baseCollections[T.resourceName()] = collection.collection
self.storeCenter.loadApiCallCollection(type: T.self)
return collection
}
func asyncLoadingStoredCollection<T : Storable>(inMemory: Bool = false) async -> StoredCollection<T> {
let collection = await StoredCollection<T>(store: self, inMemory: inMemory)
let collection = StoredCollection<T>(store: self, indexed: indexed, inMemory: inMemory, limit: limit)
self._collections[T.resourceName()] = collection
self._baseCollections[T.resourceName()] = collection
StoreCenter.main.loadApiCallCollection(type: T.self)
return collection
}
@ -178,14 +114,13 @@ final public class Store {
/// - synchronized: indicates if the data is synchronized with the server
/// - inMemory: Indicates if the collection should only live in memory, and not write into a file
/// - sendsUpdate: Indicates if updates of items should be sent to the server
public func registerObject<T : Storable>(synchronized: Bool, inMemory: Bool = false, shouldLoadDataFromServer: Bool = true) -> StoredSingleton<T> {
public func registerObject<T : Storable>(synchronized: Bool, inMemory: Bool = false, sendsUpdate: Bool = true) -> StoredSingleton<T> {
let storedObject = StoredSingleton<T>(store: self, inMemory: inMemory, shouldLoadDataFromServer: shouldLoadDataFromServer)
self._collections[T.resourceName()] = storedObject
let storedObject = StoredSingleton<T>(store: self, inMemory: inMemory)
self._collections[T.resourceName()] = storedObject
if synchronized {
self.storeCenter.loadApiCallCollection(type: T.self)
StoreCenter.main.loadApiCallCollection(type: T.self)
}
return storedObject
@ -197,51 +132,45 @@ final public class Store {
/// - Parameters:
/// - id: the id of the data
public func findById<T: Storable>(_ id: T.ID) -> T? {
guard let collection = self._baseCollections[T.resourceName()] as? StoredCollection<T> else {
guard let collection = self._collections[T.resourceName()] as? StoredCollection<T> else {
Logger.w("Collection \(T.resourceName()) not registered")
return nil
}
return collection.findById(id)
}
/// Returns a collection by type
func syncedCollection<T: SyncedStorable>() throws -> SyncedCollection<T> {
if let collection = self._collections[T.resourceName()] as? SyncedCollection<T> {
return collection
}
throw StoreError.collectionNotRegistered(type: T.resourceName())
/// Filters a collection by predicate
/// - Parameters:
/// - isIncluded: a predicate to returns if a data should be filtered in
public func filter<T: Storable>(isIncluded: (T) throws -> (Bool)) rethrows -> [T] {
do {
return try self.collection().filter(isIncluded)
} catch {
return []
}
/// Returns a collection by type
func syncedCollection<T: SyncedStorable>(type: T.Type) throws -> SyncedCollection<T> {
return try self.syncedCollection()
}
/// Returns a collection by type
func someCollection<T: Storable>(type: T.Type) throws -> any SomeCollection {
if let collection = self._collections[T.resourceName()] {
func collection<T: Storable>() throws -> StoredCollection<T> {
if let collection = self._collections[T.resourceName()] as? StoredCollection<T> {
return collection
}
throw StoreError.collectionNotRegistered(type: T.resourceName())
}
func registerOrGetSyncedCollection<T: SyncedStorable>(_ type: T.Type) -> SyncedCollection<T> {
func registerOrGetSyncedCollection<T: SyncedStorable>(_ type: T.Type) -> StoredCollection<T> {
do {
return try self.syncedCollection()
return try self.collection()
} catch {
return self.registerSynchronizedCollection(indexed: true, inMemory: false, noLoad: true)
return self.registerSynchronizedCollection(indexed: true, inMemory: false)
}
}
/// Loads all collection with the data from the server
public func loadCollectionsFromServer(clear: Bool) {
public func loadCollectionsFromServer() {
for collection in self._syncedCollections() {
Task {
do {
try await collection.loadDataFromServerIfAllowed(clear: clear)
} catch {
Logger.error(error)
}
try? await collection.loadDataFromServerIfAllowed()
}
}
}
@ -273,163 +202,42 @@ final public class Store {
// MARK: - Synchronization
fileprivate func _requestWrite<T: SyncedStorable>(type: T.Type) {
self._baseCollections[T.resourceName()]?.requestWriteIfNecessary()
}
@MainActor
func synchronizationAddOrUpdate<T: SyncedStorable>(_ instances: [T], shared: SharingStatus?) {
for item in instances {
if !self.storeCenter.hasAlreadyBeenDeleted(item) {
self.addOrUpdateIfNewer(item, shared: shared)
}
}
self._requestWrite(type: T.self)
}
/// Calls addOrUpdateIfNewer from the collection corresponding to the instance
@MainActor
func addOrUpdateIfNewer<T: SyncedStorable>(_ instance: T, shared: SharingStatus?) {
let collection: SyncedCollection<T> = self.registerOrGetSyncedCollection(T.self)
func addOrUpdateIfNewer<T: SyncedStorable>(_ instance: T, shared: Bool) {
let collection: StoredCollection<T> = self.registerOrGetSyncedCollection(T.self)
collection.addOrUpdateIfNewer(instance, shared: shared)
}
@MainActor
func synchronizationDelete<T: SyncedStorable>(_ identifiers: [ObjectIdentifier], type: T.Type) {
for identifier in identifiers {
do {
try self.deleteNoSyncNoCascadeNoWrite(type: type, id: identifier.modelId)
} catch {
Logger.error(error)
}
self.storeCenter.cleanupDataLog(dataId: identifier.modelId)
}
self._requestWrite(type: T.self)
}
@MainActor
func synchronizationRevoke<T: SyncedStorable>(_ identifiers: [ObjectIdentifier], type: T.Type) {
for identifier in identifiers {
do {
if let instance = self._instance(id: identifier.modelId, type: type) {
if instance.sharing != nil && !self.storeCenter.isReferenced(instance: instance) {
try self.deleteNoSyncNoCascadeNoWrite(type: type, id: identifier.modelId)
}
}
} catch {
Logger.error(error)
}
}
self._requestWrite(type: T.self)
}
fileprivate func _instance<T: SyncedStorable>(id: String, type: T.Type) -> T? {
let realId: T.ID = T.buildRealId(id: id)
return self.findById(realId)
}
/// Calls deleteById from the collection corresponding to the instance
func deleteNoSyncNoCascadeNoWrite<T: SyncedStorable>(type: T.Type, id: String) throws {
let collection: SyncedCollection<T> = try self.syncedCollection()
collection.deleteByStringId(id, actionOption: .noCascadeNoWrite)
}
func isReferenced<T: Storable, S: Storable>(collectionType: S.Type, type: T.Type, id: String) -> Bool {
if let collection = self._baseCollections[S.resourceName()] {
return collection.hasParentReferences(type: type, id: id)
} else {
return false
}
}
public func deleteUnusedGrantedIfNecessary<T: SyncedStorable>(_ instance: T, originStoreId: String?) {
if !self.storeCenter.isReferenced(instance: instance) {
do {
let collection: SyncedCollection<T> = try self.syncedCollection()
collection.deleteUnusedGranted(instance: instance)
} catch {
Logger.error(error)
}
}
}
public func deleteUnusedSharedDependencies<T: SyncedStorable>(type: T.Type, _ handler: (T) throws -> Bool) {
do {
let collection: SyncedCollection<T> = try self.syncedCollection()
let items = try collection.items.filter(handler)
self.deleteUnusedSharedDependencies(items)
} catch {
Logger.error(error)
}
}
public func deleteUnusedSharedDependencies<T: SyncedStorable>(type: T.Type) {
do {
let collection: SyncedCollection<T> = try self.syncedCollection()
self.deleteUnusedSharedDependencies(collection.items)
} catch {
Logger.error(error)
}
}
/// Deletes dependencies of shared objects that are not used elsewhere in the system
/// Similar to _deleteDependencies but only for unused shared objects
public func deleteUnusedSharedDependencies<T: SyncedStorable>(_ items: [T]) {
func deleteNoSync<T: Storable>(instance: T) {
do {
for item in items {
guard item.sharing != nil else { continue }
if !self.storeCenter.isReferenced(instance: item) {
// Only delete if the shared item has no references
item.deleteUnusedSharedDependencies(store: self)
let collection: SyncedCollection<T> = try self.syncedCollection()
collection.deleteUnusedGranted(instance: item)
}
}
let collection: StoredCollection<T> = try self.collection()
collection.delete(instance: instance)
} catch {
Logger.error(error)
}
}
public func deleteAllDependencies<T: Storable>(type: T.Type, actionOption: ActionOption) {
do {
let collection = try self.someCollection(type: type)
collection.deleteAllItemsAndDependencies(actionOption: actionOption)
} catch {
Logger.error(error)
}
}
public func deleteDependencies<T>(type: T.Type, actionOption: ActionOption, _ isIncluded: (T) -> Bool) where T: SyncedStorable {
do {
let collection = try self.someCollection(type: type)
if let syncCollection = collection as? SyncedCollection<T> {
syncCollection.deleteDependencies(actionOption: actionOption, isIncluded)
}
} catch {
Logger.error(error)
}
/// Calls deleteById from the collection corresponding to the instance
func deleteNoSync<T: SyncedStorable>(type: T.Type, id: String) throws {
let collection: StoredCollection<T> = try self.collection()
collection.deleteByStringIdNoSync(id)
}
public func deleteDependencies<T>(type: T.Type, actionOption: ActionOption, _ isIncluded: (T) -> Bool) where T: Storable {
do {
let collection = try self.someCollection(type: type)
if let syncCollection = collection as? StoredCollection<T> {
syncCollection.deleteDependencies(actionOption: actionOption, isIncluded)
}
} catch {
Logger.error(error)
/// Calls deleteById from the collection corresponding to the instance
func referenceCount<T: SyncedStorable>(type: T.Type, id: String) -> Int {
var count: Int = 0
for collection in self._collections.values {
count += collection.referenceCount(type: type, id: id)
}
return count
}
// MARK: - Write
/// Returns the directory URL of the store
fileprivate func _directoryPath() throws -> URL {
var url = try FileUtils.pathForDirectoryInDocuments(directory: storeCenter.directoryName)
var url = try FileUtils.pathForDirectoryInDocuments(directory: Store.storageDirectory)
if let identifier {
url.append(component: identifier)
}
@ -444,22 +252,14 @@ final public class Store {
var fileURL = try self._directoryPath()
fileURL.append(component: fileName)
try content.write(to: fileURL, atomically: false, encoding: .utf8)
// Logger.log("write into \(fileURL)...")
}
/// Returns the URL matching a Storable type
/// - Parameters:
/// - type: a Storable type
func fileURL<T: Storable>(type: T.Type) throws -> URL {
return try self.fileURL(fileName: T.fileName())
}
/// Returns the URL matching a Storable type
/// - Parameters:
/// - type: a Storable type
func fileURL(fileName: String) throws -> URL {
let fileURL = try self._directoryPath()
return fileURL.appending(component: fileName)
return fileURL.appending(component: T.fileName())
}
/// Removes a file matching a Storable type
@ -479,16 +279,16 @@ final public class Store {
/// Retrieves all the items on the server
public func getItems<T: SyncedStorable>() async throws -> [T] {
if let identifier = self.identifier {
return try await self.storeCenter.getItems(identifier: identifier)
return try await StoreCenter.main.getItems(identifier: identifier)
} else {
return try await self.storeCenter.getItems()
return try await StoreCenter.main.getItems()
}
}
func loadCollectionItems<T: SyncedStorable>(_ items: [T], clear: Bool) async {
func loadCollectionItems<T: SyncedStorable>(_ items: [T]) async {
do {
let collection: SyncedCollection<T> = try self.syncedCollection()
await collection.loadItems(items, clear: clear)
let collection: StoredCollection<T> = try self.collection()
await collection.clearAndLoadItems(items)
} catch {
Logger.error(error)
}

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()
}
}

@ -0,0 +1,342 @@
//
// 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)
}
}

@ -6,63 +6,35 @@
//
import Foundation
import Combine
public protocol SomeCollection<Item>: Identifiable {
protocol CollectionHolder {
associatedtype Item
associatedtype Item: Storable
var items: [Item] { get }
func reset()
}
protocol SomeCollection: CollectionHolder, Identifiable {
var resourceName: String { get }
var hasLoaded: Bool { get }
var inMemory: Bool { get }
var type: any Storable.Type { get }
func reset()
func hasParentReferences<S: Storable>(type: S.Type, id: String) -> Bool
var items: [Item] { get }
func deleteAllItemsAndDependencies(actionOption: ActionOption)
func deleteDependencies(actionOption: ActionOption, _ isIncluded: (Item) -> Bool)
func findById(_ id: Item.ID) -> Item?
func requestWriteIfNecessary()
}
protocol CollectionDelegate<Item> {
associatedtype Item: Storable
func loadingForMemoryCollection() async
func itemMerged(_ pendingOperation: PendingOperation<Item>)
}
enum CollectionMethod {
case insert
case update
case delete
}
func allItems() -> [any Storable]
func referenceCount<S: Storable>(type: S.Type, id: String) -> Int
public struct ActionResult<T> {
var instance: T
var method: CollectionMethod
var pending: Bool
}
public struct ActionOption: Codable {
var synchronize: Bool
var cascade: Bool
var write: Bool
static let standard: ActionOption = ActionOption(synchronize: false, cascade: false, write: true)
static let noCascadeNoWrite: ActionOption = ActionOption(synchronize: false, cascade: false, write: false)
static let cascade: ActionOption = ActionOption(synchronize: false, cascade: true, write: true)
static let syncedCascade: ActionOption = ActionOption(synchronize: true, cascade: true, write: true)
protocol SomeSyncedCollection: SomeCollection {
func loadDataFromServerIfAllowed() async throws
func loadCollectionsFromServerIfNoFile() async throws
}
public class StoredCollection<T: Storable>: SomeCollection {
public typealias Item = T
public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollection, CollectionHolder
{
/// Doesn't write the collection in a file
fileprivate(set) public var inMemory: Bool = false
fileprivate(set) var inMemory: Bool = false
/// The list of stored items
@Published public fileprivate(set) var items: [T] = []
@ -73,41 +45,27 @@ public class StoredCollection<T: Storable>: SomeCollection {
/// Provides fast access for instances if the collection has been instanced with [indexed] = true
fileprivate var _indexes: [T.ID: T]? = nil
/// A PendingOperationManager instance that manages operations while the collection is not loaded
fileprivate(set) var pendingOperationManager: PendingOperationManager<T>? = nil
fileprivate var _writingTimer: Timer? = nil
/// Indicates whether the collection has changed, thus requiring a write operation
fileprivate var _triggerWrite: Bool = false {
fileprivate var _hasChanged: Bool = false {
didSet {
if self._triggerWrite == true {
self._scheduleWrite()
self._triggerWrite = false
}
if self._hasChanged == true {
self._scheduleWrite()
DispatchQueue.main.async {
NotificationCenter.default.post(
name: NSNotification.Name.CollectionDidChange, object: self)
}
self._hasChanged = false
}
}
}
/// Indicates if the collection has loaded locally, with or without a file
fileprivate(set) public var hasLoaded: Bool = false
/// Sets a max number of items inside the collection
fileprivate(set) var limit: Int? = nil
init(store: Store, inMemory: Bool = false) async {
self.store = store
if self.inMemory == false {
await self.loadFromFile()
}
}
init(store: Store, indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil, noLoad: Bool = false) {
init(store: Store, indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil) {
if indexed {
self._indexes = [:]
}
@ -115,24 +73,21 @@ public class StoredCollection<T: Storable>: SomeCollection {
self.store = store
self.limit = limit
if noLoad {
self.hasLoaded = true
} else {
Task {
await self.load()
}
self.load()
}
fileprivate init() {
// self.synchronized = false
self.store = Store.main
}
init(store: Store) {
self.store = store
/// Returns a dummy StoredCollection instance
public static func placeholder() -> StoredCollection<T> {
return StoredCollection<T>()
}
var storeCenter: StoreCenter { return self.store.storeCenter }
/// Returns the name of the managed resource
public var resourceName: String {
var resourceName: String {
return T.resourceName()
}
@ -143,91 +98,58 @@ public class StoredCollection<T: Storable>: SomeCollection {
// MARK: - Loading
/// Sets the collection as changed to trigger a write
public func requestWriteIfNecessary() {
if self.inMemory == false {
self._triggerWrite = true
}
func setChanged() {
self._hasChanged = true
}
/// Migrates if necessary and asynchronously decodes the json file
func load() async {
if !self.inMemory {
await self.loadFromFile()
} else {
await MainActor.run {
self.setAsLoaded()
}
}
}
func load() {
/// Starts the JSON file decoding asynchronously
func loadFromFile() async {
do {
try await self._decodeJSONFile()
if !self.inMemory {
try self.loadFromFile()
}
} catch {
Logger.error(error)
await MainActor.run {
self.setAsLoaded()
}
do {
let fileURL = try self.store.fileURL(type: T.self)
let jsonString: String = try FileUtils.readFile(fileURL: fileURL)
if !jsonString.isEmpty {
StoreCenter.main.log(message: "Could not decode: \(jsonString)")
}
} catch {
}
}
/// Starts the JSON file decoding synchronously or asynchronously
func loadFromFile() throws {
try self._decodeJSONFile()
}
/// Decodes the json file into the items array
fileprivate func _decodeJSONFile() async throws {
fileprivate func _decodeJSONFile() throws {
let fileURL = try self.store.fileURL(type: T.self)
if FileManager.default.fileExists(atPath: fileURL.path()) {
let jsonString: String = try FileUtils.readFile(fileURL: fileURL)
let decoded: [T] = try jsonString.decodeArray() ?? []
self.hasLoaded = true // avoid pending management
self.setItems(decoded)
self._setItems(decoded)
}
await MainActor.run {
self.setAsLoaded()
}
}
/// Sets the collection as loaded
/// Send a CollectionDidLoad event
@MainActor
func setAsLoaded() {
self.hasLoaded = true
self._mergePendingOperations()
DispatchQueue.main.async {
NotificationCenter.default.post(
name: NSNotification.Name.CollectionDidLoad, object: self)
}
}
/// Sets a collection of items and indexes them
func setItems(_ items: [T]) {
self.clear()
fileprivate func _setItems(_ items: [T]) {
for item in items {
self._addItem(instance: item)
item.store = self.store
}
}
@MainActor
func loadAndWrite(_ items: [T], clear: Bool = false) {
if clear {
self.setItems(items)
self.setAsLoaded()
} else {
self.setAsLoaded()
self.addOrUpdate(contentOfs: items)
}
self.requestWriteIfNecessary()
self.items = items
self._updateIndexIfNecessary()
}
/// Updates the whole index with the items array
@ -237,78 +159,94 @@ public class StoredCollection<T: Storable>: SomeCollection {
}
}
func clearAndLoadItems(_ items: [T]) async {
await MainActor.run {
self.clear()
self._setItems(items)
self.setAsLoaded()
self.setChanged()
}
}
// MARK: - Basic operations
/// Adds or updates the provided instance inside the collection
/// Adds it if its id is not found, and otherwise updates it
@discardableResult public func addOrUpdate(instance: T) -> ActionResult<T> {
defer {
self.requestWriteIfNecessary()
}
return self._rawAddOrUpdate(instance: instance)
public func addOrUpdate(instance: T) {
self.addOrUpdateItem(instance: instance)
}
/// Adds or update a sequence of elements
public func addOrUpdate(contentOfs sequence: any Sequence<T>, _ handler: ((ActionResult<T>) -> ())? = nil) {
/// Adds or update an instance inside the collection and writes
func addOrUpdateItem(instance: T) {
defer {
self.requestWriteIfNecessary()
self._hasChanged = true
}
for instance in sequence {
let result = self._rawAddOrUpdate(instance: instance)
handler?(result)
}
}
fileprivate func _rawAddOrUpdate(instance: T) -> ActionResult<T> {
if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
let updated = self._updateItem(instance, index: index, actionOption: .standard)
return ActionResult(instance: instance, method: .update, pending: !updated)
self.updateItem(instance, index: index)
} else {
let added = self._addItem(instance: instance)
return ActionResult(instance: instance, method: .insert, pending: !added)
self.addItem(instance: instance)
}
}
/// A method the treat the collection as a single instance holder
func setSingletonNoSync(instance: T) {
defer {
self.requestWriteIfNecessary()
self._hasChanged = true
}
self.clear()
self._addItem(instance: instance)
self.items.removeAll()
self.addItem(instance: instance)
}
/// Deletes the instance in the collection and sets the collection as changed to trigger a write
public func delete(instance: T) {
self.delete(instance: instance, actionOption: .cascade)
/// Deletes an item by its id
func deleteById(_ id: T.ID) {
if let instance = self.findById(id) {
self.delete(instance: instance)
}
}
/// Deletes the instance in the collection and sets the collection as changed to trigger a write
public func delete(instance: T, actionOption: ActionOption) {
public func delete(instance: T) {
defer {
self._triggerWrite = true
self._hasChanged = true
}
self.deleteItem(instance, actionOption: actionOption)
self.deleteItem(instance)
}
/// Deletes all items of the sequence by id and sets the collection as changed to trigger a write
public func delete(contentOfs sequence: any RandomAccessCollection<T>, _ handler: ((ActionResult<T>) -> ())? = nil) {
self.delete(contentOfs: sequence, actionOption: .cascade, handler: handler)
public func delete(contentOfs sequence: any RandomAccessCollection<T>) {
defer {
self._hasChanged = true
}
func delete(contentOfs sequence: any RandomAccessCollection<T>, actionOption: ActionOption, handler: ((ActionResult<T>) -> ())? = nil) {
for instance in sequence {
self.deleteItem(instance)
}
}
/// Adds or update a sequence of elements
public func addOrUpdate(contentOfs sequence: any Sequence<T>) {
self.addSequence(sequence)
// self._addOrUpdate(contentOfs: sequence)
}
/// Adds a sequence of objects inside the collection and performs a write
func addSequence(_ sequence: any Sequence<T>) {
defer {
self._triggerWrite = true
self._hasChanged = true
}
for instance in sequence {
let deleted = self.deleteItem(instance, actionOption: actionOption)
handler?(ActionResult(instance: instance, method: .delete, pending: !deleted))
if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
self.updateItem(instance, index: index)
} else { // insert
self.addItem(instance: instance)
}
}
}
/// This method sets the storeId for the given instance if the collection belongs to a store with an id
@ -322,45 +260,17 @@ public class StoredCollection<T: Storable>: SomeCollection {
}
}
func add(instance: T, actionOption: ActionOption) {
self._addItem(instance: instance, actionOption: actionOption)
}
/// Adds an instance to the collection
@discardableResult fileprivate func _addItem(instance: T, actionOption: ActionOption = .standard) -> Bool {
if !self.hasLoaded {
self.addPendingOperation(method: .add, instance: instance, actionOption: actionOption)
return false
}
self.invalidateCache()
func addItem(instance: T) {
self._affectStoreIdIfNecessary(instance: instance)
self.items.append(instance)
instance.store = self.store
self._indexes?[instance.id] = instance
self._applyLimitIfPresent()
if T.storeParent() {
_ = self.storeCenter.requestStore(identifier: instance.stringId) // make directory
}
return true
}
func update(_ instance: T, index: Int, actionOption: ActionOption) {
self._updateItem(instance, index: index, actionOption: actionOption)
// self.requestWrite()
}
/// Updates an instance to the collection by index
@discardableResult fileprivate func _updateItem(_ instance: T, index: Int, actionOption: ActionOption) -> Bool {
if !self.hasLoaded {
self.addPendingOperation(method: .update, instance: instance, actionOption: actionOption)
return false
}
self.invalidateCache()
func updateItem(_ instance: T, index: Int) {
let item = self.items[index]
if item !== instance {
@ -369,48 +279,11 @@ public class StoredCollection<T: Storable>: SomeCollection {
instance.store = self.store
self._indexes?[instance.id] = instance
return true
}
/// Deletes an instance from the collection
@discardableResult fileprivate func deleteItem(_ instance: T, actionOption: ActionOption = .cascade) -> Bool {
if !self.hasLoaded {
self.addPendingOperation(method: .delete, instance: instance, actionOption: actionOption)
return false
}
if actionOption.cascade {
instance.deleteDependencies(store: self.store, actionOption: actionOption)
}
self.localDeleteOnly(instance: instance)
if T.storeParent() {
self.storeCenter.destroyStore(identifier: instance.stringId)
}
return true
}
/// Deletes an instance from the collection
@discardableResult func deleteUnusedShared(_ instance: T, actionOption: ActionOption) -> Bool {
if !self.hasLoaded {
self.addPendingOperation(method: .deleteUnusedShared, instance: instance, actionOption: actionOption)
return false
}
// For shared objects, we need to check for dependencies that are also shared
// but not used elsewhere before deleting them
instance.deleteUnusedSharedDependencies(store: self.store)
self.localDeleteOnly(instance: instance)
return true
}
func localDeleteOnly(instance: T) {
self.invalidateCache()
func deleteItem(_ instance: T) {
instance.deleteDependencies()
self.items.removeAll { $0.id == instance.id }
self._indexes?.removeValue(forKey: instance.id)
}
@ -422,16 +295,6 @@ public class StoredCollection<T: Storable>: SomeCollection {
}
}
func deleteByStringId(_ id: String, actionOption: ActionOption = .cascade) {
let realId = T.buildRealId(id: id)
if let instance = self.findById(realId) {
self.deleteItem(instance, actionOption: actionOption)
}
if actionOption.write {
self.requestWriteIfNecessary()
}
}
/// Returns the instance corresponding to the provided [id]
public func findById(_ id: T.ID) -> T? {
if let index = self._indexes, let instance = index[id] {
@ -440,174 +303,86 @@ public class StoredCollection<T: Storable>: SomeCollection {
return self.items.first(where: { $0.id == id })
}
/// Deletes a list of items
/// Proceeds to "hard" delete the items without synchronizing them
/// Also removes related API calls
public func deleteDependencies(_ items: any Sequence<T>) {
defer {
self.requestWriteIfNecessary()
self._hasChanged = true
}
self.invalidateCache()
let itemsArray = Array(items) // fix error if items is self.items
for item in itemsArray {
if let index = self.items.firstIndex(where: { $0.id == item.id }) {
self.items.remove(at: index)
}
}
}
public func deleteAllItemsAndDependencies(actionOption: ActionOption) {
self._delete(contentOfs: self.items, actionOption: actionOption)
}
public func deleteDependencies(actionOption: ActionOption, _ isIncluded: (T) -> Bool) {
let items = self.items.filter(isIncluded)
self._delete(contentOfs: items, actionOption: actionOption)
/// 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)
}
fileprivate func _delete(contentOfs sequence: any RandomAccessCollection<T>, actionOption: ActionOption) {
for instance in sequence {
self.deleteItem(instance, actionOption: actionOption)
}
}
// MARK: - Pending operations
func addPendingOperation(method: StorageMethod, instance: T, actionOption: ActionOption) {
if self.pendingOperationManager == nil {
self.pendingOperationManager = PendingOperationManager<T>(store: self.store, inMemory: self.inMemory)
}
self._addPendingOperationIfPossible(method: method, instance: instance, actionOption: actionOption)
}
fileprivate func _addPendingOperationIfPossible(method: StorageMethod, instance: T, actionOption: ActionOption) {
self.pendingOperationManager?.addPendingOperation(method: method, instance: instance, actionOption: actionOption)
}
fileprivate func _mergePendingOperations() {
guard let manager = self.pendingOperationManager, manager.items.isNotEmpty else { return }
Logger.log(">>> Merge pending \(manager.typeName): \(manager.items.count)")
for item in manager.items {
let data = item.data
switch item.method {
case .add, .update:
self.addOrUpdate(instance: data)
case .delete:
self.deleteItem(data, actionOption: item.actionOption)
case .deleteUnusedShared:
self.deleteUnusedShared(data, actionOption: item.actionOption)
}
// MARK: - SomeCall
}
manager.reset()
self.pendingOperationManager = nil
/// Returns the collection items as [any Storable]
func allItems() -> [any Storable] {
return self.items
}
// MARK: - File access
/// Schedules a write operation
fileprivate func _scheduleWrite() {
self._cleanTimer()
DispatchQueue.main.async {
self._writingTimer = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(self._write), userInfo: nil, repeats: false)
}
}
fileprivate func _cleanTimer() {
self._writingTimer?.invalidate()
self._writingTimer = nil
guard !self.inMemory else { return }
DispatchQueue(label: "lestorage.queue.write", qos: .utility).asyncAndWait { // sync to make sure we don't have writes performed at the same time
self._write()
}
}
/// Writes all the items as a json array inside a file
@objc fileprivate func _write() {
DispatchQueue(label: "lestorage.queue.write", qos: .utility).async {
fileprivate func _write() {
do {
let jsonString: String = try self.items.jsonString()
try self.store.write(content: jsonString, fileName: T.fileName())
} catch {
Logger.error(error)
self.storeCenter.log(
StoreCenter.main.log(
message: "write failed for \(T.resourceName()): \(error.localizedDescription)")
}
}
self._cleanTimer()
}
/// Simply clears the items of the collection
public func clear() {
self.invalidateCache()
func clear() {
self.items.removeAll()
}
/// Removes the items of the collection and deletes the corresponding file
public func reset() {
self.clear()
self.items.removeAll()
self.store.removeFile(type: T.self)
}
public var type: any Storable.Type { return T.self }
var type: any Storable.Type { return T.self }
// MARK: - Reference count
/// Counts the references to an object - given its type and id - inside the collection
public func hasParentReferences<S: Storable>(type: S.Type, id: String) -> Bool {
let relationships = T.parentRelationships().filter { $0.type == type }
guard relationships.count > 0 else { return false }
func referenceCount<S: Storable>(type: S.Type, id: String) -> Int {
let relationships = T.relationships().filter { $0.type == type }
guard relationships.count > 0 else { return 0 }
for item in self.items {
for relationship in relationships {
if item[keyPath: relationship.keyPath] as? String == id {
return true
}
return self.items.reduce(0) { count, item in
count
+ relationships.filter { relationship in
(item[keyPath: relationship.keyPath] as? String) == id
}.count
}
}
return false
}
// MARK: - for Synced Collection
@MainActor
func updateLocalInstance(_ serverInstance: T) {
if let localInstance = self.findById(serverInstance.id) {
localInstance.copy(from: serverInstance)
self.requestWriteIfNecessary()
}
}
// MARK: - Cached queries
fileprivate var _cacheVersion = 0
fileprivate var _queryCache: [AnyHashable: (version: Int, result: Any)] = [:]
// Generic query method with caching
public func cached<Result>(
key: AnyHashable,
compute: ([T]) -> Result
) -> Result {
if let cached = self._queryCache[key],
cached.version == self._cacheVersion,
let result = cached.result as? Result {
return result
}
let result = compute(items)
self._queryCache[key] = (self._cacheVersion, result)
return result
}
private func invalidateCache() {
self._cacheVersion += 1
}
}
extension StoredCollection: RandomAccessCollection {
public static func placeholder() -> StoredCollection<T> {
return StoredCollection<T>(store: Store(storeCenter: StoreCenter.main))
}
// MARK: - RandomAccessCollection
public var startIndex: Int { return self.items.startIndex }
@ -617,13 +392,13 @@ extension StoredCollection: RandomAccessCollection {
return self.items.index(after: i)
}
public subscript(index: Int) -> T {
open subscript(index: Int) -> T {
get {
return self.items[index]
}
set(newValue) {
self.items[index] = newValue
self._triggerWrite = true
self._hasChanged = true
}
}

@ -8,24 +8,11 @@
import Foundation
/// A class extending the capabilities of StoredCollection but supposedly manages only one item
public class StoredSingleton<T: SyncedStorable>: SyncedCollection<T> {
var shouldLoadDataFromServer: Bool = true
init(store: Store, inMemory: Bool = false, shouldLoadDataFromServer: Bool = true) {
super.init(store: store, inMemory: inMemory)
self.shouldLoadDataFromServer = shouldLoadDataFromServer
}
public override func loadDataFromServerIfAllowed(clear: Bool = false) async throws {
if shouldLoadDataFromServer {
try await super.loadDataFromServerIfAllowed(clear: clear)
}
}
public class StoredSingleton<T: SyncedStorable>: StoredCollection<T> {
/// Sets the singleton to the collection without synchronizing it
public func setItemNoSync(_ instance: T) {
self.collection.setSingletonNoSync(instance: instance)
self.setSingletonNoSync(instance: instance)
}
/// updates the existing singleton
@ -37,15 +24,7 @@ public class StoredSingleton<T: SyncedStorable>: SyncedCollection<T> {
/// Returns the singleton
public func item() -> T? {
return self.collection.items.first
}
public func tryPutBeforeUpdating(_ instance: T) async throws {
let result = try await StoreCenter.main.service().rawPut(instance)
if let item = self.item() {
item.copy(from: result)
self.addOrUpdate(instance: item)
}
return self.items.first
}
// MARK: - Protects from use

@ -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)
}
}
}

@ -7,17 +7,10 @@
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()
var shared: Bool? { get set }
/// Returns HTTP methods that do not need to pass the token to the request
static func tokenExemptedMethods() -> [HTTPMethod]
@ -28,14 +21,16 @@ public protocol SyncedStorable: Storable {
}
protocol URLParameterConvertible {
func queryParameters(storeCenter: StoreCenter) -> [String : String]
func queryParameters() -> [String : String]
}
public protocol SideStorable {
var storeId: String? { get set }
}
extension Storable {
extension SyncedStorable {
public static var copyServerResponse: Bool { return false }
func getStoreId() -> String? {
if let alt = self as? SideStorable {
@ -45,13 +40,3 @@ extension Storable {
}
}
public extension SyncedStorable {
func copy() -> Self {
let copy = Self()
copy.copy(from: self)
return copy
}
}

@ -8,9 +8,9 @@
import Foundation
class ClassLoader {
static var classCache: [String : AnyClass] = [:]
static var classCache: [String: AnyClass] = [:]
static func getClass(_ className: String, classProject: String? = nil) -> AnyClass? {
static func getClass(_ className: String) -> AnyClass? {
if let cachedClass = classCache[className] {
return cachedClass
}
@ -23,14 +23,6 @@ class ClassLoader {
}
}
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

@ -7,9 +7,9 @@
import Foundation
public class JSON {
class JSON {
public static var encoder: JSONEncoder = {
static var encoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
#if DEBUG
@ -23,7 +23,7 @@ public class JSON {
return encoder
}()
public static var decoder: JSONDecoder = {
static var decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .custom { decoder in

@ -25,10 +25,6 @@ extension Array {
}
}
func group<T>(handler: (Element) -> T) -> [T : [Element]] {
return Dictionary(grouping: self, by: { handler($0) })
}
}
extension RandomAccessCollection {

@ -9,19 +9,19 @@ import Foundation
extension Date {
static var iso8601Formatter: ISO8601DateFormatter = {
static var iso8601Formatter: ISO8601DateFormatter {
let iso8601Formatter = ISO8601DateFormatter()
iso8601Formatter.timeZone = TimeZone(abbreviation: "CET")
iso8601Formatter.formatOptions = [.withInternetDateTime, .withTimeZone]
return iso8601Formatter
}()
}
public static var iso8601FractionalFormatter: ISO8601DateFormatter = {
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()

@ -24,14 +24,7 @@ enum KeychainError: Error {
}
}
protocol KeychainService {
func add(username: String, value: String) throws
func add(value: String) throws
func getValue() throws -> String
func deleteValue() throws
}
class KeychainStore: KeychainService {
class KeychainStore {
let serverId: String

@ -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()! })
}
}

@ -11,19 +11,15 @@ 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
init(urlString: String) {
self._url = urlString
_setupWebSocket()
}
@ -33,7 +29,6 @@ class WebSocketManager: ObservableObject {
}
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)")
@ -60,22 +55,23 @@ class WebSocketManager: ObservableObject {
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 {
guard StoreCenter.main.deviceId() != deviceId else {
break
}
Task {
await self.storeCenter.synchronizeLastUpdates()
do {
try await StoreCenter.main.synchronizeLastUpdates()
} catch {
Logger.error(error)
}
}
case .data(let data):
@ -94,7 +90,7 @@ class WebSocketManager: ObservableObject {
private func _handleWebSocketError(_ error: Error) {
// print("WebSocket error: \(error)")
// up to 10 seconds of reconnection
// Exponential backoff for reconnection
let delay = min(Double(self._reconnectAttempts), 10.0)
self._reconnectAttempts += 1
@ -135,17 +131,7 @@ class WebSocketManager: ObservableObject {
var pingStatus: Bool {
return self._pingOk
}
var failure: Bool {
return self._failure
}
var error: Error? {
return self._error
}
var reconnectAttempts: Int {
return self._reconnectAttempts
}
}

@ -8,45 +8,23 @@
import Testing
@testable import LeStorage
class Thing: SyncedModelObject, SyncedStorable, URLParameterConvertible {
override required init() {
super.init()
}
class Thing: ModelObject, Storable {
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 = ""
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 collection = ApiCallCollection<Thing>()
let thing = Thing(name: "yeah")
@ -72,7 +50,7 @@ struct ApiCallTests {
}
@Test func testApiCallProvisioning2() async throws {
let collection = ApiCallCollection<Thing>(storeCenter: StoreCenter.main)
let collection = ApiCallCollection<Thing>()
let thing = Thing(name: "yeah")
@ -97,7 +75,7 @@ struct ApiCallTests {
}
@Test func testApiCallProvisioning3() async throws {
let collection = ApiCallCollection<Thing>(storeCenter: StoreCenter.main)
let collection = ApiCallCollection<Thing>()
let thing = Thing(name: "yeah")
@ -109,25 +87,4 @@ struct ApiCallTests {
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)
}
}

@ -6,82 +6,55 @@
//
import Testing
@testable import LeStorage
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 }
static var relationshipNames: [String] = []
}
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 }
static var relationshipNames: [String] = []
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
let cars: StoredCollection<Car> = Store.main.registerCollection(inMemory: true)
let boats: StoredCollection<Boat> = Store.main.registerSynchronizedCollection(inMemory: true)
#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
let newApiCallCount = await StoreCenter.main.apiCallCount(type: Boat.self)
#expect(oldApiCallCount == newApiCallCount - 1)
cars.reset()
boats.reset()
#expect(cars.count == 0)
#expect(boats.count == 0)
}
}

@ -6,13 +6,12 @@
//
import Testing
@testable import LeStorage
import LeStorage
class IntObject: ModelObject, Storable {
static func resourceName() -> String { "int" }
static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { [] }
static func storeParent() -> Bool { return false }
static var relationshipNames: [String] = []
var id: Int
var name: String
@ -21,20 +20,12 @@ class IntObject: ModelObject, Storable {
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 }
static var relationshipNames: [String] = []
var id: String
var name: String
@ -43,50 +34,15 @@ class StringObject: ModelObject, Storable {
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 intObjects: StoredCollection<IntObject> = Store.main.registerCollection()
let int = IntObject(id: 12, name: "test")
self.intObjects.addOrUpdate(instance: int)
intObjects.addOrUpdate(instance: int)
if let search = intObjects.findById(12) {
#expect(search.id == 12)
@ -96,10 +52,10 @@ struct IdentifiableTests {
}
@Test func testStringIds() async throws {
let stringObjects: StoredCollection<StringObject> = Store.main.registerCollection()
try await ensureCollectionLoaded(self.stringObjects)
let string = StringObject(id: "coco", name: "name")
self.stringObjects.addOrUpdate(instance: string)
stringObjects.addOrUpdate(instance: string)
if let search = stringObjects.findById("coco") {
#expect(search.id == "coco")

@ -4,98 +4,86 @@
//
// Created by Laurent Morvillier on 16/10/2024.
//
import XCTest
import Testing
@testable import LeStorage
struct Error: Swift.Error, CustomStringConvertible {
let description: String
class StoredCollectionTests: XCTestCase {
init(_ description: String) {
self.description = description
}
}
var collection: StoredCollection<MockStorable>!
struct StoredCollectionTests {
var collection: StoredCollection<MockStorable>
override func setUp() {
super.setUp()
self.collection = Store.main.registerCollection()
}
init() async {
collection = await StoreCenter.main.mainStore.asyncLoadingStoredCollection(inMemory: true)
collection.reset()
override func tearDown() {
self.collection.clear()
super.tearDown()
}
@Test func testInitialization() async throws {
#expect(self.collection.hasLoaded)
#expect(collection.items.count == 0)
func testInitialization() {
XCTAssertEqual(collection.items.count, 0)
}
@Test func testAddOrUpdate() async throws {
func testAddOrUpdate() 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")
XCTAssertEqual(collection.items.count, 1)
XCTAssertEqual(collection.items[0].id, "1")
}
}
@Test func testDelete() async throws {
func testDelete() throws {
let item = MockStorable(id: "1", name: "Test")
collection.addOrUpdate(instance: item)
#expect(collection.items.count == 1)
XCTAssertEqual(collection.items.count, 1)
collection.delete(instance: item)
#expect(collection.items.isEmpty)
try collection.delete(instance: item)
XCTAssertEqual(collection.items.count, 0)
}
@Test func testFindById() async throws {
func testFindById() 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")
}
let foundItem = collection.findById("1")
XCTAssertNotNil(foundItem)
XCTAssertEqual(foundItem?.id, "1")
}
@Test func testDeleteById() async throws {
func testDeleteById() throws {
let item = MockStorable(id: "1", name: "Test")
collection.addOrUpdate(instance: item)
collection.deleteByStringId("1")
let search = collection.findById("1")
#expect(search == nil)
try collection.deleteById("1")
XCTAssertNil(collection.findById("1"))
}
@Test func testAddOrUpdateMultiple() async throws {
func testAddOrUpdateMultiple() throws {
let items = [
MockStorable(id: "1", name: "Test1"),
MockStorable(id: "2", name: "Test2"),
]
collection.addOrUpdate(contentOfs: items)
#expect(collection.items.count == 2)
XCTAssertEqual(collection.items.count, 2)
}
@Test func testDeleteAll() async throws {
func testDeleteAll() throws {
let items = [
MockStorable(id: "1", name: "Test1"),
MockStorable(id: "2", name: "Test2"),
]
collection.addOrUpdate(contentOfs: items)
#expect(collection.items.count == 2)
XCTAssertEqual(collection.items.count, 2)
collection.clear()
#expect(collection.items.isEmpty)
XCTAssertEqual(collection.items.count, 0)
}
@Test func testRandomAccessCollection() async throws {
func testRandomAccessCollection() {
let items = [
MockStorable(id: "1", name: "Test1"),
MockStorable(id: "2", name: "Test2"),
@ -104,15 +92,9 @@ struct StoredCollectionTests {
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")
}
XCTAssertEqual(collection.startIndex, 0)
XCTAssertEqual(collection.endIndex, 3)
XCTAssertEqual(collection[1].name, "Test2")
}
}
@ -130,12 +112,5 @@ class MockStorable: ModelObject, Storable {
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…
Cancel
Save