Compare commits

..

1 Commits
main ... sync

Author SHA1 Message Date
Laurent 22753ae230 first draft 1 year ago
  1. 180
      LeStorage.xcodeproj/project.pbxproj
  2. 4
      LeStorage.xcodeproj/xcshareddata/xcschemes/LeStorage.xcscheme
  3. 55
      LeStorage.xcodeproj/xcshareddata/xcschemes/LeStorageTests.xcscheme
  4. 443
      LeStorage/ApiCallCollection.swift
  5. 11
      LeStorage/CLAUDE.md
  6. 188
      LeStorage/Codables/ApiCall.swift
  7. 83
      LeStorage/Codables/DataAccess.swift
  8. 44
      LeStorage/Codables/DataLog.swift
  9. 87
      LeStorage/Codables/FailedAPICall.swift
  10. 57
      LeStorage/Codables/GetSyncData.swift
  11. 61
      LeStorage/Codables/Log.swift
  12. 34
      LeStorage/Codables/PendingOperation.swift
  13. 15
      LeStorage/Codables/Settings.swift
  14. 83
      LeStorage/Codables/SyncData.swift
  15. 77
      LeStorage/ModelObject.swift
  16. 60
      LeStorage/NetworkMonitor.swift
  17. 18
      LeStorage/Notification+Name.swift
  18. 66
      LeStorage/PendingOperationManager.swift
  19. 31
      LeStorage/Relationship.swift
  20. 833
      LeStorage/Services.swift
  21. 71
      LeStorage/Storable.swift
  22. 497
      LeStorage/Store.swift
  23. 1162
      LeStorage/StoreCenter.swift
  24. 64
      LeStorage/StoreLibrary.swift
  25. 785
      LeStorage/StoredCollection.swift
  26. 37
      LeStorage/StoredSingleton.swift
  27. 478
      LeStorage/SyncedCollection.swift
  28. 57
      LeStorage/SyncedStorable.swift
  29. 50
      LeStorage/Utils/ClassLoader.swift
  30. 59
      LeStorage/Utils/Codable+Extensions.swift
  31. 4
      LeStorage/Utils/Collection+Extension.swift
  32. 33
      LeStorage/Utils/Date+Extensions.swift
  33. 26
      LeStorage/Utils/Dictionary+Extensions.swift
  34. 59
      LeStorage/Utils/Errors.swift
  35. 11
      LeStorage/Utils/FileManager+Extensions.swift
  36. 9
      LeStorage/Utils/FileUtils.swift
  37. 12
      LeStorage/Utils/Formatter.swift
  38. 22
      LeStorage/Utils/KeychainStore.swift
  39. 6
      LeStorage/Utils/Logger.swift
  40. 44
      LeStorage/Utils/MockKeychainStore.swift
  41. 17
      LeStorage/Utils/String+Extensions.swift
  42. 24
      LeStorage/Utils/UIDevice+Extensions.swift
  43. 39
      LeStorage/Utils/URLManager.swift
  44. 151
      LeStorage/WebSocketManager.swift
  45. 133
      LeStorageTests/ApiCallTests.swift
  46. 87
      LeStorageTests/CollectionsTests.swift
  47. 110
      LeStorageTests/IdentifiableTests.swift
  48. 141
      LeStorageTests/StoredCollectionTests.swift
  49. 2
      README.md

@ -3,26 +3,17 @@
archiveVersion = 1;
classes = {
};
objectVersion = 70;
objectVersion = 56;
objects = {
/* 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 */; };
C425D43F2B6D24E1002A7B48 /* LeStorage.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C425D4342B6D24E1002A7B48 /* LeStorage.framework */; };
C425D4442B6D24E1002A7B48 /* LeStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C425D4432B6D24E1002A7B48 /* LeStorageTests.swift */; };
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 */; };
C4339BFF2CFF86B3004E5F09 /* Dictionary+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4339BFE2CFF86B3004E5F09 /* Dictionary+Extensions.swift */; };
C456EFE22BE52379007388E2 /* StoredSingleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C456EFE12BE52379007388E2 /* StoredSingleton.swift */; };
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 */; };
@ -40,24 +31,12 @@
C4A47D9B2B7CFFDA00ADC637 /* ApiCall.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D992B7CFFC500ADC637 /* ApiCall.swift */; };
C4A47D9C2B7CFFE000ADC637 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D9A2B7CFFC500ADC637 /* Settings.swift */; };
C4A47DAF2B85FD3800ADC637 /* Errors.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47DAE2B85FD3800ADC637 /* Errors.swift */; };
C4AC9CE52CEFB12100CC13DF /* DataAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4AC9CE42CEFB12100CC13DF /* DataAccess.swift */; };
C4AC9CE82CF0A13B00CC13DF /* ClassLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4AC9CE72CF0A13B00CC13DF /* ClassLoader.swift */; };
C4AC9CEA2CF754D200CC13DF /* Relationship.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4AC9CE92CF754CC00CC13DF /* Relationship.swift */; };
C4B96E1D2D8C53D700C2955F /* UIDevice+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4B96E1C2D8C53D700C2955F /* UIDevice+Extensions.swift */; };
C4C33F6F2C9B06B7006316DE /* LeStorage.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C425D4342B6D24E1002A7B48 /* LeStorage.framework */; };
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 */; };
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 */; };
C4FC2E312C353E7B0021F3BF /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FC2E302C353E7B0021F3BF /* Log.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
C4C33F702C9B06B7006316DE /* PBXContainerItemProxy */ = {
C425D4402B6D24E1002A7B48 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = C425D42B2B6D24E1002A7B48 /* Project object */;
proxyType = 1;
@ -67,23 +46,14 @@
/* End PBXContainerItemProxy section */
/* 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>"; };
C425D43E2B6D24E1002A7B48 /* LeStorageTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LeStorageTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
C425D4432B6D24E1002A7B48 /* LeStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeStorageTests.swift; sourceTree = "<group>"; };
C425D4572B6D2519002A7B48 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = "<group>"; };
C4339BFE2CFF86B3004E5F09 /* Dictionary+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dictionary+Extensions.swift"; sourceTree = "<group>"; };
C456EFE12BE52379007388E2 /* StoredSingleton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSingleton.swift; sourceTree = "<group>"; };
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>"; };
@ -101,26 +71,10 @@
C4A47D992B7CFFC500ADC637 /* ApiCall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCall.swift; sourceTree = "<group>"; };
C4A47D9A2B7CFFC500ADC637 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = "<group>"; };
C4A47DAE2B85FD3800ADC637 /* Errors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Errors.swift; sourceTree = "<group>"; };
C4AC9CE42CEFB12100CC13DF /* DataAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataAccess.swift; sourceTree = "<group>"; };
C4AC9CE72CF0A13B00CC13DF /* ClassLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassLoader.swift; sourceTree = "<group>"; };
C4AC9CE92CF754CC00CC13DF /* Relationship.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Relationship.swift; sourceTree = "<group>"; };
C4B96E1C2D8C53D700C2955F /* UIDevice+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIDevice+Extensions.swift"; sourceTree = "<group>"; };
C4C33F6B2C9B06B7006316DE /* LeStorageTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LeStorageTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
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>"; };
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>"; };
C4FC2E302C353E7B0021F3BF /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
C4C33F6C2C9B06B7006316DE /* LeStorageTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = LeStorageTests; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
C425D4312B6D24E1002A7B48 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
@ -129,11 +83,11 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
C4C33F682C9B06B7006316DE /* Frameworks */ = {
C425D43B2B6D24E1002A7B48 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
C4C33F6F2C9B06B7006316DE /* LeStorage.framework in Frameworks */,
C425D43F2B6D24E1002A7B48 /* LeStorage.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -144,7 +98,7 @@
isa = PBXGroup;
children = (
C425D4362B6D24E1002A7B48 /* LeStorage */,
C4C33F6C2C9B06B7006316DE /* LeStorageTests */,
C425D4422B6D24E1002A7B48 /* LeStorageTests */,
C425D4352B6D24E1002A7B48 /* Products */,
);
sourceTree = "<group>";
@ -153,7 +107,7 @@
isa = PBXGroup;
children = (
C425D4342B6D24E1002A7B48 /* LeStorage.framework */,
C4C33F6B2C9B06B7006316DE /* LeStorageTests.xctest */,
C425D43E2B6D24E1002A7B48 /* LeStorageTests.xctest */,
);
name = Products;
sourceTree = "<group>";
@ -161,27 +115,18 @@
C425D4362B6D24E1002A7B48 /* LeStorage */ = {
isa = PBXGroup;
children = (
C4E2A7D02E8D6A48007E5186 /* CLAUDE.md */,
C4A47D6E2B7154F600ADC637 /* README.md */,
C425D4372B6D24E1002A7B48 /* LeStorage.h */,
C425D4382B6D24E1002A7B48 /* LeStorage.docc */,
C4A47D9D2B7CFFF500ADC637 /* Codables */,
C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */,
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 */,
C456EFE12BE52379007388E2 /* StoredSingleton.swift */,
C4D4779E2CB92FD80077713D /* SyncedStorable.swift */,
C4FAE6992CEB84B300790446 /* WebSocketManager.swift */,
C4A47D932B7CF7C500ADC637 /* MicroStorage.swift */,
C4A47D822B7665BC00ADC637 /* Wip */,
C4A47D582B6D352900ADC637 /* Utils */,
@ -189,24 +134,24 @@
path = LeStorage;
sourceTree = "<group>";
};
C425D4422B6D24E1002A7B48 /* LeStorageTests */ = {
isa = PBXGroup;
children = (
C425D4432B6D24E1002A7B48 /* LeStorageTests.swift */,
);
path = LeStorageTests;
sourceTree = "<group>";
};
C4A47D582B6D352900ADC637 /* Utils */ = {
isa = PBXGroup;
children = (
C4AC9CE72CF0A13B00CC13DF /* ClassLoader.swift */,
C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */,
C4A47D6A2B71244100ADC637 /* Collection+Extension.swift */,
C4D477962CB66EEA0077713D /* Date+Extensions.swift */,
C4339BFE2CFF86B3004E5F09 /* Dictionary+Extensions.swift */,
C4A47DAE2B85FD3800ADC637 /* Errors.swift */,
C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */,
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>";
@ -223,14 +168,9 @@
isa = PBXGroup;
children = (
C4A47D992B7CFFC500ADC637 /* ApiCall.swift */,
C4D4779C2CB923720077713D /* DataLog.swift */,
C45D35902C0A1DB5000F379F /* FailedAPICall.swift */,
C4FC2E302C353E7B0021F3BF /* Log.swift */,
C4A47D9A2B7CFFC500ADC637 /* Settings.swift */,
C400D7222CC2AF560092237C /* GetSyncData.swift */,
C4AC9CE42CEFB12100CC13DF /* DataAccess.swift */,
C48638B22D9BC6A8007E3E06 /* PendingOperation.swift */,
C49774DE2DC4B3D7005CD239 /* SyncData.swift */,
);
path = Codables;
sourceTree = "<group>";
@ -267,27 +207,22 @@
productReference = C425D4342B6D24E1002A7B48 /* LeStorage.framework */;
productType = "com.apple.product-type.framework";
};
C4C33F6A2C9B06B7006316DE /* LeStorageTests */ = {
C425D43D2B6D24E1002A7B48 /* LeStorageTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = C4C33F722C9B06B7006316DE /* Build configuration list for PBXNativeTarget "LeStorageTests" */;
buildConfigurationList = C425D44B2B6D24E1002A7B48 /* Build configuration list for PBXNativeTarget "LeStorageTests" */;
buildPhases = (
C4C33F672C9B06B7006316DE /* Sources */,
C4C33F682C9B06B7006316DE /* Frameworks */,
C4C33F692C9B06B7006316DE /* Resources */,
C425D43A2B6D24E1002A7B48 /* Sources */,
C425D43B2B6D24E1002A7B48 /* Frameworks */,
C425D43C2B6D24E1002A7B48 /* Resources */,
);
buildRules = (
);
dependencies = (
C4C33F712C9B06B7006316DE /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
C4C33F6C2C9B06B7006316DE /* LeStorageTests */,
C425D4412B6D24E1002A7B48 /* PBXTargetDependency */,
);
name = LeStorageTests;
packageProductDependencies = (
);
productName = LeStorageTests;
productReference = C4C33F6B2C9B06B7006316DE /* LeStorageTests.xctest */;
productReference = C425D43E2B6D24E1002A7B48 /* LeStorageTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */
@ -297,14 +232,14 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1600;
LastUpgradeCheck = 1630;
LastSwiftUpdateCheck = 1520;
LastUpgradeCheck = 1520;
TargetAttributes = {
C425D4332B6D24E1002A7B48 = {
CreatedOnToolsVersion = 15.2;
};
C4C33F6A2C9B06B7006316DE = {
CreatedOnToolsVersion = 16.0;
C425D43D2B6D24E1002A7B48 = {
CreatedOnToolsVersion = 15.2;
};
};
};
@ -322,7 +257,7 @@
projectRoot = "";
targets = (
C425D4332B6D24E1002A7B48 /* LeStorage */,
C4C33F6A2C9B06B7006316DE /* LeStorageTests */,
C425D43D2B6D24E1002A7B48 /* LeStorageTests */,
);
};
/* End PBXProject section */
@ -332,12 +267,11 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
C4E2A7D12E8D6A4D007E5186 /* CLAUDE.md in Resources */,
C4A47D6F2B7154F600ADC637 /* README.md in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
C4C33F692C9B06B7006316DE /* Resources */ = {
C425D43C2B6D24E1002A7B48 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
@ -354,63 +288,43 @@
C4A47D532B6D2C5F00ADC637 /* Logger.swift in Sources */,
C4A47D842B7B97F000ADC637 /* KeychainStore.swift in Sources */,
C4FC2E312C353E7B0021F3BF /* Log.swift in Sources */,
C4D477A12CB9586A0077713D /* SyncedCollection.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 */,
C425D4392B6D24E1002A7B48 /* LeStorage.docc in Sources */,
C4AC9CE82CF0A13B00CC13DF /* ClassLoader.swift in Sources */,
C4A47DAF2B85FD3800ADC637 /* Errors.swift in Sources */,
C4AC9CEA2CF754D200CC13DF /* Relationship.swift in Sources */,
C4A47D612B6D3C1300ADC637 /* Services.swift in Sources */,
C4A47D552B6D2DBF00ADC637 /* FileUtils.swift in Sources */,
C456EFE22BE52379007388E2 /* StoredSingleton.swift in Sources */,
C467AAE32CD2467500D76CD2 /* Formatter.swift in Sources */,
C4A47D652B6E92FE00ADC637 /* Storable.swift in Sources */,
C4339BFF2CFF86B3004E5F09 /* Dictionary+Extensions.swift in Sources */,
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 */,
C4A47D9B2B7CFFDA00ADC637 /* ApiCall.swift in Sources */,
C4B96E1D2D8C53D700C2955F /* UIDevice+Extensions.swift in Sources */,
C4A47D942B7CF7C500ADC637 /* MicroStorage.swift in Sources */,
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 */,
);
runOnlyForDeploymentPostprocessing = 0;
};
C4C33F672C9B06B7006316DE /* Sources */ = {
C425D43A2B6D24E1002A7B48 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
C425D4442B6D24E1002A7B48 /* LeStorageTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
C4C33F712C9B06B7006316DE /* PBXTargetDependency */ = {
C425D4412B6D24E1002A7B48 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = C425D4332B6D24E1002A7B48 /* LeStorage */;
targetProxy = C4C33F702C9B06B7006316DE /* PBXContainerItemProxy */;
targetProxy = C425D4402B6D24E1002A7B48 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
@ -451,7 +365,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 +431,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 +457,10 @@
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 +489,10 @@
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";
@ -608,13 +518,14 @@
};
name = Release;
};
C4C33F732C9B06B7006316DE /* Debug */ = {
C425D44C2B6D24E1002A7B48 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 526E96RFNP;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.staxriver.LeStorageTests;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -624,13 +535,14 @@
};
name = Debug;
};
C4C33F742C9B06B7006316DE /* Release */ = {
C425D44D2B6D24E1002A7B48 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 526E96RFNP;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.staxriver.LeStorageTests;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -661,11 +573,11 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
C4C33F722C9B06B7006316DE /* Build configuration list for PBXNativeTarget "LeStorageTests" */ = {
C425D44B2B6D24E1002A7B48 /* Build configuration list for PBXNativeTarget "LeStorageTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
C4C33F732C9B06B7006316DE /* Debug */,
C4C33F742C9B06B7006316DE /* Release */,
C425D44C2B6D24E1002A7B48 /* Debug */,
C425D44D2B6D24E1002A7B48 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1630"
LastUpgradeVersion = "1530"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
@ -35,7 +35,7 @@
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C4C33F6A2C9B06B7006316DE"
BlueprintIdentifier = "C425D43D2B6D24E1002A7B48"
BuildableName = "LeStorageTests.xctest"
BlueprintName = "LeStorageTests"
ReferencedContainer = "container:LeStorage.xcodeproj">

@ -1,55 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1630"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C4C33F6A2C9B06B7006316DE"
BuildableName = "LeStorageTests.xctest"
BlueprintName = "LeStorageTests"
ReferencedContainer = "container:LeStorage.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

@ -1,5 +1,5 @@
//
// ApiCallCollection.swift
// SafeCollection.swift
// LeStorage
//
// Created by Laurent Morvillier on 17/06/2024.
@ -7,51 +7,36 @@
import Foundation
protocol SomeCallCollection {
protocol SomeCallCollection {
func findCallById(_ id: String) async -> (any SomeCall)?
func deleteById(_ id: String) async
func hasPendingCalls() async -> Bool
func contentOfFile() async -> String?
func reset() async
func resumeApiCalls() async
func type() async -> any Storable.Type
func resourceName() async -> String
}
enum ApiCallError: Error, LocalizedError {
case encodingError(id: String, type: String)
var errorDescription: String? {
switch self {
case .encodingError(let id, let type):
return "Can't encode instance \(type) with id: \(id)"
}
}
}
/// ApiCallCollection is an object communicating with a server to synchronize data managed locally
/// The Api calls are serialized and stored in a JSON file
/// Failing Api calls are stored forever and will be executed again later
actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
fileprivate var storeCenter: StoreCenter
actor ApiCallCollection<T: Storable>: SomeCallCollection {
/// The list of api calls
fileprivate(set) var items: [ApiCall<T>] = []
/// The number of time an execution loop has been called
fileprivate var _attemptLoops: Int = 0
/// Indicates if the collection is currently retrying ApiCalls
fileprivate var _isExecutingCalls: Bool = false
fileprivate var _schedulingTask: Task<(), Never>? = nil
fileprivate var _isRetryingCalls: Bool = false
/// The task of waiting and executing ApiCalls
fileprivate var _reschedulingTask: Task<Void, any Error>? = nil
/// Indicates whether the collection content has changed
/// Initiates a write when true
fileprivate var _hasChanged: Bool = false {
@ -62,35 +47,28 @@ 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 {
try self._decodeJSONFile()
self.rescheduleApiCallsIfNecessary()
}
/// 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
fileprivate func _decodeJSONFile() throws {
let fileURL = try self._urlForJSONFile()
if FileManager.default.fileExists(atPath: fileURL.path()) {
let jsonString: String = try FileUtils.readFile(fileURL: fileURL)
do {
let decoded: [ApiCall<T>] = try jsonString.decodeArray() ?? []
self.items = decoded
} catch {
let decoded: [OldApiCall<T>] = try jsonString.decodeArray() ?? []
self.items = decoded.compactMap { $0.toNewApiCall() }
}
let decoded: [ApiCall<T>] = try jsonString.decodeArray() ?? []
// Logger.log("loaded \(fileURL.lastPathComponent) with \(decoded.count) items")
self.items = decoded
}
}
@ -98,15 +76,17 @@ 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")
}
}
/// Adds or update an API call instance
func addOrUpdate(_ instance: ApiCall<T>) {
if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
@ -116,27 +96,26 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
}
self._hasChanged = true
}
/// Deletes an API call by [id]
func deleteById(_ id: String) {
self.items.removeAll(where: { $0.id == id })
// Logger.log("\(T.resourceName()) > Delete by id, count after deletion = \(self.items.count)")
self._hasChanged = true
}
/// Deletes a call by a data id
func deleteByDataId(_ dataId: String) {
if let apiCallIndex = self.items.firstIndex(where: { $0.data?.stringId == dataId }) {
if let apiCallIndex = self.items.firstIndex(where: { $0.dataId == dataId }) {
self.items.remove(at: apiCallIndex)
self._hasChanged = true
}
}
/// Returns the Api call associated with the provided id
func findById(_ id: String) -> ApiCall<T>? {
return self.items.first(where: { $0.id == id })
}
/// Returns the Api call associated with the provided id
func findCallById(_ id: String) async -> (any SomeCall)? {
return self.findById(id)
@ -144,11 +123,9 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
/// Removes all objects in memory and deletes the JSON file
func reset() {
self._isExecutingCalls = false
self._schedulingTask?.cancel()
self._reschedulingTask?.cancel()
self.items.removeAll()
self._hasChanged = true
do {
let url: URL = try self._urlForJSONFile()
if FileManager.default.fileExists(atPath: url.path()) {
@ -159,281 +136,140 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
}
}
func resumeApiCalls() {
self._attemptLoops = -1
self.rescheduleApiCallsIfNecessary()
if self._schedulingTask != nil && self._attemptLoops > 2 {
self._schedulingTask?.cancel()
self._attemptLoops = -1
self.rescheduleApiCallsIfNecessary()
/// Reschedule the execution of API calls
fileprivate func _rescheduleApiCalls() {
guard self.items.isNotEmpty else {
return
}
}
self._isRetryingCalls = true
self._attemptLoops += 1
self._reschedulingTask = Task {
let delay = pow(2, self._attemptLoops)
let seconds = NSDecimalNumber(decimal: delay).intValue
Logger.log("\(T.resourceName()): wait for \(seconds) sec")
try await Task.sleep(until: .now + .seconds(seconds))
let apiCallsCopy = self.items
for apiCall in apiCallsCopy {
apiCall.attemptsCount += 1
apiCall.lastAttemptDate = Date()
do {
let _ = try await self._executeApiCall(apiCall)
} catch {
Logger.error(error)
}
}
/// Reschedule API calls without waiting
func rescheduleImmediately() {
self._attemptLoops = -1
self.rescheduleApiCallsIfNecessary()
}
self._hasChanged = true
/// Reschedule API calls if necessary
func rescheduleApiCallsIfNecessary() {
if self.items.isNotEmpty && !self._isExecutingCalls {
self._schedulingTask = Task {
await self._waitAndExecuteApiCalls()
if self.items.isEmpty {
self._isRetryingCalls = false
} else {
self._rescheduleApiCalls()
}
}
}
/// Reschedule the execution of API calls
fileprivate func _waitAndExecuteApiCalls() async {
guard !self._isExecutingCalls, self.storeCenter.forceNoSynchronization == false else { return }
guard self.items.isNotEmpty else { return }
self._isExecutingCalls = true
self._attemptLoops += 1
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)")
}
// MARK: - Synchronization
fileprivate func _batchExecution() async {
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)
/// Returns an APICall instance for the Storable [instance] and an HTTP [method]
/// The method updates existing calls or creates a new one
fileprivate func _callForInstance(_ instance: T, method: HTTPMethod) throws -> ApiCall<T>? {
if let existingCall = self.items.first(where: { $0.dataId == instance.id }) {
switch method {
case .delete:
self.deleteById(existingCall.id) // delete the existing call as we don't need it
if existingCall.method == HTTPMethod.post {
return nil // if the post has not been done, we can just stop here
} else {
let results: [OperationResult<T>] = try await self._executeApiCalls(batch)
if T.copyServerResponse {
let instances: [T] = results.compactMap { $0.data }
self.storeCenter.updateLocalInstances(instances)
}
return try self._createCall(instance, method: method) // otherwise it's a put and we want to send the delete
}
} catch {
Logger.error(error)
default: // here we should only trying to PUT, so we update the existing POST/PUT with the instance new values
existingCall.body = try instance.jsonString()
return existingCall
}
}
}
@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)
return try self._createCall(instance, method: method)
}
return data
}
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)
} else {
return try JSON.decoder.decode(V.self, from: "{}".data(using: .utf8)!)
}
}
/// Wait for an exponentionnaly long time depending on the number of attemps
fileprivate func _wait() async {
guard self._attemptLoops > 0 else { return }
var seconds = self._attemptLoops
if self._attemptLoops > 5 {
let delay = pow(2, self._attemptLoops - 2) // starts at 16s
seconds = NSDecimalNumber(decimal: delay).intValue
}
Logger.log("\(T.resourceName()): wait for \(seconds) sec")
do {
try await Task.sleep(until: .now + .seconds(seconds))
} catch {
Logger.w("*** WAITING CRASHED !!!")
Logger.error(error)
}
}
// MARK: - Synchronization
/// Returns an APICall instance for the Storable [instance] and an HTTP [method]
/// 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) {
// cleanup if necessary
switch method {
case .delete: // we don't want anything else than a DELETE in the queue
let existingCalls = self.items.filter { $0.data?.stringId == instance.stringId }
self._deleteCalls(existingCalls)
case .put: // we don't want mixed PUT calls so we delete the others
let existingPuts = self.items.filter { $0.data?.stringId == instance.stringId && $0.method == .put }
self._deleteCalls(existingPuts)
default:
break
}
let call: ApiCall<T> = self._createCall(method, instance: instance, transactionId: transactionId)
self._addCallToWaitingList(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
}
/// 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> {
if let instance {
return ApiCall(method: method, data: instance, transactionId: transactionId, option: option)
} else {
return ApiCall(method: .get, data: nil, option: option)
}
fileprivate func _createCall(_ instance: T, method: HTTPMethod) throws -> ApiCall<T> {
let jsonString = try instance.jsonString()
return ApiCall(method: method, dataId: String(instance.id), body: jsonString)
}
/// 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>) throws {
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 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]
/// Reschedule API calls if necessary
func rescheduleApiCallsIfNecessary() {
if !self._isRetryingCalls {
self._rescheduleApiCalls()
}
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)
} catch {
self.rescheduleApiCallsIfNecessary()
Logger.error(error)
}
} else {
self.rescheduleImmediately()
func sendInsertion(_ instance: T) async throws -> T? {
do {
return try await self._synchronize(instance, method: HTTPMethod.post)
} catch {
self.rescheduleApiCallsIfNecessary()
Logger.error(error)
}
return nil
}
/// Creates and execute the ApiCalls corresponding to the [batch]
func executeBatch(_ batch: OperationBatch<T>) {
self._prepareCalls(batch: batch)
self.rescheduleImmediately()
/// Sends an update api call for the provided [instance]
func sendUpdate(_ instance: T) async throws -> T? {
do {
return try await self._synchronize(instance, method: HTTPMethod.put)
} catch {
self.rescheduleApiCallsIfNecessary()
Logger.error(error)
}
return nil
}
func singleBatchExecution(_ batch: OperationBatch<T>) async {
self._prepareCalls(batch: batch)
await self._batchExecution()
/// Sends an delete api call for the provided [instance]
func sendDeletion(_ instance: T) async throws -> T? {
do {
return try await self._synchronize(instance, method: HTTPMethod.delete)
} catch {
self.rescheduleApiCallsIfNecessary()
Logger.error(error)
}
return nil
}
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>) {
let transactionId = Store.randomId()
for insert in batch.inserts {
self._prepareCall(instance: insert, method: .post, transactionId: transactionId)
}
for update in batch.updates {
self._prepareCall(instance: update, method: .put, transactionId: transactionId)
}
for delete in batch.deletes {
self._prepareCall(instance: delete, method: .delete, transactionId: transactionId)
/// Initiates the process of sending the data with the server
fileprivate func _synchronize(_ instance: T, method: HTTPMethod) async throws -> T? {
if let apiCall = try self._callForInstance(instance, method: method) {
try self._prepareCall(apiCall: apiCall)
return try await self._executeApiCall(apiCall)
} else {
return nil
}
}
/// Prepares and executes a GET call
fileprivate func _prepareAndSendGetCall(_ apiCall: ApiCall<T>) async throws {
self._addCallToWaitingList(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...")
let results = try await self.storeCenter.execute(apiCalls: apiCalls)
for result in results {
switch result.status {
case 200..<300:
self.deleteById(result.apiCallId)
default:
break
}
}
return results
fileprivate func _executeApiCall(_ apiCall: ApiCall<T>) async throws -> T {
return try await StoreCenter.main.execute(apiCall: apiCall)
}
/// Returns the content of the API call file as a String
func contentOfFile() -> String? {
guard let fileURL = try? self._urlForJSONFile() else { return nil }
@ -442,39 +278,10 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
}
return nil
}
/// 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
}
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

@ -7,133 +7,31 @@
import Foundation
public protocol SomeCall: Identifiable, Storable {
protocol SomeCall: Storable {
var id: String { get }
var lastAttemptDate: Date { get }
var attemptsCount: Int { get }
var method: HTTPMethod { get }
var dataId: String? { get }
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
var transactionId: String = Store.randomId()
/// Creation date of the call
var creationDate: Date? = Date()
/// The HTTP method of the call
public var method: HTTPMethod
/// The content of the call
var data: T?
/// The number of times the call has been executed
public var attemptsCount: Int = 0
/// The date of the last execution
public var lastAttemptDate: Date = Date()
/// 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) {
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()
}
func urlExtension() -> String {
switch self.method {
case HTTPMethod.put, HTTPMethod.delete:
return T.path(id: self.data?.stringId)
case HTTPMethod.post:
return T.path()
case HTTPMethod.get:
if let parameters = self.urlParameters?.toQueryString() {
return T.path() + parameters
} else {
return T.path()
}
}
}
public var dataId: String? {
return self.data?.stringId
}
public var dataContent: String? {
if let data = self.data {
return try? data.jsonString()
}
return nil
}
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 {
class ApiCall<T: Storable>: ModelObject, Storable, SomeCall {
static func resourceName() -> String { return "apicalls_" + T.resourceName() }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
static func storeParent() -> Bool { return false }
static func filterByStoreIdentifier() -> Bool { return false }
var id: String = Store.randomId()
/// The transactionId to group calls together
var transactionId: String? = Store.randomId()
/// Creation date of the call
var creationDate: Date? = Date()
/// The http URL of the call
// var url: String
/// The HTTP method of the call
/// The HTTP method of the call: post...
var method: HTTPMethod
/// The content of the call
var body: String?
/// The id of the underlying data
var dataId: String
/// The id of the underlying data stored in the body
var dataId: String?
/// The content of the call
var body: String
/// The number of times the call has been executed
var attemptsCount: Int = 0
@ -141,71 +39,11 @@ class OldApiCall<T: Storable>: ModelObject, Storable, SomeCall {
/// The date of the last execution
var lastAttemptDate: Date = Date()
/// The parameters to add in the URL to obtain : "?p1=v1&p2=v2"
var urlParameters: [String : String]? = nil
init(method: HTTPMethod, dataId: String? = nil, body: String? = nil, transactionId: String? = nil) {
init(method: HTTPMethod, dataId: String, body: String) {
// self.url = url
self.method = method
self.dataId = dataId
self.body = body
if let transactionId {
self.transactionId = transactionId
}
}
init(method: HTTPMethod, data: T, transactionId: String? = nil) throws {
self.method = method
self.dataId = data.stringId
self.body = try data.jsonString()
if let transactionId {
self.transactionId = transactionId
}
}
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()
}
func urlExtension() -> String {
switch self.method {
case HTTPMethod.put, HTTPMethod.delete:
return T.path(id: self.dataId)
case HTTPMethod.post:
return T.path()
case HTTPMethod.get:
if let parameters = self.urlParameters?.toQueryString() {
return T.path() + parameters
} else {
return T.path()
}
}
}
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() {
let apiCall = ApiCall(method: self.method, data: instance, transactionId: self.transactionId)
apiCall.id = self.id
apiCall.creationDate = self.creationDate
apiCall.attemptsCount = self.attemptsCount
apiCall.lastAttemptDate = self.lastAttemptDate
apiCall.urlParameters = self.urlParameters
return apiCall
} else {
return nil
}
}
}

@ -1,83 +0,0 @@
//
// DataAcces.swift
// LeStorage
//
// Created by Laurent Morvillier on 21/11/2024.
//
import Foundation
class DataAccess: SyncedModelObject, SyncedStorable {
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
static func resourceName() -> String { return "data-access" }
static func relationships() -> [Relationship] { return [] }
public static func parentRelationships() -> [Relationship] { return [] }
public static func childrenRelationships() -> [Relationship] { return [] }
static var copyServerResponse: Bool = false
static func storeParent() -> Bool { return false }
override required init() {
super.init()
}
var id: String = Store.randomId()
var sharedWith: [String] = []
var modelName: String = ""
var modelId: String = ""
var grantedAt: Date = Date()
init(owner: String, sharedWith: [String], modelName: String, modelId: String, storeId: String?) {
self.sharedWith = sharedWith
self.modelName = modelName
self.modelId = modelId
super.init()
self.relatedUser = owner
self.storeId = storeId
}
// Codable implementation
enum CodingKeys: String, CodingKey {
case id
case sharedWith
case modelName
case modelId
case grantedAt
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
sharedWith = try container.decode([String].self, forKey: .sharedWith)
modelName = try container.decode(String.self, forKey: .modelName)
modelId = try container.decode(String.self, forKey: .modelId)
grantedAt = try container.decode(Date.self, forKey: .grantedAt)
try super.init(from: decoder)
}
override func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(sharedWith, forKey: .sharedWith)
try container.encode(modelName, forKey: .modelName)
try container.encode(modelId, forKey: .modelId)
try container.encode(grantedAt, forKey: .grantedAt)
try super.encode(to: encoder)
}
func copy(from other: any Storable) {
guard let dataAccess = other as? DataAccess else { return }
self.id = dataAccess.id
self.sharedWith = dataAccess.sharedWith
self.modelName = dataAccess.modelName
self.modelId = dataAccess.modelId
self.storeId = dataAccess.storeId
self.grantedAt = dataAccess.grantedAt
}
public func copyForUpdate(from other: any Storable) {
self.copy(from: other)
}
}

@ -1,44 +0,0 @@
//
// DataLog.swift
// LeStorage
//
// Created by Laurent Morvillier on 11/10/2024.
//
import Foundation
class DataLog: ModelObject, Storable {
static func resourceName() -> String { return "data-logs" }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
static func relationships() -> [Relationship] { return [] }
public static func parentRelationships() -> [Relationship] { return [] }
public static func childrenRelationships() -> [Relationship] { return [] }
static func storeParent() -> Bool { return false }
var id: String = Store.randomId()
/// The id of the underlying data
var dataId: String
/// The name of class of the underlying data
var modelName: String
/// The operation performed on the underlying data
var operation: HTTPMethod
init(dataId: String, modelName: String, operation: HTTPMethod) {
self.dataId = dataId
self.modelName = modelName
self.operation = operation
}
func copy(from other: any Storable) {
fatalError("should not happen")
}
public func copyForUpdate(from other: any Storable) {
fatalError("should not happen")
}
}

@ -7,107 +7,38 @@
import Foundation
class FailedAPICall: SyncedModelObject, SyncedStorable {
class FailedAPICall: ModelObject, Storable {
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()
}
static func filterByStoreIdentifier() -> Bool { return false }
var id: String = Store.randomId()
/// The creation date of the call
var date: Date = Date()
/// The id of the API call
var callId: String
/// The type of the call
var type: String
/// The JSON representation of the API call
var apiCall: String
/// The server error
var error: String
/// The authentication header
var authentication: String?
init(callId: String, type: String, apiCall: String, error: String, authentication: String?) {
self.callId = callId
self.type = type
self.apiCall = apiCall
self.error = error
self.authentication = authentication
super.init()
}
// MARK: - Codable
enum CodingKeys: String, CodingKey {
case id
case date
case callId
case type
case apiCall
case error
case authentication
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
date = try container.decode(Date.self, forKey: .date)
callId = try container.decode(String.self, forKey: .callId)
type = try container.decode(String.self, forKey: .type)
apiCall = try container.decode(String.self, forKey: .apiCall)
error = try container.decode(String.self, forKey: .error)
authentication = try container.decodeIfPresent(String.self, forKey: .authentication)
try super.init(from: decoder)
}
override func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(date, forKey: .date)
try container.encode(callId, forKey: .callId)
try container.encode(type, forKey: .type)
try container.encode(apiCall, forKey: .apiCall)
try container.encode(error, forKey: .error)
try container.encodeIfPresent(authentication, forKey: .authentication)
try super.encode(to: encoder)
}
func copy(from other: any Storable) {
guard let fac = other as? FailedAPICall else { return }
self.date = fac.date
self.callId = fac.callId
self.type = fac.type
self.apiCall = fac.apiCall
self.error = fac.error
self.authentication = fac.authentication
}
public func copyForUpdate(from other: any Storable) {
self.copy(from: other)
}
}

@ -1,57 +0,0 @@
//
// SyncData.swift
// LeStorage
//
// Created by Laurent Morvillier on 18/10/2024.
//
import Foundation
class GetSyncData: SyncedModelObject, SyncedStorable, URLParameterConvertible {
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
static var copyServerResponse: Bool = false
static func storeParent() -> Bool { return false }
var date: String = ""
enum CodingKeys: String, CodingKey {
case date
}
override required init() {
super.init()
}
required public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
date = try container.decode(String.self, forKey: .date)
try super.init(from: decoder)
}
static func resourceName() -> String {
return "sync-data"
}
func copy(from other: any Storable) {
guard let getSyncData = other as? GetSyncData else { return }
self.date = getSyncData.date
}
public func copyForUpdate(from other: any Storable) {
fatalError("should not happen")
}
func queryParameters(storeCenter: StoreCenter) -> [String : String] {
return ["last_update" : self._formattedLastUpdate,
"device_id" : storeCenter.deviceId()]
}
fileprivate var _formattedLastUpdate: String {
let encodedDate = self.date.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
return encodedDate.replacingOccurrences(of: "+", with: "%2B")
}
static func relationships() -> [Relationship] { return [] }
public static func parentRelationships() -> [Relationship] { return [] }
public static func childrenRelationships() -> [Relationship] { return [] }
}

@ -7,67 +7,20 @@
import Foundation
class Log: SyncedModelObject, SyncedStorable {
class Log: ModelObject, Storable {
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()
}
static func filterByStoreIdentifier() -> Bool { return false }
var id: String = Store.randomId()
var date: Date = Date()
var user: String? = nil
var message: String = ""
init(message: String, user: String?) {
self.message = message
self.user = user
super.init()
}
// MARK: - Codable
enum CodingKeys: String, CodingKey {
case id
case date
case user
case message
}
required init(from decoder: Decoder) throws {
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)
}
var date: Date = Date()
override func encode(to encoder: Encoder) throws {
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)
}
var message: String
func copy(from other: any Storable) {
guard let log = other as? Log else { return }
self.date = log.date
self.message = log.message
init(message: String) {
self.message = 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
}
}

@ -16,19 +16,4 @@ class Settings: MicroStorable {
var userId: String? = nil
var username: String? = nil
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
}
}

@ -9,85 +9,20 @@ import Foundation
/// A class used as the root class for Storable objects
/// Provides default implementations of the Storable protocol
open class ModelObject: NSObject {
open class ModelObject {
public var store: Store? = nil
public override init() { }
public init() { }
open func deleteDependencies(store: Store, actionOption: ActionOption) {
open func deleteDependencies() throws {
}
open func deleteUnusedSharedDependencies(store: Store) {
// Default implementation does nothing
// Subclasses should override this to handle their specific dependencies
}
static var relationshipNames: [String] = []
}
open class BaseModelObject: ModelObject, Codable {
public var storeId: String? = nil
public override init() { }
// Coding Keys to map properties during encoding/decoding
enum CodingKeys: String, CodingKey {
case storeId
}
// Required initializer for Decodable
required public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.storeId = try container.decodeIfPresent(String.self, forKey: .storeId)
}
// Required method for Encodable
open func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.storeId, forKey: .storeId)
open func copyFromServerInstance(_ instance: any Storable) -> Bool {
return false
}
}
open class SyncedModelObject: BaseModelObject {
public var relatedUser: String? = nil
public var lastUpdate: Date = Date()
public var sharing: SharingStatus?
public override init() {
super.init()
}
static var relationshipNames: [String] = []
enum CodingKeys: String, CodingKey {
case relatedUser
case lastUpdate
case sharing = "_sharing"
}
// Required initializer for Decodable
required public init(from decoder: Decoder) throws {
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)
try super.init(from: decoder)
}
// Required method for Encodable
open override func encode(to encoder: Encoder) throws {
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)
}
try super.encode(to: encoder)
}
}

@ -1,60 +0,0 @@
//
// NetworkMonitor.swift
// LeStorage
//
// Created by Laurent Morvillier on 25/10/2024.
//
import Network
import Foundation
public class NetworkMonitor {
public static let shared = NetworkMonitor()
private var _monitor: NWPathMonitor
private var _queue = DispatchQueue(label: "lestorage.queue.network_monitor")
public var isConnected: Bool {
get {
return status == .satisfied
}
}
private(set) var status: NWPath.Status = .requiresConnection
// Closure to be called when connection is established
var onConnectionEstablished: (() -> Void)?
private init() {
_monitor = NWPathMonitor()
self._startMonitoring()
}
private func _startMonitoring() {
_monitor.pathUpdateHandler = { [weak self] path in
guard let self = self else { return }
// Update status
self.status = path.status
// Print status for debugging
Logger.log("Network Status: \(path.status)")
// Handle connection established
if path.status == .satisfied {
DispatchQueue.main.async {
self.onConnectionEstablished?()
}
}
}
self._monitor.start(queue: self._queue)
}
func stopMonitoring() {
self._monitor.cancel()
}
}

@ -1,18 +0,0 @@
//
// Notification+Name.swift
// LeStorage
//
// Created by Laurent Morvillier on 15/01/2025.
//
extension Notification.Name {
public static let CollectionDidLoad: Notification.Name = Notification.Name.init(
"notification.collectionDidLoad")
public static let CollectionDidChange: Notification.Name = Notification.Name.init(
"notification.collectionDidChange")
public static let LeStorageDidSynchronize: Notification.Name = Notification.Name.init(
"notification.leStorageDidSynchronize")
}

@ -1,66 +0,0 @@
//
// PendingOperationManager.swift
// LeStorage
//
// Created by Laurent Morvillier on 01/04/2025.
//
import Foundation
class PendingOperationManager<T: Storable> {
fileprivate(set) var items: [PendingOperation<T>] = []
fileprivate var _fileName: String
fileprivate var _inMemory: Bool = false
init(store: Store, inMemory: Bool) {
self._fileName = "\(store.storeCenter.directoryName)/pending_\(T.resourceName()).json"
self._inMemory = inMemory
if !inMemory {
do {
let url = try store.fileURL(fileName: self._fileName)
if FileManager.default.fileExists(atPath: url.path()) {
let jsonString = try FileUtils.readDocumentFile(fileName: self._fileName)
if let decoded: [PendingOperation<T>] = try jsonString.decode() {
self.items = decoded
}
}
} catch {
Logger.error(error)
}
}
}
var typeName: String { return String(describing: T.self) }
func addPendingOperation(method: StorageMethod, instance: T, actionOption: ActionOption) {
Logger.log("addPendingOperation: \(method), \(instance)")
let operation = PendingOperation<T>(method: method, data: instance, actionOption: actionOption)
self.items.append(operation)
self._writeIfNecessary()
}
func reset() {
self.items.removeAll()
self._writeIfNecessary()
}
fileprivate func _writeIfNecessary() {
guard !self._inMemory else { return }
do {
let jsonString: String = try self.items.jsonString()
Task(priority: .background) {
let _ = try FileUtils.writeToDocumentDirectory(content: jsonString, fileName: self._fileName)
}
} catch {
Logger.error(error)
}
}
}

@ -1,31 +0,0 @@
//
// Relationship.swift
// LeStorage
//
// Created by Laurent Morvillier on 27/11/2024.
//
public enum StoreLookup {
case same
case main
case child
}
public struct Relationship {
public init(type: any Storable.Type, keyPath: AnyKeyPath, storeLookup: StoreLookup) {
self.type = type
self.keyPath = keyPath
self.storeLookup = storeLookup
}
/// The type of the relationship
var type: any Storable.Type
/// the keyPath to access the relationship
var keyPath: AnyKeyPath
/// Indicates whether the linked object is on the main Store
var storeLookup: StoreLookup
}

File diff suppressed because it is too large Load Diff

@ -8,8 +8,8 @@
import Foundation
/// A protocol describing classes that can be stored locally in JSON and synchronized on our django server
public protocol Storable: Codable, Identifiable, NSObjectProtocol {
public protocol Storable: Codable, Identifiable where ID : StringProtocol {
/// The store containing a reference to the instance
var store: Store? { get set }
@ -17,29 +17,26 @@ public protocol Storable: Codable, Identifiable, NSObjectProtocol {
/// Also used as the name of the local file
static func resourceName() -> String
/// Returns HTTP methods that do not need to pass the token to the request
static func tokenExemptedMethods() -> [HTTPMethod]
/// This method is only used if the instance store uses an identifier
/// This method should return true if the resources need to get filtered using the store identifier when performing a GET
/// Returning false won't filter the resources when performing a GET
static func filterByStoreIdentifier() -> Bool
/// A method that deletes the local dependencies of the resource
/// Mimics the behavior of the cascading delete on the django server
/// Mimics the behavior 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)
func deleteDependencies() throws
/// 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)
/// A method called to retrieve data added by the server on a POST request
/// The method will be called after a POST has succeeded,
/// and will provide a copy of what's on the server
func copyFromServerInstance(_ instance: any Storable) -> Bool
/// 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
func copy(from other: any Storable)
/// 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
static var relationshipNames: [String] { get }
}
@ -51,16 +48,7 @@ extension Storable {
}
/// Returns a string id for the instance
public var stringId: String {
switch self.id {
case let sp as any StringProtocol:
return String(sp)
case let intLitteral as any ExpressibleByIntegerLiteral:
return "\(intLitteral)"
default:
fatalError("id not convertible to string")
}
}
var stringId: String { return String(self.id) }
/// Returns the relative path of the instance for the django server
static func path(id: String? = nil) -> String {
@ -72,15 +60,20 @@ extension Storable {
return path
}
static func buildRealId(id: String) -> ID {
switch ID.self {
case is String.Type:
return id as! ID
case is Int64.Type:
return Formatter.number.number(from: id)?.int64Value as! ID
default:
fatalError("ID \(type(of: ID.self)) is neither String nor Int, can't parse \(id)")
}
public static func storageDirectoryPath() throws -> URL {
return try FileUtils.pathForDirectoryInDocuments(directory: Store.storageDirectory)
}
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)
}
static func urlForJSONFile() throws -> URL {
var storageDirectory = try self.storageDirectoryPath()
storageDirectory.append(component: self.fileName())
return storageDirectory
}
}

@ -8,111 +8,61 @@
import Foundation
import UIKit
public enum StoreError: Error, LocalizedError {
public enum StoreError: Error {
case missingService
case missingUserId
case missingUsername
case missingToken
case missingKeychainStore
case collectionNotRegistered(type: String)
case unexpectedCollectionType(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)"
}
}
case collectionNotRegistered(type: String)
case cannotSyncCollection(name: String)
}
public struct StoreIdentifier {
var value: String
var parameterName: String
public var errorDescription: String? {
switch self {
case .missingService:
return "Services instance is nil"
case .missingUsername:
return "The username is missing"
case .missingUserId:
return "The user id is missing"
case .missingToken:
return "There is no stored token"
case .missingKeychainStore:
return "There is no keychain store"
case .collectionNotRegistered(let type):
return "The collection \(type) is not registered"
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)"
}
public init(value: String, parameterName: String) {
self.value = value
self.parameterName = parameterName
}
var urlComponent: String {
return "?\(self.parameterName)=\(self.value)"
}
}
final public class Store {
open 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
fileprivate(set) var identifier: StoreIdentifier? = nil
public init(storeCenter: StoreCenter) {
self.storeCenter = storeCenter
}
/// Indicates whether the store directory has been created at the init
fileprivate var _created: Bool = false
public required init(storeCenter: StoreCenter, identifier: String) {
self.storeCenter = storeCenter
self.identifier = identifier
let directory = "\(storeCenter.directoryName)/\(identifier)"
self._createDirectory(directory: directory)
public init() {
self._createDirectory(directory: Store.storageDirectory)
}
public static var main: Store { return StoreCenter.main.mainStore }
public func alternateStore(identifier: String) throws -> Store {
return try self.storeCenter.store(identifier: identifier)
public required init(identifier: String, parameter: String) {
self.identifier = StoreIdentifier(value: identifier, parameterName: parameter)
let directory = "\(Store.storageDirectory)/\(identifier)"
self._createDirectory(directory: directory)
}
/// Creates the store directory
/// - Parameters:
/// - directory: the name of the directory
fileprivate func _createDirectory(directory: String) {
FileManager.default.createDirectoryInDocuments(directoryName: directory)
self._created = FileManager.default.createDirectoryInDocuments(directoryName: directory)
}
/// A method to provide ids corresponding to the django storage
@ -122,72 +72,40 @@ final public class Store {
/// Registers a collection
/// - Parameters:
/// - synchronized: indicates if the data is synchronized with the server
/// - 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 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
}
/// - sendsUpdate: Indicates if updates of items should be sent to the server
public func registerCollection<T : Storable>(synchronized: Bool, indexed: Bool = false, inMemory: Bool = false, sendsUpdate: Bool = true) -> StoredCollection<T> {
let collection = StoredCollection<T>(store: self, indexed: indexed, inMemory: inMemory, limit: limit)
let collection = StoredCollection<T>(synchronized: synchronized, store: self, indexed: indexed, inMemory: inMemory, sendsUpdate: sendsUpdate)
self._collections[T.resourceName()] = collection
self._baseCollections[T.resourceName()] = collection
return collection
}
/// Registers a synchronized collection
/// - 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 synchronized {
StoreCenter.main.loadApiCallCollection(type: T.self)
}
if let collection: SyncedCollection<T> = try? self.syncedCollection() {
return collection
if self._created, let identifier {
self._migrate(collection, identifier: identifier, type: T.self)
}
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)
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)
self._collections[T.resourceName()] = collection
self._baseCollections[T.resourceName()] = collection
return collection
}
/// Registers a singleton object
/// - Parameters:
/// - 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>(synchronized: synchronized, store: self, inMemory: inMemory, sendsUpdate: sendsUpdate)
self._collections[T.resourceName()] = storedObject
if synchronized {
self.storeCenter.loadApiCallCollection(type: T.self)
StoreCenter.main.loadApiCallCollection(type: T.self)
}
return storedObject
}
@ -196,241 +114,62 @@ final public class Store {
/// Looks for an instance by id
/// - 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 {
public func findById<T: Storable>(_ id: String) -> T? {
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
/// 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 []
}
throw StoreError.collectionNotRegistered(type: T.resourceName())
}
/// 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> {
do {
return try self.syncedCollection()
} catch {
return self.registerSynchronizedCollection(indexed: true, inMemory: false, noLoad: true)
}
}
/// Loads all collection with the data from the server
public func loadCollectionsFromServer(clear: Bool) {
for collection in self._syncedCollections() {
Task {
do {
try await collection.loadDataFromServerIfAllowed(clear: clear)
} catch {
Logger.error(error)
}
}
}
}
/// Loads all synchronized collection with server data if they don't already have a local file
public func loadCollectionsFromServerIfNoFile() {
for collection in self._syncedCollections() {
Task {
do {
try await collection.loadCollectionsFromServerIfNoFile()
} catch {
Logger.error(error)
public func loadCollectionsFromServer() {
for collection in self._collections.values {
if collection.synchronized {
Task {
try? await collection.loadDataFromServerIfAllowed()
}
}
}
}
/// Returns the list of synchronized collection inside the store
fileprivate func _syncedCollections() -> [any SomeSyncedCollection] {
return self._collections.values.compactMap { $0 as? any SomeSyncedCollection }
}
/// Resets all registered collection
public func reset() {
for collection in self._collections.values {
collection.reset()
}
}
// 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)
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]) {
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)
}
}
} 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)
}
}
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)
}
/// Returns the names of all collections
public func collectionNames() -> [String] {
return self._collections.values.map { $0.resourceName }
}
// MARK: - Write
/// Returns the directory URL of the store
fileprivate func _directoryPath() throws -> URL {
var url = try FileUtils.pathForDirectoryInDocuments(directory: storeCenter.directoryName)
if let identifier {
fileprivate func _directoryPath() throws -> URL {
var url = try FileUtils.pathForDirectoryInDocuments(directory: Store.storageDirectory)
if let identifier = self.identifier?.value {
url.append(component: identifier)
}
return url
@ -444,22 +183,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
@ -477,27 +208,91 @@ 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)
public func getItems<T: Storable>() async throws -> [T] {
if T.filterByStoreIdentifier() {
return try await StoreCenter.main.getItems(identifier: self.identifier)
} else {
return try await self.storeCenter.getItems()
return try await StoreCenter.main.getItems()
}
}
/// Requests an insertion to the StoreCenter
/// - Parameters:
/// - instance: an object to insert
func sendInsertion<T: Storable>(_ instance: T) async throws -> T? {
return try await StoreCenter.main.sendInsertion(instance)
}
func loadCollectionItems<T: SyncedStorable>(_ items: [T], clear: Bool) async {
do {
let collection: SyncedCollection<T> = try self.syncedCollection()
await collection.loadItems(items, clear: clear)
} catch {
Logger.error(error)
/// Requests an update to the StoreCenter
/// - Parameters:
/// - instance: an object to update
@discardableResult func sendUpdate<T: Storable>(_ instance: T) async throws -> T? {
return try await StoreCenter.main.sendUpdate(instance)
}
/// Requests a deletion to the StoreCenter
/// - Parameters:
/// - instance: an object to delete
@discardableResult func sendDeletion<T: Storable>(_ instance: T) async throws -> T? {
return try await StoreCenter.main.sendDeletion(instance)
}
/// Loads all synchronized collection with server data if they don't already have a local file
public func loadCollectionsFromServerIfNoFile() {
for collection in self._collections.values {
if collection.synchronized {
Task {
do {
try await collection.loadCollectionsFromServerIfNoFile()
} catch {
Logger.error(error)
}
}
}
}
}
/// Returns whether all collections have loaded locally
public func fileCollectionsAllLoaded() -> Bool {
let fileCollections = self._collections.values.filter { $0.inMemory == false }
return fileCollections.allSatisfy { $0.hasLoaded }
public func collectionsAllLoaded() -> Bool {
return self._collections.values.allSatisfy { $0.hasLoaded }
}
fileprivate var _validIds: [String] = []
fileprivate func _migrate<T : Storable>(_ collection: StoredCollection<T>, identifier: StoreIdentifier, type: T.Type) {
self._validIds.append(identifier.value)
let oldCollection: StoredCollection<T> = StoredCollection<T>(synchronized: false, store: Store.main, asynchronousIO: false)
let filtered: [T] = oldCollection.items.filter { item in
var propertyValue: String? = item.stringForPropertyName(identifier.parameterName)
if propertyValue == nil {
let values = T.relationshipNames.map { item.stringForPropertyName($0) }
propertyValue = values.compactMap { $0 }.first
}
return self._validIds.first(where: { $0 == propertyValue }) != nil
}
if filtered.count > 0 {
self._validIds.append(contentsOf: filtered.map { $0.stringId })
try? collection.addOrUpdateNoSync(contentOfs: filtered)
Logger.log("Migrated \(filtered.count) \(T.resourceName())")
}
}
}
fileprivate extension Storable {
func stringForPropertyName(_ propertyName: String) -> String? {
let mirror = Mirror(reflecting: self)
for child in mirror.children {
if let label = child.label, label == "_\(propertyName)" {
return child.value as? String
}
}
return nil
}
}

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

@ -6,625 +6,488 @@
//
import Foundation
import Combine
public protocol SomeCollection<Item>: Identifiable {
associatedtype Item: Storable
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()
enum StoredCollectionError: Error {
case unmanagedHTTPMethod(method: String)
case missingApiCallCollection
case missingInstance
}
protocol CollectionDelegate<Item> {
associatedtype Item: Storable
func loadingForMemoryCollection() async
func itemMerged(_ pendingOperation: PendingOperation<Item>)
protocol CollectionHolder {
associatedtype Item
var items: [Item] { get }
func reset()
}
enum CollectionMethod {
case insert
case update
case delete
}
protocol SomeCollection: CollectionHolder, Identifiable {
var resourceName: String { get }
var synchronized: Bool { get }
var hasLoaded: Bool { get }
public struct ActionResult<T> {
var instance: T
var method: CollectionMethod
var pending: Bool
func allItems() -> [any Storable]
func loadDataFromServerIfAllowed() async throws
func loadCollectionsFromServerIfNoFile() async throws
}
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)
extension Notification.Name {
public static let CollectionDidLoad: Notification.Name = Notification.Name.init("notification.collectionDidLoad")
public static let CollectionDidChange: Notification.Name = Notification.Name.init("notification.collectionDidChange")
}
public class StoredCollection<T: Storable>: SomeCollection {
public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollection, CollectionHolder {
public typealias Item = T
/// If true, will synchronize the data with the provided server located at the Store's synchronizationApiURL
let synchronized: Bool
/// Doesn't write the collection in a file
fileprivate(set) public var inMemory: Bool = false
fileprivate var _inMemory: Bool = false
/// Indicates if the synchronized collection sends update to the API
fileprivate var _sendsUpdate: Bool = true
/// The list of stored items
@Published public fileprivate(set) var items: [T] = []
/// The reference to the Store
fileprivate(set) var store: Store
/// Provides fast access for instances if the collection has been instanced with [indexed] = true
fileprivate var _indexes: [T.ID: T]? = nil
/// A PendingOperationManager instance that manages operations while the collection is not loaded
fileprivate(set) var pendingOperationManager: PendingOperationManager<T>? = nil
fileprivate var _store: Store
fileprivate var _writingTimer: Timer? = nil
/// Provides fast access for instances if the collection has been instanced with [indexed] = true
fileprivate var _indexes: [String : T]? = 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 {
if self._hasChanged == true {
self._scheduleWrite()
self._triggerWrite = false
}
DispatchQueue.main.async {
NotificationCenter.default.post(
name: NSNotification.Name.CollectionDidChange, object: self)
DispatchQueue.main.async {
NotificationCenter.default.post(name: NSNotification.Name.CollectionDidChange, object: self)
}
self._hasChanged = false
}
}
}
/// Denotes a collection that loads and writes asynchronously
fileprivate var asynchronousIO: Bool = true
/// Indicates if the collection has loaded locally, with or without a file
fileprivate(set) public var hasLoaded: Bool = false
/// 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(synchronized: Bool, store: Store, indexed: Bool = false, asynchronousIO: Bool = true, inMemory: Bool = false, sendsUpdate: Bool = true) {
self.synchronized = synchronized
self.asynchronousIO = asynchronousIO
if indexed {
self._indexes = [:]
}
self.inMemory = inMemory
self.store = store
self.limit = limit
if noLoad {
self.hasLoaded = true
} else {
Task {
await self.load()
}
}
self._inMemory = inMemory
self._sendsUpdate = sendsUpdate
self._store = store
self._load()
}
init(store: Store) {
self.store = store
fileprivate init() {
self.synchronized = false
self._store = Store.main
}
var storeCenter: StoreCenter { return self.store.storeCenter }
/// Returns the name of the managed resource
public var resourceName: String {
return T.resourceName()
public static func placeholder() -> StoredCollection<T> {
return StoredCollection<T>()
}
public var storeId: String? {
return self.store.identifier
var resourceName: String {
return T.resourceName()
}
// MARK: - Loading
/// Sets the collection as changed to trigger a write
public func requestWriteIfNecessary() {
if self.inMemory == false {
self._triggerWrite = 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()
}
}
}
/// Starts the JSON file decoding asynchronously
func loadFromFile() async {
fileprivate func _load() {
do {
try await self._decodeJSONFile()
if self._inMemory {
Task {
try await self.loadDataFromServerIfAllowed()
}
} else {
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
fileprivate func _loadFromFile() throws {
if self.asynchronousIO {
Task(priority: .high) {
try self._decodeJSONFile()
}
} else {
try self._decodeJSONFile()
}
}
/// Decodes the json file into the items array
fileprivate func _decodeJSONFile() async throws {
let fileURL = try self.store.fileURL(type: T.self)
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)
}
await MainActor.run {
self.setAsLoaded()
for var item in decoded {
item.store = self._store
}
if self.asynchronousIO {
DispatchQueue.main.async {
self._setItems(decoded)
self._setAsLoaded()
}
} else {
self._setItems(decoded)
self._setAsLoaded()
}
} else {
self._setAsLoaded()
}
}
/// Sets the collection as loaded
/// Send a CollectionDidLoad event
@MainActor
func setAsLoaded() {
fileprivate func _setAsLoaded() {
self.hasLoaded = true
self._mergePendingOperations()
NotificationCenter.default.post(
name: NSNotification.Name.CollectionDidLoad, object: self)
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()
for item in items {
self._addItem(instance: item)
}
fileprivate func _setItems(_ items: [T]) {
self.items = items
self._updateIndexIfNecessary()
}
@MainActor
func loadAndWrite(_ items: [T], clear: Bool = false) {
if clear {
self.setItems(items)
self.setAsLoaded()
} else {
self.setAsLoaded()
self.addOrUpdate(contentOfs: items)
}
self.requestWriteIfNecessary()
}
/// Updates the whole index with the items array
fileprivate func _updateIndexIfNecessary() {
if self._indexes != nil {
self._indexes = self.items.dictionary { $0.id }
if let _ = self._indexes {
self._indexes = self.items.dictionary { $0.stringId }
}
}
// 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)
}
/// Adds or update a sequence of elements
public func addOrUpdate(contentOfs sequence: any Sequence<T>, _ handler: ((ActionResult<T>) -> ())? = nil) {
defer {
self.requestWriteIfNecessary()
/// Retrieves the data from the server and loads it into the items array
public func loadDataFromServerIfAllowed() async throws {
guard self.synchronized, !(self is StoredSingleton<T>) else {
throw StoreError.cannotSyncCollection(name: self.resourceName)
}
for instance in sequence {
let result = self._rawAddOrUpdate(instance: instance)
handler?(result)
do {
let items: [T] = try await self._store.getItems()
if items.count > 0 {
DispatchQueue.main.async {
self._addOrUpdate(contentOfs: items, shouldSync: false)
}
}
} catch {
Logger.error(error)
}
self._setAsLoaded()
}
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)
} else {
let added = self._addItem(instance: instance)
return ActionResult(instance: instance, method: .insert, pending: !added)
/// 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()
}
}
/// A method the treat the collection as a single instance holder
func setSingletonNoSync(instance: T) {
// MARK: - Basic operations
/// Adds or updates the provided instance inside the collection
/// Adds it if its id is not found, and otherwise updates it
public func addOrUpdate(instance: T) throws {
defer {
self.requestWriteIfNecessary()
self._hasChanged = true
}
self.clear()
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 the instance in the collection and sets the collection as changed to trigger a write
public func delete(instance: T, actionOption: ActionOption) {
defer {
self._triggerWrite = true
var item = instance
item.store = self._store
// update
if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
self.items[index] = instance
self._sendUpdateIfNecessary(instance)
} else { // insert
self.items.append(instance)
self._sendInsertionIfNecessary(instance)
}
self.deleteItem(instance, actionOption: actionOption)
}
self._indexes?[instance.stringId] = 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)
}
func delete(contentOfs sequence: any RandomAccessCollection<T>, actionOption: ActionOption, handler: ((ActionResult<T>) -> ())? = nil) {
/// Sends a POST request for the instance, and changes the collection to perform a write
public func writeChangeAndInsertOnServer(instance: T) {
defer {
self._triggerWrite = true
}
for instance in sequence {
let deleted = self.deleteItem(instance, actionOption: actionOption)
handler?(ActionResult(instance: instance, method: .delete, pending: !deleted))
}
}
/// This method sets the storeId for the given instance if the collection belongs to a store with an id
fileprivate func _affectStoreIdIfNecessary(instance: T) {
if let storeId = self.store.identifier {
if var altStorable = instance as? SideStorable {
altStorable.storeId = storeId
} else {
fatalError("instance does not implement SideStorable, thus sync cannot work")
}
self._hasChanged = true
}
}
func add(instance: T, actionOption: ActionOption) {
self._addItem(instance: instance, actionOption: actionOption)
self._sendInsertionIfNecessary(instance)
}
/// 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
/// A method the treat the collection as a single instance holder
func setSingletonNoSync(instance: T) {
defer {
self._hasChanged = true
}
self.invalidateCache()
self._affectStoreIdIfNecessary(instance: instance)
self.items.removeAll()
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()
let item = self.items[index]
if item !== instance {
self.items[index].copy(from: instance)
}
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 {
/// Deletes the instance in the collection by id
public func delete(instance: T) throws {
if !self.hasLoaded {
self.addPendingOperation(method: .delete, instance: instance, actionOption: actionOption)
return false
}
if actionOption.cascade {
instance.deleteDependencies(store: self.store, actionOption: actionOption)
defer {
self._hasChanged = true
}
self.localDeleteOnly(instance: instance)
try instance.deleteDependencies()
self.items.removeAll { $0.id == instance.id }
self._indexes?.removeValue(forKey: instance.stringId)
if T.storeParent() {
self.storeCenter.destroyStore(identifier: instance.stringId)
}
return true
self._sendDeletionIfNecessary(instance)
}
/// Deletes an instance from the collection
@discardableResult func deleteUnusedShared(_ instance: T, actionOption: ActionOption) -> Bool {
/// Deletes all items of the sequence by id
public func delete(contentOfs sequence: any Sequence<T>) throws {
if !self.hasLoaded {
self.addPendingOperation(method: .deleteUnusedShared, instance: instance, actionOption: actionOption)
return false
defer {
self._hasChanged = true
}
// 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
for instance in sequence {
try instance.deleteDependencies()
self.items.removeAll { $0.id == instance.id }
self._indexes?.removeValue(forKey: instance.stringId)
self._sendDeletionIfNecessary(instance)
}
}
func localDeleteOnly(instance: T) {
self.invalidateCache()
self.items.removeAll { $0.id == instance.id }
self._indexes?.removeValue(forKey: instance.id)
/// Adds or update a sequence of elements
public func addOrUpdate(contentOfs sequence: any Sequence<T>) throws {
self._addOrUpdate(contentOfs: sequence)
}
/// If the collection has more instance that its limit, remove the surplus
fileprivate func _applyLimitIfPresent() {
if let limit {
self.items = self.items.suffix(limit)
}
/// Adds or update a sequence of elements without synchronizing it
func addOrUpdateNoSync(contentOfs sequence: any Sequence<T>) throws {
self._addOrUpdate(contentOfs: sequence, shouldSync: false)
}
func deleteByStringId(_ id: String, actionOption: ActionOption = .cascade) {
let realId = T.buildRealId(id: id)
if let instance = self.findById(realId) {
self.deleteItem(instance, actionOption: actionOption)
/// Inserts or updates all items in the sequence
fileprivate func _addOrUpdate(contentOfs sequence: any Sequence<T>, shouldSync: Bool = true) {
defer {
self._hasChanged = true
}
if actionOption.write {
self.requestWriteIfNecessary()
for var instance in sequence {
if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
self.items[index] = instance
if shouldSync {
self._sendUpdateIfNecessary(instance)
}
} else { // insert
self.items.append(instance)
if shouldSync {
self._sendInsertionIfNecessary(instance)
}
}
instance.store = self._store
self._indexes?[instance.stringId] = instance
}
}
/// Returns the instance corresponding to the provided [id]
public func findById(_ id: T.ID) -> T? {
public func findById(_ id: String) -> T? {
if let index = self._indexes, let instance = index[id] {
return instance
}
return self.items.first(where: { $0.id == id })
}
/// Deletes a list of items
/// Deletes the instance corresponding to the provided [id]
public func deleteById(_ id: String) throws {
if let instance = self.findById(id) {
try self.delete(instance: instance)
}
}
/// Proceeds to "hard" delete the items without synchronizing them
/// Also removes related API calls
public func deleteDependencies(_ items: any Sequence<T>) {
defer {
self.requestWriteIfNecessary()
self._hasChanged = true
}
self.invalidateCache()
let itemsArray = Array(items) // fix error if items is self.items
for item in itemsArray {
for item in items {
if let index = self.items.firstIndex(where: { $0.id == item.id }) {
self.items.remove(at: index)
}
Task {
do {
try await StoreCenter.main.deleteApiCallByDataId(type: T.self, id: item.stringId)
} catch {
Logger.error(error)
}
}
}
}
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 {
try self.delete(contentOfs: self.items)
}
// MARK: - Migrations
fileprivate func _delete(contentOfs sequence: any RandomAccessCollection<T>, actionOption: ActionOption) {
for instance in sequence {
self.deleteItem(instance, actionOption: actionOption)
/// Makes POST ApiCall for all items in the collection
public func insertAllIntoCurrentService() {
for item in self.items {
self._sendInsertionIfNecessary(item)
}
}
// 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)
/// Makes POST ApiCall for the provided item
public func insertIntoCurrentService(item: T) {
self._sendInsertionIfNecessary(item)
}
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)
}
}
manager.reset()
self.pendingOperationManager = nil
// MARK: - SomeCall
/// Returns the collection items as [any Storable]
func allItems() -> [any Storable] {
return self.items
}
// MARK: - File access
/// Schedules a write operation
fileprivate func _scheduleWrite() {
self._cleanTimer()
DispatchQueue.main.async {
self._writingTimer = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(self._write), userInfo: nil, repeats: false)
guard !self._inMemory else { return }
if self.asynchronousIO {
DispatchQueue(label: "lestorage.queue.write", qos: .utility).asyncAndWait { // sync to make sure we don't have writes performed at the same time
self._write()
}
} else {
self._write()
}
}
fileprivate func _cleanTimer() {
self._writingTimer?.invalidate()
self._writingTimer = nil
}
/// Writes all the items as a json array inside a file
@objc fileprivate func _write() {
DispatchQueue(label: "lestorage.queue.write", qos: .utility).async {
do {
let jsonString: String = try self.items.jsonString()
try self.store.write(content: jsonString, fileName: T.fileName())
} catch {
Logger.error(error)
self.storeCenter.log(
message: "write failed for \(T.resourceName()): \(error.localizedDescription)")
}
fileprivate func _write() {
// Logger.log("Start write to \(T.fileName())...")
do {
let jsonString: String = try self.items.jsonString()
try self._store.write(content: jsonString, fileName: T.fileName())
} catch {
Logger.error(error) // TODO how to notify the main project
}
self._cleanTimer()
// Logger.log("End write")
}
/// 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.store.removeFile(type: T.self)
self.items.removeAll()
self._store.removeFile(type: T.self)
}
public 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 }
for item in self.items {
for relationship in relationships {
if item[keyPath: relationship.keyPath] as? String == id {
return true
// MARK: - Reschedule calls
/// 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) {
guard self.synchronized else {
return
}
Task {
do {
if let result = try await self._store.sendInsertion(instance) {
DispatchQueue.main.async {
self._hasChanged = instance.copyFromServerInstance(result)
}
}
} catch {
Logger.error(error)
}
}
return false
}
// MARK: - for Synced Collection
@MainActor
func updateLocalInstance(_ serverInstance: T) {
if let localInstance = self.findById(serverInstance.id) {
localInstance.copy(from: serverInstance)
self.requestWriteIfNecessary()
/// Sends an update api call for the provided [instance]
/// - Parameters:
/// - instance: the object to PUT
fileprivate func _sendUpdateIfNecessary(_ instance: T) {
guard self.synchronized, self._sendsUpdate else {
return
}
}
// 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
Task {
do {
try await self._store.sendUpdate(instance)
} catch {
Logger.error(error)
}
}
let result = compute(items)
self._queryCache[key] = (self._cacheVersion, result)
return result
}
private func invalidateCache() {
self._cacheVersion += 1
/// Sends an delete api call for the provided [instance]
/// - Parameters:
/// - instance: the object to DELETE
fileprivate func _sendDeletionIfNecessary(_ instance: T) {
guard self.synchronized else {
return
}
Task {
do {
try await self._store.sendDeletion(instance)
} catch {
Logger.error(error)
}
}
}
}
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 }
public var endIndex: Int { return self.items.endIndex }
public func index(after i: Int) -> Int {
return self.items.index(after: i)
return self.items.index(after: i)
}
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,53 +8,28 @@
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: Storable>: 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
public func update() {
public func update() throws {
if let item = self.item() {
self.addOrUpdate(instance: item)
try self.addOrUpdate(instance: item)
}
}
/// 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
public override func addOrUpdate(contentOfs sequence: any Sequence<T>) {
fatalError("method unavailable for StoredSingleton, use update")
}
func addOrUpdateIfNewer(_ instance: T) {
public override func addOrUpdate(contentOfs sequence: any Sequence<T>) throws {
fatalError("method unavailable for StoredSingleton, use update")
}

@ -1,478 +0,0 @@
//
// SyncedCollection.swift
// LeStorage
//
// Created by Laurent Morvillier on 11/10/2024.
//
import Foundation
protocol SomeSyncedCollection: SomeCollection {
func loadDataFromServerIfAllowed(clear: Bool) async throws
func loadCollectionsFromServerIfNoFile() async throws
}
public class SyncedCollection<T : SyncedStorable>: SomeSyncedCollection, CollectionDelegate {
public typealias Item = T
let store: Store
let collection: StoredCollection<T>
init(store: Store, indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil, noLoad: Bool = false) {
self.store = store
self.collection = StoredCollection<T>(store: store, indexed: indexed, inMemory: inMemory, limit: limit, noLoad: noLoad)
}
init(store: Store, inMemory: Bool) async {
self.store = store
self.collection = await StoredCollection(store: store, inMemory: inMemory)
}
var storeCenter: StoreCenter { return self.store.storeCenter }
public var storeId: String? {
return self.store.identifier
}
/// Returns a dummy SyncedCollection instance
public static func placeholder() -> SyncedCollection<T> {
return SyncedCollection<T>(store: Store(storeCenter: StoreCenter.main))
}
/// Loads the collection using the server data only if the collection file doesn't exists
func loadCollectionsFromServerIfNoFile() async throws {
let fileURL: URL = try self.store.fileURL(type: T.self)
if !FileManager.default.fileExists(atPath: fileURL.path()) {
try await self.loadDataFromServerIfAllowed()
}
}
/// Retrieves the data from the server and loads it into the items array
public func loadDataFromServerIfAllowed(clear: Bool = false) async throws {
do {
try await self.storeCenter.sendGetRequest(T.self, storeId: self.storeId, clear: clear)
} catch {
Logger.error(error)
}
}
func loadOnceAsync() async throws {
let items: [T] = try await self.storeCenter.service().get()
await self.loadItems(items, clear: true)
}
/// Updates a local item from a server instance. This method is typically used when the server makes update
/// to an object when it's inserted. The SyncedCollection possibly needs to update its own copy with new values.
/// - serverInstance: the instance of the object on the server
func updateFromServerInstance(_ serverInstance: T) {
guard T.copyServerResponse else {
return
}
Task {
await self.collection.updateLocalInstance(serverInstance)
}
}
@MainActor
func loadItems(_ items: [T], clear: Bool = false) {
self.collection.loadAndWrite(items, clear: clear)
}
// MARK: - Basic operations with sync
/// Adds or update an instance synchronously, dispatching network operations to background tasks
public func addOrUpdate(instance: T) {
let result = _addOrUpdateCore(instance: instance)
if result.method == .insert {
Task { await self._sendInsertion(instance) }
} else {
Task { await self._sendUpdate(instance) }
}
}
/// Private helper function that contains the shared logic
private func _addOrUpdateCore(instance: T) -> ActionResult<T> {
instance.lastUpdate = Date()
let result = self.collection.addOrUpdate(instance: instance)
if result.method == .update {
if instance.sharing != nil {
self._cleanUpSharedDependencies()
}
}
return result
}
fileprivate func _addOrUpdateCore(contentOfs sequence: any Sequence<T>) -> OperationBatch<T> {
let date = Date()
let batch = OperationBatch<T>()
for instance in sequence {
instance.lastUpdate = date
let result = self.collection.addOrUpdate(instance: instance)
if result.method == .insert {
batch.addInsert(instance)
} else {
batch.addUpdate(instance)
}
}
self._cleanUpSharedDependencies()
return batch
}
/// Adds or update a sequence and writes
public func addOrUpdate(contentOfs sequence: any Sequence<T>) {
let batch = self._addOrUpdateCore(contentOfs: sequence)
Task { await self._sendOperationBatch(batch) }
}
/// Deletes an instance and writes
public func delete(instance: T) {
self.collection.delete(instance: instance, actionOption: .syncedCascade)
self.storeCenter.createDeleteLog(instance)
Task { await self._sendDeletion(instance) }
}
/// Deletes all items of the sequence by id and sets the collection as changed to trigger a write
public func delete(contentOfs sequence: any RandomAccessCollection<T>) {
self.delete(contentOfs: sequence, actionOption: .syncedCascade)
}
func delete(contentOfs sequence: any RandomAccessCollection<T>, actionOption: ActionOption) {
guard sequence.isNotEmpty else { return }
let batch = self._deleteCore(contentOfs: sequence, actionOption: actionOption)
if actionOption.synchronize {
Task { await self._sendOperationBatch(batch) }
}
}
/// Deletes all items of the sequence by id and sets the collection as changed to trigger a write
fileprivate func _deleteCore(contentOfs sequence: any RandomAccessCollection<T>, actionOption: ActionOption) -> OperationBatch<T> {
var deleted: [T] = []
self.collection.delete(contentOfs: sequence, actionOption: actionOption) { result in
self.storeCenter.createDeleteLog(result.instance)
if !result.pending {
deleted.append(result.instance)
}
}
let batch = OperationBatch<T>()
batch.deletes = deleted
return batch
}
fileprivate func _cleanUpSharedDependencies() {
for relationship in T.relationships() {
if let syncedType = relationship.type as? (any SyncedStorable.Type) {
do {
try self._deleteUnusedSharedInstances(relationship: relationship, type: syncedType, originStoreId: self.storeId)
} catch {
Logger.error(error)
}
}
}
}
fileprivate func _deleteUnusedSharedInstances<S: SyncedStorable>(relationship: Relationship, type: S.Type, originStoreId: String?) throws {
let store: Store
switch relationship.storeLookup {
case .main: store = self.store.storeCenter.mainStore
case .same: store = self.store
case .child:
throw StoreError.invalidStoreLookup(from: type, to: relationship.type)
}
let collection: SyncedCollection<S> = try store.syncedCollection()
collection._deleteUnusedGrantedInstances(originStoreId: originStoreId)
}
fileprivate func _deleteUnusedGrantedInstances(originStoreId: String?) {
let sharedItems = self.collection.items.filter { $0.sharing == .granted }
for sharedItem in sharedItems {
self.store.deleteUnusedGrantedIfNecessary(sharedItem, originStoreId: originStoreId
)
}
}
public func deleteAllItemsAndDependencies(actionOption: ActionOption) {
if actionOption.synchronize {
self.delete(contentOfs: self.items, actionOption: actionOption)
} else {
self.collection.deleteAllItemsAndDependencies(actionOption: actionOption)
}
}
public func deleteDependencies(actionOption: ActionOption, _ isIncluded: (T) -> Bool) {
let items = self.items.filter(isIncluded)
if actionOption.synchronize {
self.delete(contentOfs: items, actionOption: actionOption)
} else {
self.collection.delete(contentOfs: items)
}
}
// MARK: - Asynchronous operations
/// Adds or update an instance asynchronously and waits for network operations
public func addOrUpdateAsync(instance: T) async throws {
let result = _addOrUpdateCore(instance: instance)
if result.method == .insert {
try await self._executeBatchOnce(OperationBatch(insert: instance))
} else {
try await self._executeBatchOnce(OperationBatch(update: instance))
}
}
public func addOrUpdateAsync(contentOfs sequence: any Sequence<T>) async throws {
let batch = self._addOrUpdateCore(contentOfs: sequence)
try await self._executeBatchOnce(batch)
}
/// Deletes all items of the sequence by id and sets the collection as changed to trigger a write
public func deleteAsync(contentOfs sequence: any RandomAccessCollection<T>) async throws {
guard sequence.isNotEmpty else { return }
let batch = self._deleteCore(contentOfs: sequence, actionOption: .syncedCascade)
try await self._executeBatchOnce(batch)
}
/// Deletes an instance and writes
public func deleteAsync(instance: T) async throws {
self.collection.delete(instance: instance, actionOption: .syncedCascade)
self.storeCenter.createDeleteLog(instance)
try await self._executeBatchOnce(OperationBatch(delete: instance))
}
// MARK: - Basic operations without sync
/// Adds or update an instance without synchronizing it
func addOrUpdateNoSync(_ instance: T) {
self.collection.addOrUpdate(instance: instance)
// self.addOrUpdateItem(instance: instance)
}
/// Adds or update a sequence of elements without synchronizing it
func addOrUpdateNoSync(contentOfs sequence: any Sequence<T>) {
self.collection.addOrUpdate(contentOfs: sequence)
}
public func deleteNoSync(contentOfs sequence: any RandomAccessCollection<T>) {
self.collection.delete(contentOfs: sequence)
}
/// Deletes the instance in the collection without synchronization
public func deleteNoSync(instance: T, cascading: Bool = false) {
self.collection.delete(instance: instance, actionOption: .cascade)
}
func deleteUnusedGranted(instance: T) {
guard instance.sharing != nil else { return }
self.deleteByStringId(instance.stringId)
instance.deleteUnusedSharedDependencies(store: self.store)
}
func deleteByStringId(_ id: String, actionOption: ActionOption = .standard) {
self.collection.deleteByStringId(id, actionOption: actionOption)
}
// MARK: - Collection Delegate
func loadingForMemoryCollection() async {
do {
try await self.loadDataFromServerIfAllowed()
} catch {
Logger.error(error)
}
}
func itemMerged(_ pendingOperation: PendingOperation<T>) {
let batch = OperationBatch<T>()
switch pendingOperation.method {
case .add:
batch.inserts.append(pendingOperation.data)
case .update:
batch.updates.append(pendingOperation.data)
case .delete:
batch.deletes.append(pendingOperation.data)
case .deleteUnusedShared:
break
}
Task { await self._sendOperationBatch(batch) }
}
// MARK: - Send requests
fileprivate func _sendInsertion(_ instance: T) async {
await self._sendOperationBatch(OperationBatch(insert: instance))
}
fileprivate func _sendUpdate(_ instance: T) async {
await self._sendOperationBatch(OperationBatch(update: instance))
}
fileprivate func _sendDeletion(_ instance: T) async {
await self._sendOperationBatch(OperationBatch(delete: instance))
}
fileprivate func _sendOperationBatch(_ batch: OperationBatch<T>) async {
do {
try await self.storeCenter.sendOperationBatch(batch)
} catch {
Logger.error(error)
}
}
fileprivate func _executeBatchOnce(_ batch: OperationBatch<T>) async throws {
try await self.storeCenter.singleBatchExecution(batch)
}
// MARK: Single calls
public func addsIfPostSucceeds(_ instance: T) async throws {
if let result = try await self.storeCenter.service().post(instance) {
self.addOrUpdateNoSync(result)
}
}
public func updateIfPutSucceeds(_ instance: T) async throws {
if let result = try await self.storeCenter.service().put(instance) {
self.addOrUpdateNoSync(result)
}
}
// MARK: - Synchronization
/// Adds or update an instance if it is newer than the local instance
func addOrUpdateIfNewer(_ instance: T, shared: SharingStatus?) {
if let index = self.collection.items.firstIndex(where: { $0.id == instance.id }) {
let localInstance = self.collection.items[index]
if instance.lastUpdate > localInstance.lastUpdate {
self.collection.update(instance, index: index, actionOption: .standard)
} else {
// print("do not update \(T.resourceName()): \(instance.lastUpdate.timeIntervalSince1970) / local: \(localInstance.lastUpdate.timeIntervalSince1970)")
}
} else { // insert
instance.sharing = shared
self.collection.add(instance: instance, actionOption: .standard)
}
}
// MARK: - Others
/// Sends a POST request for the instance, and changes the collection to perform a write
public func writeChangeAndInsertOnServer(instance: T) {
self.collection.addOrUpdate(instance: instance)
Task {
await self._sendInsertion(instance)
}
}
// MARK: - SomeCollection
public var hasLoaded: Bool { return self.collection.hasLoaded}
public var inMemory: Bool { return self.collection.inMemory }
public var type: any Storable.Type { return T.self }
public func hasParentReferences<S>(type: S.Type, id: String) -> Bool where S : Storable {
return self.collection.hasParentReferences(type: type, id: id)
}
public func reset() {
self.collection.reset()
}
public func findById(_ id: T.ID) -> T? {
return self.collection.findById(id)
}
public var items: [T] {
return self.collection.items
}
public func requestWriteIfNecessary() {
self.collection.requestWriteIfNecessary()
}
// MARK: - Cached queries
public func cached<Result>(
key: AnyHashable,
compute: ([T]) -> Result
) -> Result {
return self.collection.cached(key: key, compute: compute)
}
}
class OperationBatch<T> {
var inserts: [T] = []
var updates: [T] = []
var deletes: [T] = []
init() {
}
init(insert: T) {
self.inserts = [insert]
}
init(update: T) {
self.updates = [update]
}
init(delete: T) {
self.deletes = [delete]
}
func addInsert(_ instance: T) {
self.inserts.append(instance)
}
func addUpdate(_ instance: T) {
self.updates.append(instance)
}
func addDelete(_ instance: T) {
self.deletes.append(instance)
}
}
extension SyncedCollection: RandomAccessCollection {
public var startIndex: Int { return self.collection.items.startIndex }
public var endIndex: Int { return self.collection.items.endIndex }
public func index(after i: Int) -> Int {
return self.collection.items.index(after: i)
}
public subscript(index: Int) -> T {
get {
return self.collection.items[index]
}
set(newValue) {
self.collection.update(newValue, index: index, actionOption: .standard)
}
}
}

@ -1,57 +0,0 @@
//
// SyncedStorable.swift
// LeStorage
//
// Created by Laurent Morvillier on 11/10/2024.
//
import Foundation
public enum SharingStatus: Int, Codable {
case shared = 1
case granted
}
public protocol SyncedStorable: Storable {
var lastUpdate: Date { get set }
var sharing: SharingStatus? { get set }
init()
/// Returns HTTP methods that do not need to pass the token to the request
static func tokenExemptedMethods() -> [HTTPMethod]
/// Returns whether we should copy the server response into the local instance
static var copyServerResponse: Bool { get }
}
protocol URLParameterConvertible {
func queryParameters(storeCenter: StoreCenter) -> [String : String]
}
public protocol SideStorable {
var storeId: String? { get set }
}
extension Storable {
func getStoreId() -> String? {
if let alt = self as? SideStorable {
return alt.storeId
}
return nil
}
}
public extension SyncedStorable {
func copy() -> Self {
let copy = Self()
copy.copy(from: self)
return copy
}
}

@ -1,50 +0,0 @@
//
// ClassLoader.swift
// LeStorage
//
// Created by Laurent Morvillier on 22/11/2024.
//
import Foundation
class ClassLoader {
static var classCache: [String : AnyClass] = [:]
static func getClass(_ className: String, classProject: String? = nil) -> AnyClass? {
if let cachedClass = classCache[className] {
return cachedClass
}
if let bundleName = Bundle.main.infoDictionary?["CFBundleName"] as? String {
let sanitizedBundleName = bundleName.replacingOccurrences(of: " ", with: "_")
let fullName = "\(sanitizedBundleName).\(className)"
if let projectClass = _getClass(fullName) {
return projectClass
}
}
if let classProject {
let sanitizedBundleName = classProject.replacingOccurrences(of: " ", with: "_")
let fullName = "\(sanitizedBundleName).\(className)"
if let projectClass = _getClass(fullName) {
return projectClass
}
}
let leStorageClassName = "LeStorage.\(className)"
if let projectClass = _getClass(leStorageClassName) {
return projectClass
}
return nil
}
static func _getClass(_ className: String) -> AnyClass? {
if let loadedClass = NSClassFromString(className) {
classCache[className] = loadedClass
return loadedClass
}
return nil
}
}

@ -7,43 +7,22 @@
import Foundation
public class JSON {
public static var encoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
#if DEBUG
encoder.outputFormatting = .prettyPrinted
#endif
encoder.dateEncodingStrategy = .custom { date, encoder in
let dateString = Date.iso8601FractionalFormatter.string(from: date)
var container = encoder.singleValueContainer()
try container.encode(dateString)
} // need dates with thousandth precision
return encoder
}()
fileprivate var jsonEncoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
#if DEBUG
encoder.outputFormatting = .prettyPrinted
#endif
encoder.dateEncodingStrategy = .iso8601
return encoder
}()
public static var decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let dateString = try container.decode(String.self)
if let date = Date.iso8601FractionalFormatter.date(from: dateString) {
return date
} else if let date = Date.iso8601Formatter.date(from: dateString) {
return date
} else {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Invalid date format: \(dateString)"
)
}
} // need dates with thousandth precision
return decoder
}()
}
fileprivate var jsonDecoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601
return decoder
}()
extension Encodable {
@ -53,11 +32,11 @@ extension Encodable {
}
public func jsonData() throws -> Data {
return try JSON.encoder.encode(self)
return try jsonEncoder.encode(self)
}
public func prettyJSONString() throws -> String {
let data = try JSON.encoder.encode(self)
let data = try jsonEncoder.encode(self)
return String(data: data, encoding: .utf8) ?? ""
}
@ -78,11 +57,11 @@ extension String {
extension Data {
public func decode<T : Decodable>() throws -> T {
return try JSON.decoder.decode(T.self, from: self)
return try jsonDecoder.decode(T.self, from: self)
}
public func decodeArray<T : Decodable>() throws -> [T] {
return try JSON.decoder.decode([T].self, from: self)
return try jsonDecoder.decode([T].self, from: self)
}
}

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

@ -1,33 +0,0 @@
//
// Date+Extensions.swift
// LeStorage
//
// Created by Laurent Morvillier on 09/10/2024.
//
import Foundation
extension Date {
static var iso8601Formatter: ISO8601DateFormatter = {
let iso8601Formatter = ISO8601DateFormatter()
iso8601Formatter.timeZone = TimeZone(abbreviation: "CET")
iso8601Formatter.formatOptions = [.withInternetDateTime, .withTimeZone]
return iso8601Formatter
}()
public static var iso8601FractionalFormatter: ISO8601DateFormatter = {
let iso8601Formatter = ISO8601DateFormatter()
iso8601Formatter.timeZone = TimeZone(abbreviation: "CET")
iso8601Formatter.formatOptions = [.withInternetDateTime, .withTimeZone, .withFractionalSeconds]
return iso8601Formatter
}()
public static var microSecondFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'" // puts 000 for the last decimals
formatter.timeZone = TimeZone(abbreviation: "UTC")
return formatter
}()
}

@ -1,26 +0,0 @@
//
// Dictionary+Extensions.swift
// LeStorage
//
// Created by Laurent Morvillier on 03/12/2024.
//
import Foundation
extension Dictionary where Key == String, Value == String {
func toQueryString() -> String {
guard !self.isEmpty else {
return ""
}
let pairs = self.map { key, value in
let escapedKey = key.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? key
let escapedValue = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value
return "\(escapedKey)=\(escapedValue)"
}
return "?" + pairs.joined(separator: "&")
}
}

@ -20,63 +20,6 @@ public class ErrorUtils {
}
public enum ServiceError: Error, LocalizedError {
case urlCreationError(url: String)
case cantConvertToUUID(id: String)
case missingUserName
case responseError(response: String)
case cantDecodeData(resource: String, method: String, content: String?)
public var errorDescription: String? {
switch self {
case .urlCreationError(let url):
return "Can't create URL from \(url)"
case .cantConvertToUUID(let id):
return "Cant convert \(id) to UUID"
case .missingUserName:
return "There is no userName defined in the Settings"
case .responseError(let response):
return "The server returned an error: \(response)"
case .cantDecodeData(let resource, let method, let content):
return "cannot decode data from \(resource), method: \(method): \(content ?? "")"
}
}
}
public enum UUIDError: Error, LocalizedError {
public enum UUIDError: Error {
case cantConvertString(string: String)
public var errorDescription: String? {
switch self {
case .cantConvertString(let string):
return "cant convert string to UUID: \(string)"
}
}
}
public enum LeStorageError: Error, LocalizedError {
case cantFindClassFromName(name: String)
case cantCastTypeToSyncedStorable(name: String)
case cantAccessCFBundleName
case cantCreateDataAccessBecauseNotInMainStore
case cantCreateDataAccessBecauseUserIdIsNil
case dataAccessCollectionNotDefined
public var errorDescription: String? {
switch self {
case .cantFindClassFromName(let string):
return "can't find class for class name: \(string)"
case .cantCastTypeToSyncedStorable(let string):
return "can't cast type \(string) to SyncedStorable"
case .cantAccessCFBundleName:
return "can't access CFBundleName for some reason"
case .cantCreateDataAccessBecauseNotInMainStore:
return "Can't create data access because the data is not in the main Store"
case .cantCreateDataAccessBecauseUserIdIsNil:
return "Can't create data access because the there is no logged user"
case .dataAccessCollectionNotDefined:
return "Can't create data access because the collection is not defined"
}
}
}

@ -9,23 +9,18 @@ import Foundation
extension FileManager {
@discardableResult func createDirectoryInDocuments(directoryName: String) -> Bool {
func createDirectoryInDocuments(directoryName: String) -> Bool {
let documentsDirectory = self.urls(for: .documentDirectory, in: .userDomainMask).first!
let directoryURL = documentsDirectory.appendingPathComponent(directoryName)
if !self.fileExists(atPath: directoryURL.path) {
do {
try self.createDirectory(at: directoryURL,
withIntermediateDirectories: true,
attributes: nil)
Logger.log("directory created : \(directoryURL)")
return true
try self.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil)
} catch {
Logger.error(error)
return false
}
return true
} else {
Logger.log("directory exists : \(directoryURL)")
return false
}
}

@ -7,15 +7,8 @@
import Foundation
enum FileError: Error, LocalizedError {
enum FileError : Error {
case documentDirectoryNotFound
var errorDescription: String? {
switch self {
case .documentDirectoryNotFound:
return "The document directory has not been found"
}
}
}
class FileUtils {

@ -1,12 +0,0 @@
//
// Formatter.swift
// LeStorage
//
// Created by Laurent Morvillier on 30/10/2024.
//
class Formatter {
static let number: NumberFormatter = NumberFormatter()
}

@ -1,6 +1,6 @@
//
// KeychainStore.swift
// Padel Club (from Le Countdown)
// LeCountdown
//
// Created by Laurent Morvillier on 20/12/2023.
//
@ -11,27 +11,9 @@ enum KeychainError: Error {
case keychainItemNotFound(serverId: String)
case unexpectedPasswordData
case unhandledError(status: OSStatus)
var errorDescription: String? {
switch self {
case .keychainItemNotFound(let serverId):
return "The keychainItem was not found: \(serverId)"
case .unexpectedPasswordData:
return "Keychain error: The data could not be converted to string"
case .unhandledError(let status):
return "Keychain error: Unmanaged status: \(status)"
}
}
}
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

@ -17,9 +17,9 @@ import Foundation
print("\(filestr.lastPathComponent).\(line).\(function): \(message)")
}
// @objc static public func error(_ error: Error, file: String = #file, function: String = #function, line: Int = #line) {
// Logger.error(error, file: file, function: function, line: line)
// }
@objc static public func error(_ error: Error) {
Logger.error(error, file: #file, function: #function, line: #line)
}
static public func error(_ error: Error, file: String = #file, function: String = #function, line: Int = #line) {
let filestr: NSString = NSString(string: file)

@ -1,44 +0,0 @@
//
// MockKeychainStore.swift
// LeStorage
//
// Created by Laurent Morvillier on 17/04/2025.
//
import Foundation
class TokenStore: MicroStorable {
required init() {
}
var token: String?
}
class MockKeychainStore: MicroStorage<TokenStore>, KeychainService {
let key = "store"
func add(username: String, value: String) throws {
try self.add(value: value)
}
func add(value: String) throws {
self.update { tokenStore in
tokenStore.token = value
}
}
func getValue() throws -> String {
if let value = self.item.token {
return value
}
throw KeychainError.keychainItemNotFound(serverId: "mock")
}
func deleteValue() throws {
self.update { tokenStore in
tokenStore.token = nil
}
}
}

@ -1,17 +0,0 @@
//
// String+Extensions.swift
// LeStorage
//
// Created by Laurent Morvillier on 19/05/2025.
//
import Foundation
public extension String {
static func random(length: Int = 10) -> String {
let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
return String((0..<length).map{ _ in letters.randomElement()! })
}
}

@ -1,24 +0,0 @@
//
// UIDevice+Extensions.swift
// LeStorage
//
// Created by Laurent Morvillier on 20/03/2025.
//
import Foundation
import UIKit
extension UIDevice {
func deviceModel() -> String {
var systemInfo = utsname()
uname(&systemInfo)
let modelCode = withUnsafePointer(to: &systemInfo.machine) {
$0.withMemoryRebound(to: CChar.self, capacity: 1) {
ptr in String(validatingUTF8: ptr)
}
}
return modelCode ?? "unknown"
}
}

@ -1,39 +0,0 @@
//
// URLManager.swift
// LeStorage
//
// Created by Laurent Morvillier on 18/11/2024.
//
import Foundation
struct URLManager {
var secureScheme: Bool
var domain: String
private let apiPath: String = "roads"
var api: String {
return "\(self.httpScheme)\(self.domain)/\(self.apiPath)/"
}
func websocket(userId: String) -> String {
return "\(self.wsScheme)\(self.domain)/ws/user/\(userId)/"
}
var httpScheme: String {
if self.secureScheme {
return "https://"
} else {
return "http://"
}
}
var wsScheme: String {
if self.secureScheme {
return "wss://"
} else {
return "ws://"
}
}
}

@ -1,151 +0,0 @@
//
// WebSocketManager.swift
// WebSocketTest
//
// Created by Laurent Morvillier on 30/08/2024.
//
import Foundation
import SwiftUI
import Combine
class WebSocketManager: ObservableObject {
fileprivate(set) var storeCenter: StoreCenter
fileprivate var _webSocketTask: URLSessionWebSocketTask?
fileprivate var _timer: Timer?
fileprivate var _url: String
fileprivate var _reconnectAttempts = 0
fileprivate var _failure = false
fileprivate var _error: Error? = nil
fileprivate var _pingOk = false
init(storeCenter: StoreCenter, urlString: String) {
self.storeCenter = storeCenter
self._url = urlString
_setupWebSocket()
}
deinit {
disconnect()
}
private func _setupWebSocket() {
// guard let url = URL(string: "ws://127.0.0.1:8000/ws/user/test/") else {
guard let url = URL(string: self._url) else {
Logger.w("Invalid URL: \(self._url)")
return
}
Logger.log(">>> configure websockets with: \(url)")
let session = URLSession(configuration: .default)
_webSocketTask = session.webSocketTask(with: url)
_webSocketTask?.resume()
self._receiveMessage()
// Setup a ping timer to keep the connection alive
self._timer?.invalidate()
_timer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { _ in
self._ping()
}
}
private func _receiveMessage() {
_webSocketTask?.receive { result in
switch result {
case .failure(let error):
self._failure = true
self._error = error
print("Error in receiving message: \(error)")
self._handleWebSocketError(error)
case .success(let message):
self._failure = false
self._error = nil
self._reconnectAttempts = 0
switch message {
case .string(let deviceId):
// print("device id = \(StoreCenter.main.deviceId()), origin id: \(deviceId)")
guard self.storeCenter.deviceId() != deviceId else {
break
}
Task {
await self.storeCenter.synchronizeLastUpdates()
}
case .data(let data):
print("Received binary message: \(data)")
break
@unknown default:
print("received other = \(message)")
break
}
self._receiveMessage()
}
}
}
private func _handleWebSocketError(_ error: Error) {
// print("WebSocket error: \(error)")
// up to 10 seconds of reconnection
let delay = min(Double(self._reconnectAttempts), 10.0)
self._reconnectAttempts += 1
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self = self else { return }
Logger.log("Attempting to reconnect... (Attempt #\(self._reconnectAttempts))")
_setupWebSocket()
}
}
func send(_ message: String) {
self._webSocketTask?.send(.string(message)) { error in
if let error = error {
print("Error in sending message: \(error)")
}
}
}
private func _ping() {
self._webSocketTask?.sendPing { error in
if let error: NSError = error as NSError?,
error.domain == NSPOSIXErrorDomain && error.code == 57 {
Logger.log("ping sent. Error?: \(error.localizedDescription) ")
self._setupWebSocket()
self._pingOk = false
} else {
self._pingOk = true
}
}
}
func disconnect() {
self._webSocketTask?.cancel(with: .goingAway, reason: nil)
self._timer?.invalidate()
}
var pingStatus: Bool {
return self._pingOk
}
var failure: Bool {
return self._failure
}
var error: Error? {
return self._error
}
var reconnectAttempts: Int {
return self._reconnectAttempts
}
}

@ -1,133 +0,0 @@
//
// ApiCallTests.swift
// LeStorageTests
//
// Created by Laurent Morvillier on 15/02/2025.
//
import Testing
@testable import LeStorage
class Thing: SyncedModelObject, SyncedStorable, URLParameterConvertible {
override required init() {
super.init()
}
static func resourceName() -> String { return "thing" }
static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { return [] }
static func filterByStoreIdentifier() -> Bool { return false }
static var copyServerResponse: Bool = false
static func storeParent() -> Bool { return false }
var id: String = Store.randomId()
var name: String = ""
init(name: String) {
self.name = name
super.init()
}
required init(from decoder: any Decoder) throws {
fatalError("init(from:) has not been implemented")
}
func copy(from other: any LeStorage.Storable) {
}
static func relationships() -> [LeStorage.Relationship] { return [] }
func queryParameters(storeCenter: StoreCenter) -> [String : String] {
return ["yeah?" : "god!"]
}
}
struct ApiCallTests {
@Test func testApiCallProvisioning1() async throws {
let collection = ApiCallCollection<Thing>(storeCenter: StoreCenter.main)
let thing = Thing(name: "yeah")
let _ = try await collection.sendInsertion(thing)
await #expect(collection.items.count == 1)
if let apiCall = await collection.items.first {
#expect(apiCall.method == .post)
}
thing.name = "woo"
let _ = try await collection.sendUpdate(thing)
await #expect(collection.items.count == 2) // one post and one put
if let apiCall = await collection.items.first {
#expect(apiCall.method == .post)
}
if let apiCall = await collection.items.last {
#expect(apiCall.method == .put)
}
let _ = try await collection.sendDeletion(thing)
await #expect(collection.items.count == 1)
}
@Test func testApiCallProvisioning2() async throws {
let collection = ApiCallCollection<Thing>(storeCenter: StoreCenter.main)
let thing = Thing(name: "yeah")
let _ = try await collection.sendUpdate(thing)
await #expect(collection.items.count == 1)
if let apiCall = await collection.items.first {
#expect(apiCall.method == .put)
}
thing.name = "woo"
let _ = try await collection.sendUpdate(thing)
let _ = try await collection.sendUpdate(thing)
let _ = try await collection.sendUpdate(thing)
await #expect(collection.items.count == 1)
if let apiCall = await collection.items.first {
#expect(apiCall.method == .put)
}
let _ = try await collection.sendDeletion(thing)
await #expect(collection.items.count == 1)
}
@Test func testApiCallProvisioning3() async throws {
let collection = ApiCallCollection<Thing>(storeCenter: StoreCenter.main)
let thing = Thing(name: "yeah")
let _ = try await collection.sendDeletion(thing)
await #expect(collection.items.count == 1)
let _ = try await collection.sendDeletion(thing)
await #expect(collection.items.count == 1)
let _ = try await collection.sendDeletion(thing)
await #expect(collection.items.count == 1)
}
@Test func testGetProvisioning() async throws {
let collection = ApiCallCollection<Thing>(storeCenter: StoreCenter.main)
try await collection.sendGetRequest(storeId: "1")
await #expect(collection.items.count == 1)
try await collection.sendGetRequest(storeId: "1")
await #expect(collection.items.count == 1)
try await collection.sendGetRequest(storeId: "2")
await #expect(collection.items.count == 2)
try await collection.sendGetRequest(instance: Thing(name: "man!"))
await #expect(collection.items.count == 3)
try await collection.sendGetRequest(storeId: nil)
await #expect(collection.items.count == 4)
try await collection.sendGetRequest(storeId: nil)
await #expect(collection.items.count == 4)
}
}

@ -1,87 +0,0 @@
//
// CollectionsTests.swift
// LeStorageTests
//
// Created by Laurent Morvillier on 15/10/2024.
//
import Testing
@testable import LeStorage
class Car: ModelObject, Storable {
var id: String = Store.randomId()
static func resourceName() -> String { return "car" }
func copy(from other: any LeStorage.Storable) {
}
static func relationships() -> [LeStorage.Relationship] { return [] }
static func storeParent() -> Bool { return false }
}
class Boat: ModelObject, SyncedStorable {
var id: String = Store.randomId()
var lastUpdate: Date = Date()
var sharing: LeStorage.SharingStatus?
override required init() {
super.init()
}
static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { return [] }
static func resourceName() -> String { return "boat" }
static var copyServerResponse: Bool = false
static func storeParent() -> Bool { return false }
var storeId: String? { return nil }
func copy(from other: any LeStorage.Storable) {
}
static func relationships() -> [LeStorage.Relationship] { return [] }
}
struct CollectionsTests {
var cars: StoredCollection<Car>
var boats: SyncedCollection<Boat>
init() async {
cars = await StoreCenter.main.mainStore.asyncLoadingStoredCollection(inMemory: true)
boats = await StoreCenter.main.mainStore.asyncLoadingSynchronizedCollection(inMemory: true)
}
@Test func testLoading() async {
#expect(self.cars.hasLoaded)
#expect(self.boats.hasLoaded)
}
@Test func differentiationTest() async throws {
// Cars
#expect(cars.count == 0)
cars.addOrUpdate(instance: Car())
#expect(cars.count == 1)
// Boats
#expect(boats.count == 0)
let oldApiCallCount = await StoreCenter.main.apiCallCount(type: Boat.self)
#expect(oldApiCallCount == 0)
boats.addOrUpdate(instance: Boat())
#expect(boats.count == 1)
// Cars and boats
cars.reset()
boats.reset()
#expect(cars.count == 0)
#expect(boats.count == 0)
}
}

@ -1,110 +0,0 @@
//
// LeStorageTests.swift
// LeStorageTests
//
// Created by Laurent Morvillier on 18/09/2024.
//
import Testing
@testable import LeStorage
class IntObject: ModelObject, Storable {
static func resourceName() -> String { "int" }
static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { [] }
static func storeParent() -> Bool { return false }
var id: Int
var name: String
init(id: Int, name: String) {
self.id = id
self.name = name
}
func copy(from other: any LeStorage.Storable) {
}
static func relationships() -> [LeStorage.Relationship] {
return []
}
}
class StringObject: ModelObject, Storable {
static func resourceName() -> String { "string" }
static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { [] }
static func storeParent() -> Bool { return false }
var id: String
var name: String
init(id: String, name: String) {
self.id = id
self.name = name
}
func copy(from other: any LeStorage.Storable) {
}
static func relationships() -> [LeStorage.Relationship] {
return []
}
}
struct IdentifiableTests {
let intObjects: StoredCollection<IntObject>
let stringObjects: StoredCollection<StringObject>
init() {
let dir = "test_" + String.random()
let storeCenter: StoreCenter = StoreCenter(directoryName:dir)
intObjects = storeCenter.mainStore.registerCollection()
stringObjects = storeCenter.mainStore.registerCollection()
}
func ensureCollectionLoaded(_ collection: any SomeCollection) async throws {
// Wait for the collection to finish loading
// Adjust the timeout as needed
let timeout = 5.0 // seconds
let startTime = Date()
while !collection.hasLoaded {
// Check for timeout
if Date().timeIntervalSince(startTime) > timeout {
throw Error("Collection loading timed out")
}
// Wait a bit before checking again
try await Task.sleep(for: .milliseconds(100))
}
collection.reset()
}
@Test func testIntIds() async throws {
try await ensureCollectionLoaded(self.intObjects)
let int = IntObject(id: 12, name: "test")
self.intObjects.addOrUpdate(instance: int)
if let search = intObjects.findById(12) {
#expect(search.id == 12)
} else {
Issue.record("object is missing")
}
}
@Test func testStringIds() async throws {
try await ensureCollectionLoaded(self.stringObjects)
let string = StringObject(id: "coco", name: "name")
self.stringObjects.addOrUpdate(instance: string)
if let search = stringObjects.findById("coco") {
#expect(search.id == "coco")
} else {
Issue.record("object is missing")
}
}
}

@ -1,141 +0,0 @@
//
// StoredCollectionTests.swift
// LeStorageTests
//
// Created by Laurent Morvillier on 16/10/2024.
//
import Testing
@testable import LeStorage
struct Error: Swift.Error, CustomStringConvertible {
let description: String
init(_ description: String) {
self.description = description
}
}
struct StoredCollectionTests {
var collection: StoredCollection<MockStorable>
init() async {
collection = await StoreCenter.main.mainStore.asyncLoadingStoredCollection(inMemory: true)
collection.reset()
}
@Test func testInitialization() async throws {
#expect(self.collection.hasLoaded)
#expect(collection.items.count == 0)
}
@Test func testAddOrUpdate() async throws {
let item = MockStorable(id: "1", name: "Test")
collection.addOrUpdate(instance: item)
#expect(collection.items.count == 1)
if let first = collection.items.first {
#expect(first.id == "1")
} else {
Issue.record("missing record")
}
}
@Test func testDelete() async throws {
let item = MockStorable(id: "1", name: "Test")
collection.addOrUpdate(instance: item)
#expect(collection.items.count == 1)
collection.delete(instance: item)
#expect(collection.items.isEmpty)
}
@Test func testFindById() async throws {
let item = MockStorable(id: "1", name: "Test")
collection.addOrUpdate(instance: item)
if let foundItem = collection.findById("1") {
#expect(foundItem.id == "1")
} else {
Issue.record("missing item")
}
}
@Test func testDeleteById() async throws {
let item = MockStorable(id: "1", name: "Test")
collection.addOrUpdate(instance: item)
collection.deleteByStringId("1")
let search = collection.findById("1")
#expect(search == nil)
}
@Test func testAddOrUpdateMultiple() async throws {
let items = [
MockStorable(id: "1", name: "Test1"),
MockStorable(id: "2", name: "Test2"),
]
collection.addOrUpdate(contentOfs: items)
#expect(collection.items.count == 2)
}
@Test func testDeleteAll() async throws {
let items = [
MockStorable(id: "1", name: "Test1"),
MockStorable(id: "2", name: "Test2"),
]
collection.addOrUpdate(contentOfs: items)
#expect(collection.items.count == 2)
collection.clear()
#expect(collection.items.isEmpty)
}
@Test func testRandomAccessCollection() async throws {
let items = [
MockStorable(id: "1", name: "Test1"),
MockStorable(id: "2", name: "Test2"),
MockStorable(id: "3", name: "Test3"),
]
collection.addOrUpdate(contentOfs: items)
#expect(collection.startIndex == 0)
#expect(collection.endIndex == 3)
if collection.count > 2 {
#expect(collection[1].name == "Test2")
} else {
Issue.record("count not good")
}
}
}
// Mock Storable for testing purposes
class MockStorable: ModelObject, Storable {
var id: String = Store.randomId()
var name: String
init(id: String, name: String) {
self.id = id
self.name = name
}
static func resourceName() -> String {
return "mocks"
}
func copy(from other: any LeStorage.Storable) {
}
static func relationships() -> [LeStorage.Relationship] {
return []
}
static func storeParent() -> Bool { return false }
}

@ -16,7 +16,7 @@ You can store collections inside separate folders by creating other stores:
# Sync
- When registering your collection, you can choose to have it synchronized. To do that:
- Call `StoreCenter.main.configureURLs`
- Set `StoreCenter.main.synchronizationApiURL`
- Pass `synchronized: true` when registering the collection
- For each of your `ModelObject`, make sure that `resourceName()` returns the resource path of the endpoint, for example "cars"
- Synchronization is expected to be done with a rest_framework API on a django server

Loading…
Cancel
Save