Compare commits

..

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

  1. 188
      LeStorage.xcodeproj/project.pbxproj
  2. 4
      LeStorage.xcodeproj/xcshareddata/xcschemes/LeStorage.xcscheme
  3. 55
      LeStorage.xcodeproj/xcshareddata/xcschemes/LeStorageTests.xcscheme
  4. 439
      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. 73
      LeStorage/Codables/Log.swift
  12. 34
      LeStorage/Codables/PendingOperation.swift
  13. 16
      LeStorage/Codables/Settings.swift
  14. 83
      LeStorage/Codables/SyncData.swift
  15. 78
      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. 809
      LeStorage/Services.swift
  21. 71
      LeStorage/Storable.swift
  22. 497
      LeStorage/Store.swift
  23. 1171
      LeStorage/StoreCenter.swift
  24. 64
      LeStorage/StoreLibrary.swift
  25. 814
      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. 54
      LeStorage/Utils/Errors.swift
  35. 11
      LeStorage/Utils/FileManager+Extensions.swift
  36. 9
      LeStorage/Utils/FileUtils.swift
  37. 12
      LeStorage/Utils/Formatter.swift
  38. 42
      LeStorage/Utils/KeychainStore.swift
  39. 8
      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. 14
      README.md

@ -3,26 +3,17 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 70; objectVersion = 56;
objects = { objects = {
/* Begin PBXBuildFile section */ /* 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 */; }; 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, ); }; }; 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 */; }; 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 */; }; C456EFE22BE52379007388E2 /* StoredSingleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C456EFE12BE52379007388E2 /* StoredSingleton.swift */; };
C45D35912C0A1DB5000F379F /* FailedAPICall.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45D35902C0A1DB5000F379F /* FailedAPICall.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 */; }; C49B6E502C2089B6002BDE1B /* ApiCallCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */; };
C49EF0242BD6BDC50077B5AA /* FileManager+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.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 */; }; C4A47D4F2B6D280200ADC637 /* StoredCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */; };
@ -40,24 +31,11 @@
C4A47D9B2B7CFFDA00ADC637 /* ApiCall.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D992B7CFFC500ADC637 /* ApiCall.swift */; }; C4A47D9B2B7CFFDA00ADC637 /* ApiCall.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D992B7CFFC500ADC637 /* ApiCall.swift */; };
C4A47D9C2B7CFFE000ADC637 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D9A2B7CFFC500ADC637 /* Settings.swift */; }; C4A47D9C2B7CFFE000ADC637 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D9A2B7CFFC500ADC637 /* Settings.swift */; };
C4A47DAF2B85FD3800ADC637 /* Errors.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47DAE2B85FD3800ADC637 /* Errors.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 */; }; 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 */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
C4C33F702C9B06B7006316DE /* PBXContainerItemProxy */ = { C425D4402B6D24E1002A7B48 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy; isa = PBXContainerItemProxy;
containerPortal = C425D42B2B6D24E1002A7B48 /* Project object */; containerPortal = C425D42B2B6D24E1002A7B48 /* Project object */;
proxyType = 1; proxyType = 1;
@ -67,23 +45,14 @@
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
/* Begin PBXFileReference 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; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredCollection.swift; sourceTree = "<group>"; };
@ -101,26 +70,9 @@
C4A47D992B7CFFC500ADC637 /* ApiCall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCall.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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 */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
C4C33F6C2C9B06B7006316DE /* LeStorageTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = LeStorageTests; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
C425D4312B6D24E1002A7B48 /* Frameworks */ = { C425D4312B6D24E1002A7B48 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
@ -129,11 +81,11 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
C4C33F682C9B06B7006316DE /* Frameworks */ = { C425D43B2B6D24E1002A7B48 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
C4C33F6F2C9B06B7006316DE /* LeStorage.framework in Frameworks */, C425D43F2B6D24E1002A7B48 /* LeStorage.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -144,7 +96,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
C425D4362B6D24E1002A7B48 /* LeStorage */, C425D4362B6D24E1002A7B48 /* LeStorage */,
C4C33F6C2C9B06B7006316DE /* LeStorageTests */, C425D4422B6D24E1002A7B48 /* LeStorageTests */,
C425D4352B6D24E1002A7B48 /* Products */, C425D4352B6D24E1002A7B48 /* Products */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
@ -153,7 +105,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
C425D4342B6D24E1002A7B48 /* LeStorage.framework */, C425D4342B6D24E1002A7B48 /* LeStorage.framework */,
C4C33F6B2C9B06B7006316DE /* LeStorageTests.xctest */, C425D43E2B6D24E1002A7B48 /* LeStorageTests.xctest */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@ -161,27 +113,18 @@
C425D4362B6D24E1002A7B48 /* LeStorage */ = { C425D4362B6D24E1002A7B48 /* LeStorage */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
C4E2A7D02E8D6A48007E5186 /* CLAUDE.md */,
C4A47D6E2B7154F600ADC637 /* README.md */, C4A47D6E2B7154F600ADC637 /* README.md */,
C425D4372B6D24E1002A7B48 /* LeStorage.h */, C425D4372B6D24E1002A7B48 /* LeStorage.h */,
C425D4382B6D24E1002A7B48 /* LeStorage.docc */, C425D4382B6D24E1002A7B48 /* LeStorage.docc */,
C4A47D9D2B7CFFF500ADC637 /* Codables */, C4A47D9D2B7CFFF500ADC637 /* Codables */,
C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */, C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */,
C4A47D6C2B71364600ADC637 /* ModelObject.swift */, C4A47D6C2B71364600ADC637 /* ModelObject.swift */,
C488C87F2CCBDC210082001F /* NetworkMonitor.swift */,
C462E0DB2D37B61100F3E6E4 /* Notification+Name.swift */,
C40EC3E42D9BDFA3007372D7 /* PendingOperationManager.swift */,
C4AC9CE92CF754CC00CC13DF /* Relationship.swift */,
C4A47D602B6D3C1300ADC637 /* Services.swift */, C4A47D602B6D3C1300ADC637 /* Services.swift */,
C425D4572B6D2519002A7B48 /* Store.swift */, C425D4572B6D2519002A7B48 /* Store.swift */,
C4977BA82DEDFE6D005CD239 /* StoreLibrary.swift */,
C4FC2E282C2B2EC30021F3BF /* StoreCenter.swift */, C4FC2E282C2B2EC30021F3BF /* StoreCenter.swift */,
C4A47D642B6E92FE00ADC637 /* Storable.swift */, C4A47D642B6E92FE00ADC637 /* Storable.swift */,
C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */, C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */,
C4D477A02CB9586A0077713D /* SyncedCollection.swift */,
C456EFE12BE52379007388E2 /* StoredSingleton.swift */, C456EFE12BE52379007388E2 /* StoredSingleton.swift */,
C4D4779E2CB92FD80077713D /* SyncedStorable.swift */,
C4FAE6992CEB84B300790446 /* WebSocketManager.swift */,
C4A47D932B7CF7C500ADC637 /* MicroStorage.swift */, C4A47D932B7CF7C500ADC637 /* MicroStorage.swift */,
C4A47D822B7665BC00ADC637 /* Wip */, C4A47D822B7665BC00ADC637 /* Wip */,
C4A47D582B6D352900ADC637 /* Utils */, C4A47D582B6D352900ADC637 /* Utils */,
@ -189,24 +132,24 @@
path = LeStorage; path = LeStorage;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
C425D4422B6D24E1002A7B48 /* LeStorageTests */ = {
isa = PBXGroup;
children = (
C425D4432B6D24E1002A7B48 /* LeStorageTests.swift */,
);
path = LeStorageTests;
sourceTree = "<group>";
};
C4A47D582B6D352900ADC637 /* Utils */ = { C4A47D582B6D352900ADC637 /* Utils */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
C4AC9CE72CF0A13B00CC13DF /* ClassLoader.swift */,
C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */, C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */,
C4A47D6A2B71244100ADC637 /* Collection+Extension.swift */, C4A47D6A2B71244100ADC637 /* Collection+Extension.swift */,
C4D477962CB66EEA0077713D /* Date+Extensions.swift */,
C4339BFE2CFF86B3004E5F09 /* Dictionary+Extensions.swift */,
C4A47DAE2B85FD3800ADC637 /* Errors.swift */, C4A47DAE2B85FD3800ADC637 /* Errors.swift */,
C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */, C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */,
C4A47D542B6D2DBF00ADC637 /* FileUtils.swift */, C4A47D542B6D2DBF00ADC637 /* FileUtils.swift */,
C467AAE22CD2466400D76CD2 /* Formatter.swift */,
C4A47D832B7B97F000ADC637 /* KeychainStore.swift */, C4A47D832B7B97F000ADC637 /* KeychainStore.swift */,
C471F2572DB10649006317F4 /* MockKeychainStore.swift */,
C4A47D522B6D2C5F00ADC637 /* Logger.swift */, C4A47D522B6D2C5F00ADC637 /* Logger.swift */,
C4B96E1C2D8C53D700C2955F /* UIDevice+Extensions.swift */,
C4FAE69B2CEB8E9500790446 /* URLManager.swift */,
C49779FB2DDB5D89005CD239 /* String+Extensions.swift */,
); );
path = Utils; path = Utils;
sourceTree = "<group>"; sourceTree = "<group>";
@ -222,15 +165,9 @@
C4A47D9D2B7CFFF500ADC637 /* Codables */ = { C4A47D9D2B7CFFF500ADC637 /* Codables */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
C4A47D9A2B7CFFC500ADC637 /* Settings.swift */,
C4A47D992B7CFFC500ADC637 /* ApiCall.swift */, C4A47D992B7CFFC500ADC637 /* ApiCall.swift */,
C4D4779C2CB923720077713D /* DataLog.swift */,
C45D35902C0A1DB5000F379F /* FailedAPICall.swift */, C45D35902C0A1DB5000F379F /* FailedAPICall.swift */,
C4FC2E302C353E7B0021F3BF /* Log.swift */,
C4A47D9A2B7CFFC500ADC637 /* Settings.swift */,
C400D7222CC2AF560092237C /* GetSyncData.swift */,
C4AC9CE42CEFB12100CC13DF /* DataAccess.swift */,
C48638B22D9BC6A8007E3E06 /* PendingOperation.swift */,
C49774DE2DC4B3D7005CD239 /* SyncData.swift */,
); );
path = Codables; path = Codables;
sourceTree = "<group>"; sourceTree = "<group>";
@ -267,27 +204,22 @@
productReference = C425D4342B6D24E1002A7B48 /* LeStorage.framework */; productReference = C425D4342B6D24E1002A7B48 /* LeStorage.framework */;
productType = "com.apple.product-type.framework"; productType = "com.apple.product-type.framework";
}; };
C4C33F6A2C9B06B7006316DE /* LeStorageTests */ = { C425D43D2B6D24E1002A7B48 /* LeStorageTests */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = C4C33F722C9B06B7006316DE /* Build configuration list for PBXNativeTarget "LeStorageTests" */; buildConfigurationList = C425D44B2B6D24E1002A7B48 /* Build configuration list for PBXNativeTarget "LeStorageTests" */;
buildPhases = ( buildPhases = (
C4C33F672C9B06B7006316DE /* Sources */, C425D43A2B6D24E1002A7B48 /* Sources */,
C4C33F682C9B06B7006316DE /* Frameworks */, C425D43B2B6D24E1002A7B48 /* Frameworks */,
C4C33F692C9B06B7006316DE /* Resources */, C425D43C2B6D24E1002A7B48 /* Resources */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
C4C33F712C9B06B7006316DE /* PBXTargetDependency */, C425D4412B6D24E1002A7B48 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
C4C33F6C2C9B06B7006316DE /* LeStorageTests */,
); );
name = LeStorageTests; name = LeStorageTests;
packageProductDependencies = (
);
productName = LeStorageTests; productName = LeStorageTests;
productReference = C4C33F6B2C9B06B7006316DE /* LeStorageTests.xctest */; productReference = C425D43E2B6D24E1002A7B48 /* LeStorageTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test"; productType = "com.apple.product-type.bundle.unit-test";
}; };
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */
@ -297,14 +229,14 @@
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
BuildIndependentTargetsInParallel = 1; BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1600; LastSwiftUpdateCheck = 1520;
LastUpgradeCheck = 1630; LastUpgradeCheck = 1520;
TargetAttributes = { TargetAttributes = {
C425D4332B6D24E1002A7B48 = { C425D4332B6D24E1002A7B48 = {
CreatedOnToolsVersion = 15.2; CreatedOnToolsVersion = 15.2;
}; };
C4C33F6A2C9B06B7006316DE = { C425D43D2B6D24E1002A7B48 = {
CreatedOnToolsVersion = 16.0; CreatedOnToolsVersion = 15.2;
}; };
}; };
}; };
@ -322,7 +254,7 @@
projectRoot = ""; projectRoot = "";
targets = ( targets = (
C425D4332B6D24E1002A7B48 /* LeStorage */, C425D4332B6D24E1002A7B48 /* LeStorage */,
C4C33F6A2C9B06B7006316DE /* LeStorageTests */, C425D43D2B6D24E1002A7B48 /* LeStorageTests */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@ -332,12 +264,11 @@
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
C4E2A7D12E8D6A4D007E5186 /* CLAUDE.md in Resources */,
C4A47D6F2B7154F600ADC637 /* README.md in Resources */, C4A47D6F2B7154F600ADC637 /* README.md in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
C4C33F692C9B06B7006316DE /* Resources */ = { C425D43C2B6D24E1002A7B48 /* Resources */ = {
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
@ -353,64 +284,43 @@
files = ( files = (
C4A47D532B6D2C5F00ADC637 /* Logger.swift in Sources */, C4A47D532B6D2C5F00ADC637 /* Logger.swift in Sources */,
C4A47D842B7B97F000ADC637 /* KeychainStore.swift in Sources */, C4A47D842B7B97F000ADC637 /* KeychainStore.swift in Sources */,
C4FC2E312C353E7B0021F3BF /* Log.swift in Sources */,
C4D477A12CB9586A0077713D /* SyncedCollection.swift in Sources */,
C4A47D512B6D2C4E00ADC637 /* Codable+Extensions.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 */, C425D4392B6D24E1002A7B48 /* LeStorage.docc in Sources */,
C4AC9CE82CF0A13B00CC13DF /* ClassLoader.swift in Sources */,
C4A47DAF2B85FD3800ADC637 /* Errors.swift in Sources */, C4A47DAF2B85FD3800ADC637 /* Errors.swift in Sources */,
C4AC9CEA2CF754D200CC13DF /* Relationship.swift in Sources */,
C4A47D612B6D3C1300ADC637 /* Services.swift in Sources */, C4A47D612B6D3C1300ADC637 /* Services.swift in Sources */,
C4A47D552B6D2DBF00ADC637 /* FileUtils.swift in Sources */, C4A47D552B6D2DBF00ADC637 /* FileUtils.swift in Sources */,
C456EFE22BE52379007388E2 /* StoredSingleton.swift in Sources */, C456EFE22BE52379007388E2 /* StoredSingleton.swift in Sources */,
C467AAE32CD2467500D76CD2 /* Formatter.swift in Sources */,
C4A47D652B6E92FE00ADC637 /* Storable.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 */, 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 */, C4A47D4F2B6D280200ADC637 /* StoredCollection.swift in Sources */,
C4A47D9C2B7CFFE000ADC637 /* Settings.swift in Sources */, C4A47D9C2B7CFFE000ADC637 /* Settings.swift in Sources */,
C49774DF2DC4B3D7005CD239 /* SyncData.swift in Sources */,
C4FC2E292C2B2EC30021F3BF /* StoreCenter.swift in Sources */, C4FC2E292C2B2EC30021F3BF /* StoreCenter.swift in Sources */,
C462E0DC2D37B61100F3E6E4 /* Notification+Name.swift in Sources */,
C4A47D812B7665AD00ADC637 /* Migration.swift in Sources */, C4A47D812B7665AD00ADC637 /* Migration.swift in Sources */,
C4A47D9B2B7CFFDA00ADC637 /* ApiCall.swift in Sources */, C4A47D9B2B7CFFDA00ADC637 /* ApiCall.swift in Sources */,
C4B96E1D2D8C53D700C2955F /* UIDevice+Extensions.swift in Sources */,
C4A47D942B7CF7C500ADC637 /* MicroStorage.swift in Sources */, C4A47D942B7CF7C500ADC637 /* MicroStorage.swift in Sources */,
C4FAE69C2CEB8E9500790446 /* URLManager.swift in Sources */,
C49EF0242BD6BDC50077B5AA /* FileManager+Extensions.swift in Sources */, C49EF0242BD6BDC50077B5AA /* FileManager+Extensions.swift in Sources */,
C425D4582B6D2519002A7B48 /* Store.swift in Sources */, C425D4582B6D2519002A7B48 /* Store.swift in Sources */,
C48638B32D9BC6A8007E3E06 /* PendingOperation.swift in Sources */,
C4D4779D2CB923720077713D /* DataLog.swift in Sources */,
C45D35912C0A1DB5000F379F /* FailedAPICall.swift in Sources */, C45D35912C0A1DB5000F379F /* FailedAPICall.swift in Sources */,
C471F2582DB10649006317F4 /* MockKeychainStore.swift in Sources */,
C49B6E502C2089B6002BDE1B /* ApiCallCollection.swift in Sources */, C49B6E502C2089B6002BDE1B /* ApiCallCollection.swift in Sources */,
C4A47D6B2B71244100ADC637 /* Collection+Extension.swift in Sources */, C4A47D6B2B71244100ADC637 /* Collection+Extension.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
C4C33F672C9B06B7006316DE /* Sources */ = { C425D43A2B6D24E1002A7B48 /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
C425D4442B6D24E1002A7B48 /* LeStorageTests.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */ /* Begin PBXTargetDependency section */
C4C33F712C9B06B7006316DE /* PBXTargetDependency */ = { C425D4412B6D24E1002A7B48 /* PBXTargetDependency */ = {
isa = PBXTargetDependency; isa = PBXTargetDependency;
target = C425D4332B6D24E1002A7B48 /* LeStorage */; target = C425D4332B6D24E1002A7B48 /* LeStorage */;
targetProxy = C4C33F702C9B06B7006316DE /* PBXContainerItemProxy */; targetProxy = C425D4402B6D24E1002A7B48 /* PBXContainerItemProxy */;
}; };
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
@ -451,7 +361,6 @@
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 526E96RFNP;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@ -518,7 +427,6 @@
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 526E96RFNP;
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@ -545,11 +453,10 @@
C425D4492B6D24E1002A7B48 /* Debug */ = { C425D4492B6D24E1002A7B48 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
BUILD_LIBRARY_FOR_DISTRIBUTION = YES;
CODE_SIGN_IDENTITY = "";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEFINES_MODULE = YES; DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 526E96RFNP;
DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_COMPATIBILITY_VERSION = 1;
DYLIB_CURRENT_VERSION = 1; DYLIB_CURRENT_VERSION = 1;
DYLIB_INSTALL_NAME_BASE = "@rpath"; DYLIB_INSTALL_NAME_BASE = "@rpath";
@ -557,7 +464,6 @@
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
IPHONEOS_DEPLOYMENT_TARGET = 17.1;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -578,11 +484,10 @@
C425D44A2B6D24E1002A7B48 /* Release */ = { C425D44A2B6D24E1002A7B48 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
BUILD_LIBRARY_FOR_DISTRIBUTION = YES;
CODE_SIGN_IDENTITY = "";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEFINES_MODULE = YES; DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 526E96RFNP;
DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_COMPATIBILITY_VERSION = 1;
DYLIB_CURRENT_VERSION = 1; DYLIB_CURRENT_VERSION = 1;
DYLIB_INSTALL_NAME_BASE = "@rpath"; DYLIB_INSTALL_NAME_BASE = "@rpath";
@ -590,7 +495,6 @@
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
IPHONEOS_DEPLOYMENT_TARGET = 17.1;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -608,13 +512,14 @@
}; };
name = Release; name = Release;
}; };
C4C33F732C9B06B7006316DE /* Debug */ = { C425D44C2B6D24E1002A7B48 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 526E96RFNP;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.staxriver.LeStorageTests; PRODUCT_BUNDLE_IDENTIFIER = com.staxriver.LeStorageTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@ -624,13 +529,14 @@
}; };
name = Debug; name = Debug;
}; };
C4C33F742C9B06B7006316DE /* Release */ = { C425D44D2B6D24E1002A7B48 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 526E96RFNP;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.staxriver.LeStorageTests; PRODUCT_BUNDLE_IDENTIFIER = com.staxriver.LeStorageTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@ -661,11 +567,11 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
C4C33F722C9B06B7006316DE /* Build configuration list for PBXNativeTarget "LeStorageTests" */ = { C425D44B2B6D24E1002A7B48 /* Build configuration list for PBXNativeTarget "LeStorageTests" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
C4C33F732C9B06B7006316DE /* Debug */, C425D44C2B6D24E1002A7B48 /* Debug */,
C4C33F742C9B06B7006316DE /* Release */, C425D44D2B6D24E1002A7B48 /* Release */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1630" LastUpgradeVersion = "1530"
version = "1.7"> version = "1.7">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
@ -35,7 +35,7 @@
parallelizable = "YES"> parallelizable = "YES">
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "C4C33F6A2C9B06B7006316DE" BlueprintIdentifier = "C425D43D2B6D24E1002A7B48"
BuildableName = "LeStorageTests.xctest" BuildableName = "LeStorageTests.xctest"
BlueprintName = "LeStorageTests" BlueprintName = "LeStorageTests"
ReferencedContainer = "container:LeStorage.xcodeproj"> 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 // LeStorage
// //
// Created by Laurent Morvillier on 17/06/2024. // Created by Laurent Morvillier on 17/06/2024.
@ -7,51 +7,35 @@
import Foundation import Foundation
protocol SomeCallCollection {
protocol SomeCallCollection {
func findCallById(_ id: String) async -> (any SomeCall)? func findCallById(_ id: String) async -> (any SomeCall)?
func deleteById(_ id: String) async func deleteById(_ id: String) async
func hasPendingCalls() async -> Bool func hasPendingCalls() async -> Bool
func contentOfFile() async -> String? func contentOfFile() async -> String?
func reset() async 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 /// ApiCallCollection is an object communicating with a server to synchronize data managed locally
/// The Api calls are serialized and stored in a JSON file /// The Api calls are serialized and stored in a JSON file
/// Failing Api calls are stored forever and will be executed again later /// Failing Api calls are stored forever and will be executed again later
actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection { actor ApiCallCollection<T: Storable>: SomeCallCollection {
fileprivate var storeCenter: StoreCenter
/// The list of api calls /// The list of api calls
fileprivate(set) var items: [ApiCall<T>] = [] fileprivate(set) var items: [ApiCall<T>] = []
/// The number of time an execution loop has been called /// The number of time an execution loop has been called
fileprivate var _attemptLoops: Int = 0 fileprivate var _attemptLoops: Int = 0
/// Indicates if the collection is currently retrying ApiCalls /// Indicates if the collection is currently retrying ApiCalls
fileprivate var _isExecutingCalls: Bool = false fileprivate var _isRetryingCalls: Bool = false
fileprivate var _schedulingTask: Task<(), Never>? = nil
fileprivate var _executionTask: Task<Void, any Error>? = nil
/// Indicates whether the collection content has changed /// Indicates whether the collection content has changed
/// Initiates a write when true /// Initiates a write when true
fileprivate var _hasChanged: Bool = false { fileprivate var _hasChanged: Bool = false {
@ -62,35 +46,28 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
} }
} }
} }
init(storeCenter: StoreCenter) {
self.storeCenter = storeCenter
}
/// Starts the JSON file decoding synchronously or asynchronously /// Starts the JSON file decoding synchronously or asynchronously
/// Reschedule Api calls if not empty /// Reschedule Api calls if not empty
func loadFromFile() throws { func loadFromFile() throws {
try self._decodeJSONFile() try self._decodeJSONFile()
self.rescheduleApiCallsIfNecessary()
} }
/// Returns the file URL of the collection /// Returns the file URL of the collection
fileprivate func _urlForJSONFile() throws -> URL { 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 /// Decodes the json file into the items array
fileprivate func _decodeJSONFile() throws { fileprivate func _decodeJSONFile() throws {
let fileURL = try self._urlForJSONFile() let fileURL = try self._urlForJSONFile()
if FileManager.default.fileExists(atPath: fileURL.path()) { if FileManager.default.fileExists(atPath: fileURL.path()) {
let jsonString: String = try FileUtils.readFile(fileURL: fileURL) let jsonString: String = try FileUtils.readFile(fileURL: fileURL)
do { let decoded: [ApiCall<T>] = try jsonString.decodeArray() ?? []
let decoded: [ApiCall<T>] = try jsonString.decodeArray() ?? [] // Logger.log("loaded \(fileURL.lastPathComponent) with \(decoded.count) items")
self.items = decoded self.items = decoded
} catch {
let decoded: [OldApiCall<T>] = try jsonString.decodeArray() ?? []
self.items = decoded.compactMap { $0.toNewApiCall() }
}
} }
} }
@ -98,15 +75,17 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
fileprivate func _write() { fileprivate func _write() {
let fileName = ApiCall<T>.fileName() let fileName = ApiCall<T>.fileName()
DispatchQueue(label: "lestorage.queue.write", qos: .utility).asyncAndWait { DispatchQueue(label: "lestorage.queue.write", qos: .utility).asyncAndWait {
Logger.log("Start write to \(fileName)...")
do { do {
let jsonString: String = try self.items.jsonString() let jsonString: String = try self.items.jsonString()
try self.storeCenter.write(content: jsonString, fileName: fileName) try T.writeToStorageDirectory(content: jsonString, fileName: fileName)
} catch { } catch {
Logger.error(error) Logger.error(error)
} }
Logger.log("End write")
} }
} }
/// Adds or update an API call instance /// Adds or update an API call instance
func addOrUpdate(_ instance: ApiCall<T>) { func addOrUpdate(_ instance: ApiCall<T>) {
if let index = self.items.firstIndex(where: { $0.id == instance.id }) { if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
@ -116,27 +95,26 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
} }
self._hasChanged = true self._hasChanged = true
} }
/// Deletes an API call by [id] /// Deletes an API call by [id]
func deleteById(_ id: String) { func deleteById(_ id: String) {
self.items.removeAll(where: { $0.id == id }) self.items.removeAll(where: { $0.id == id })
// Logger.log("\(T.resourceName()) > Delete by id, count after deletion = \(self.items.count)")
self._hasChanged = true self._hasChanged = true
} }
/// Deletes a call by a data id /// Deletes a call by a data id
func deleteByDataId(_ dataId: String) { 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.items.remove(at: apiCallIndex)
self._hasChanged = true self._hasChanged = true
} }
} }
/// Returns the Api call associated with the provided id /// Returns the Api call associated with the provided id
func findById(_ id: String) -> ApiCall<T>? { func findById(_ id: String) -> ApiCall<T>? {
return self.items.first(where: { $0.id == id }) return self.items.first(where: { $0.id == id })
} }
/// Returns the Api call associated with the provided id /// Returns the Api call associated with the provided id
func findCallById(_ id: String) async -> (any SomeCall)? { func findCallById(_ id: String) async -> (any SomeCall)? {
return self.findById(id) return self.findById(id)
@ -144,11 +122,9 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
/// Removes all objects in memory and deletes the JSON file /// Removes all objects in memory and deletes the JSON file
func reset() { func reset() {
self._isExecutingCalls = false self._executionTask?.cancel()
self._schedulingTask?.cancel()
self.items.removeAll() self.items.removeAll()
self._hasChanged = true
do { do {
let url: URL = try self._urlForJSONFile() let url: URL = try self._urlForJSONFile()
if FileManager.default.fileExists(atPath: url.path()) { if FileManager.default.fileExists(atPath: url.path()) {
@ -159,281 +135,151 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
} }
} }
func resumeApiCalls() { /// Reschedule the execution of API calls
self._attemptLoops = -1 fileprivate func _rescheduleApiCalls() {
self.rescheduleApiCallsIfNecessary()
guard self.items.isNotEmpty else {
if self._schedulingTask != nil && self._attemptLoops > 2 { return
self._schedulingTask?.cancel()
self._attemptLoops = -1
self.rescheduleApiCallsIfNecessary()
} }
}
self._isRetryingCalls = true
self._attemptLoops += 1
self._executionTask = Task {
let delay = pow(2, self._attemptLoops)
let seconds = NSDecimalNumber(decimal: delay).intValue
Logger.log("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 {
try await self._executeApiCall(apiCall)
} catch {
Logger.error(error)
}
}
/// Reschedule API calls without waiting self._hasChanged = true
func rescheduleImmediately() {
self._attemptLoops = -1
self.rescheduleApiCallsIfNecessary()
}
/// Reschedule API calls if necessary if self.items.isEmpty {
func rescheduleApiCallsIfNecessary() { self._isRetryingCalls = false
if self.items.isNotEmpty && !self._isExecutingCalls { } else {
self._schedulingTask = Task { self._rescheduleApiCalls()
await self._waitAndExecuteApiCalls()
} }
} }
} }
/// Reschedule the execution of API calls // MARK: - Synchronization
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)")
}
fileprivate func _batchExecution() async { /// Returns an APICall instance for the Storable [instance] and an HTTP [method]
let batches = Dictionary(grouping: self.items, by: { $0.transactionId }) /// The method updates existing calls or creates a new one
fileprivate func _callForInstance(_ instance: T, method: HTTPMethod) throws -> ApiCall<T>? {
for batch in batches.values {
do { if let existingCall = self.items.first(where: { $0.dataId == instance.id }) {
if batch.count == 1, let apiCall = batch.first, apiCall.method == .get { switch method {
try await self._executeGetCall(apiCall: apiCall) 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 { } else {
let results: [OperationResult<T>] = try await self._executeApiCalls(batch) return try self._createCall(instance, method: method) // otherwise it's a put and we want to send the delete
if T.copyServerResponse {
let instances: [T] = results.compactMap { $0.data }
self.storeCenter.updateLocalInstances(instances)
}
} }
} catch { default: // here we should only trying to PUT, so we update the existing POST/PUT with the instance new values
Logger.error(error) 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 { } else {
let results: [T] = try self._decode(data: data) return try self._createCall(instance, method: method)
await self.storeCenter.itemsRetrieved(results, storeId: apiCall.storeId, clear: apiCall.option != .additive)
} }
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] /// Creates an API call for the Storable [instance] and an HTTP [method]
fileprivate func _createCall(_ method: HTTPMethod, instance: T?, transactionId: String? = nil, option: CallOption? = nil) -> ApiCall<T> { fileprivate func _createCall(_ instance: T, method: HTTPMethod) throws -> ApiCall<T> {
if let instance { let jsonString = try instance.jsonString()
return ApiCall(method: method, data: instance, transactionId: transactionId, option: option) return ApiCall(method: method, dataId: String(instance.id), body: jsonString)
} else {
return ApiCall(method: .get, data: nil, option: option)
}
} }
/// Prepares a call for execution by updating its properties and adding it to its collection for storage /// 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.lastAttemptDate = Date()
apiCall.attemptsCount += 1 apiCall.attemptsCount += 1
self.addOrUpdate(apiCall) 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] /// Reschedule API calls if necessary
func sendGetRequest(storeId: String?, clear: Bool = true) async throws { func rescheduleApiCallsIfNecessary() {
var parameters: [String : String]? = nil if !self._isRetryingCalls {
if let storeId { self._rescheduleApiCalls()
parameters = [Services.storeIdURLParameter : storeId]
} }
try await self._sendGetRequest(parameters: parameters, clear: clear)
} }
/// Sends an insert api call for the provided [instance] /// Sends an insert api call for the provided [instance]
fileprivate func _sendGetRequest(parameters: [String : String]?, clear: Bool = true) async throws { func sendInsertion(_ instance: T) {
Task {
if let getCall = self._createGetCallIfNonExistent(parameters, clear: clear) {
do { do {
try await self._prepareAndSendGetCall(getCall) try await self._synchronize(instance, method: HTTPMethod.post)
} catch { } catch {
self.rescheduleApiCallsIfNecessary() self.rescheduleApiCallsIfNecessary()
Logger.error(error) Logger.error(error)
} }
} else {
self.rescheduleImmediately()
} }
} }
/// Creates and execute the ApiCalls corresponding to the [batch] /// Sends an update api call for the provided [instance]
func executeBatch(_ batch: OperationBatch<T>) { func sendUpdate(_ instance: T) {
self._prepareCalls(batch: batch) Task {
self.rescheduleImmediately() do {
try await self._synchronize(instance, method: HTTPMethod.put)
} catch {
self.rescheduleApiCallsIfNecessary()
Logger.error(error)
}
}
} }
func singleBatchExecution(_ batch: OperationBatch<T>) async { /// Sends an delete api call for the provided [instance]
self._prepareCalls(batch: batch) func sendDeletion(_ instance: T) {
await self._batchExecution() Task {
do {
try await self._synchronize(instance, method: HTTPMethod.delete)
} catch {
self.rescheduleApiCallsIfNecessary()
Logger.error(error)
}
}
} }
func executeSingleGet(instance: T) async throws -> Data where T : URLParameterConvertible { /// Initiates the process of sending the data with the server
let call = self._createCall(.get, instance: instance, option: .none) fileprivate func _synchronize(_ instance: T, method: HTTPMethod) async throws {
call.urlParameters = instance.queryParameters(storeCenter: self.storeCenter) if let apiCall = try self._callForInstance(instance, method: method) {
self._addCallToWaitingList(call) try self._prepareCall(apiCall: apiCall)
return try await self._executeGetCall(apiCall: call) try await self._executeApiCall(apiCall)
}
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)
} }
} }
/// 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 /// Executes an API call
/// For POST requests, potentially copies additional data coming from the server during the insert /// For POST requests, potentially copies additional data coming from the server during the insert
fileprivate func _executeApiCalls(_ apiCalls: [ApiCall<T>]) async throws -> [OperationResult<T>] { fileprivate func _executeApiCall(_ apiCall: ApiCall<T>) async throws {
let result = try await StoreCenter.main.execute(apiCall: apiCall)
// Logger.log("/// \(T.resourceName()) > Start \(apiCalls.count) calls execution...") switch apiCall.method {
case .post:
let results = try await self.storeCenter.execute(apiCalls: apiCalls) if let instance = self.findById(result.stringId) {
for result in results { self._hasChanged = instance.copyFromServerInstance(result)
switch result.status {
case 200..<300:
self.deleteById(result.apiCallId)
default:
break
} }
default:
break
} }
return results // Logger.log("")
} }
/// Returns the content of the API call file as a String /// Returns the content of the API call file as a String
func contentOfFile() -> String? { func contentOfFile() -> String? {
guard let fileURL = try? self._urlForJSONFile() else { return nil } guard let fileURL = try? self._urlForJSONFile() else { return nil }
@ -442,39 +288,10 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
} }
return nil return nil
} }
/// Returns if the API call collection is not empty /// Returns if the API call collection is not empty
func hasPendingCalls() -> Bool { func hasPendingCalls() -> Bool {
// print("\(T.resourceName()) calls = \(self.items.count)")
return self.items.isNotEmpty 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 import Foundation
public protocol SomeCall: Identifiable, Storable { protocol SomeCall: Storable {
var id: String { get } var id: String { get }
var lastAttemptDate: Date { get } var lastAttemptDate: Date { get }
var attemptsCount: Int { 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 resourceName() -> String { return "apicalls_" + T.resourceName() }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] } static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
static func storeParent() -> Bool { return false } static func filterByStoreIdentifier() -> Bool { return false }
var id: String = Store.randomId() var id: String = Store.randomId()
/// The transactionId to group calls together
var transactionId: String? = Store.randomId()
/// Creation date of the call /// The http URL of the call
var creationDate: Date? = Date() // var url: String
/// The HTTP method of the call /// The HTTP method of the call: post...
var method: HTTPMethod var method: HTTPMethod
/// The content of the call /// The id of the underlying data
var body: String? var dataId: String
/// The id of the underlying data stored in the body /// The content of the call
var dataId: String? var body: String
/// The number of times the call has been executed /// The number of times the call has been executed
var attemptsCount: Int = 0 var attemptsCount: Int = 0
@ -141,71 +39,11 @@ class OldApiCall<T: Storable>: ModelObject, Storable, SomeCall {
/// The date of the last execution /// The date of the last execution
var lastAttemptDate: Date = Date() var lastAttemptDate: Date = Date()
/// The parameters to add in the URL to obtain : "?p1=v1&p2=v2" init(method: HTTPMethod, dataId: String, body: String) {
var urlParameters: [String : String]? = nil // self.url = url
init(method: HTTPMethod, dataId: String? = nil, body: String? = nil, transactionId: String? = nil) {
self.method = method self.method = method
self.dataId = dataId self.dataId = dataId
self.body = body 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 import Foundation
class FailedAPICall: SyncedModelObject, SyncedStorable { class FailedAPICall: ModelObject, Storable {
static func resourceName() -> String { return "failed-api-calls" } static func resourceName() -> String { return "failed-api-calls" }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] } static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
static func relationships() -> [Relationship] { return [] } static func filterByStoreIdentifier() -> Bool { return false }
public static func parentRelationships() -> [Relationship] { return [] }
public static func childrenRelationships() -> [Relationship] { return [] }
static var copyServerResponse: Bool = false
static func storeParent() -> Bool { return false }
override required init() {
self.callId = ""
self.type = ""
self.apiCall = ""
self.error = ""
super.init()
}
var id: String = Store.randomId() var id: String = Store.randomId()
/// The creation date of the call /// The creation date of the call
var date: Date = Date() var date: Date = Date()
/// The id of the API call /// The id of the API call
var callId: String var callId: String
/// The type of the call /// The type of the call
var type: String var type: String
/// The JSON representation of the API call /// The JSON representation of the API call
var apiCall: String var apiCall: String
/// The server error /// The server error
var error: String var error: String
/// The authentication header /// The authentication header
var authentication: String? var authentication: String?
init(callId: String, type: String, apiCall: String, error: String, authentication: String?) { init(callId: String, type: String, apiCall: String, error: String, authentication: String?) {
self.callId = callId self.callId = callId
self.type = type self.type = type
self.apiCall = apiCall self.apiCall = apiCall
self.error = error self.error = error
self.authentication = authentication 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 [] }
}

@ -1,73 +0,0 @@
//
// Log.swift
// LeStorage
//
// Created by Laurent Morvillier on 03/07/2024.
//
import Foundation
class Log: SyncedModelObject, SyncedStorable {
static func resourceName() -> String { return "logs" }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
static func relationships() -> [Relationship] { return [] }
public static func parentRelationships() -> [Relationship] { return [] }
public static func childrenRelationships() -> [Relationship] { return [] }
static var copyServerResponse: Bool = false
static func storeParent() -> Bool { return false }
override required init() {
super.init()
}
var id: String = Store.randomId()
var date: Date = Date()
var user: String? = nil
var message: String = ""
init(message: String, user: String?) {
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)
}
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)
}
func copy(from other: any Storable) {
guard let log = other as? Log else { return }
self.date = log.date
self.message = log.message
}
public func copyForUpdate(from other: any Storable) {
fatalError("should not happen")
}
}

@ -1,34 +0,0 @@
//
// WaitingOperation.swift
// LeStorage
//
// Created by Laurent Morvillier on 01/04/2025.
//
import Foundation
enum StorageMethod: String, Codable {
case add
case update
case delete
case deleteUnusedShared
}
class PendingOperation<T : Storable>: Codable, Equatable {
var id: String = Store.randomId()
var method: StorageMethod
var data: T
var actionOption: ActionOption
init(method: StorageMethod, data: T, actionOption: ActionOption) {
self.method = method
self.data = data
self.actionOption = actionOption
}
static func == (lhs: PendingOperation, rhs: PendingOperation) -> Bool {
return lhs.id == rhs.id
}
}

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

@ -8,86 +8,20 @@
import Foundation import Foundation
/// A class used as the root class for Storable objects /// A class used as the root class for Storable objects
/// Provides default implementations of the Storable protocol open class ModelObject {
open class ModelObject: NSObject {
public var store: Store? = nil 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 open func copyFromServerInstance(_ instance: any Storable) -> Bool {
return false
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 class SyncedModelObject: BaseModelObject {
public var relatedUser: String? = nil static var relationshipNames: [String] = []
public var lastUpdate: Date = Date()
public var sharing: SharingStatus?
public override init() {
super.init()
}
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 import Foundation
/// A protocol describing classes that can be stored locally in JSON and synchronized on our django server /// 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 /// The store containing a reference to the instance
var store: Store? { get set } var store: Store? { get set }
@ -17,29 +17,26 @@ public protocol Storable: Codable, Identifiable, NSObjectProtocol {
/// Also used as the name of the local file /// Also used as the name of the local file
static func resourceName() -> String 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 /// 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, /// 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 /// 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 /// A method called to retrieve data added by the server on a POST request
/// and not referenced by other objects in the store /// The method will be called after a POST has succeeded,
/// This is used when cleaning up shared objects that are no longer in use /// and will provide a copy of what's on the server
func deleteUnusedSharedDependencies(store: Store) func copyFromServerInstance(_ instance: any Storable) -> Bool
/// Copies the content of another item into the instance static var relationshipNames: [String] { get }
/// 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
} }
@ -51,16 +48,7 @@ extension Storable {
} }
/// Returns a string id for the instance /// Returns a string id for the instance
public var stringId: String { var stringId: String { return String(self.id) }
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")
}
}
/// Returns the relative path of the instance for the django server /// Returns the relative path of the instance for the django server
static func path(id: String? = nil) -> String { static func path(id: String? = nil) -> String {
@ -72,15 +60,20 @@ extension Storable {
return path return path
} }
static func buildRealId(id: String) -> ID { public static func storageDirectoryPath() throws -> URL {
switch ID.self { return try FileUtils.pathForDirectoryInDocuments(directory: Store.storageDirectory)
case is String.Type: }
return id as! ID
case is Int64.Type: static func writeToStorageDirectory(content: String, fileName: String) throws {
return Formatter.number.number(from: id)?.int64Value as! ID var fileURL = try self.storageDirectoryPath()
default: fileURL.append(component: fileName)
fatalError("ID \(type(of: ID.self)) is neither String nor Int, can't parse \(id)") 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,66 @@
import Foundation import Foundation
import UIKit import UIKit
public enum StoreError: Error, LocalizedError { //public enum ResetOption {
// case all
// case synchronizedOnly
//}
public enum StoreError: Error {
case missingService case missingService
case missingUserId case missingUserId
case missingUsername case unexpectedCollectionType(name: String)
case missingToken
case missingKeychainStore
case collectionNotRegistered(type: String)
case apiCallCollectionNotRegistered(type: String) case apiCallCollectionNotRegistered(type: String)
case synchronizationInactive case collectionNotRegistered(type: String)
case storeNotRegistered(id: String) case cannotSyncCollection(name: String)
case castIssue(type: String) }
case invalidStoreLookup(from: any Storable.Type, to: any Storable.Type)
public struct StoreIdentifier {
public var localizedDescription: String { var value: String
switch self { var parameterName: String
case .missingService:
return "L'instance des services est nulle"
case .missingUsername:
return "Le nom d'utilisateur est manquant"
case .missingUserId:
return "L'identifiant utilisateur est manquant"
case .missingToken:
return "Aucun token n'est stocké"
case .missingKeychainStore:
return "Aucun magasin de trousseau n'est disponible"
case .collectionNotRegistered(let type):
return "La collection \(type) n'est pas enregistrée"
case .apiCallCollectionNotRegistered(let type):
return "La collection d'appels API n'a pas été enregistrée pour \(type)"
case .synchronizationInactive:
return "La synchronisation n'est pas active sur ce StoreCenter"
case .storeNotRegistered(let id):
return "Le magasin avec l'identifiant \(id) n'est pas enregistré"
case .castIssue(let type):
return "Problème de typage: \(type)"
case .invalidStoreLookup(let from, let to):
return "Mauvaise recherche dans le magasin de \(from) à \(to)"
}
}
public var errorDescription: String? { public init(value: String, parameterName: String) {
switch self { self.value = value
case .missingService: self.parameterName = parameterName
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)"
}
} }
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] = [:] fileprivate var _collections: [String : any SomeCollection] = [:]
/// The dictionary of all StoredCollection /// The name of the directory to store the json files
fileprivate var _baseCollections: [String : any SomeCollection] = [:] static let storageDirectory = "storage"
/// The store identifier, used to name the store directory, and to perform filtering requests to the server /// 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) { /// Indicates whether the store directory has been created at the init
self.storeCenter = storeCenter fileprivate var _created: Bool = false
}
public required init(storeCenter: StoreCenter, identifier: String) { public init() {
self.storeCenter = storeCenter self._createDirectory(directory: Store.storageDirectory)
self.identifier = identifier
let directory = "\(storeCenter.directoryName)/\(identifier)"
self._createDirectory(directory: directory)
} }
public static var main: Store { return StoreCenter.main.mainStore } public required init(identifier: String, parameter: String) {
self.identifier = StoreIdentifier(value: identifier, parameterName: parameter)
public func alternateStore(identifier: String) throws -> Store { let directory = "\(Store.storageDirectory)/\(identifier)"
return try self.storeCenter.store(identifier: identifier) self._createDirectory(directory: directory)
} }
/// Creates the store directory /// Creates the store directory
/// - Parameters: /// - Parameters:
/// - directory: the name of the directory /// - directory: the name of the directory
fileprivate func _createDirectory(directory: String) { 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 /// A method to provide ids corresponding to the django storage
@ -122,72 +77,36 @@ final public class Store {
/// Registers a collection /// Registers a collection
/// - Parameters: /// - Parameters:
/// - synchronized: indicates if the data is synchronized with the server
/// - indexed: Creates an index to quickly access the data /// - 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 /// - 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> { /// - 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> {
if let _ = try? self.someCollection(type: T.self) {
fatalError("collection already registered")
// return collection
}
let collection = StoredCollection<T>(store: self, indexed: indexed, inMemory: inMemory, limit: limit) // register collection
let collection = StoredCollection<T>(synchronized: synchronized, store: self, indexed: indexed, inMemory: inMemory, sendsUpdate: sendsUpdate)
self._collections[T.resourceName()] = collection self._collections[T.resourceName()] = collection
self._baseCollections[T.resourceName()] = collection
return collection if synchronized {
} StoreCenter.main.loadApiCallCollection(type: T.self)
}
/// 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 let collection: SyncedCollection<T> = try? self.syncedCollection() { if self._created, let identifier {
return collection 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 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 /// Registers a singleton object
/// - Parameters: /// - Parameters:
/// - synchronized: indicates if the data is synchronized with the server /// - 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 /// - 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 /// - 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) let storedObject = StoredSingleton<T>(synchronized: synchronized, store: self, inMemory: inMemory, sendsUpdate: sendsUpdate)
self._collections[T.resourceName()] = storedObject
self._collections[T.resourceName()] = storedObject self._collections[T.resourceName()] = storedObject
if synchronized {
self.storeCenter.loadApiCallCollection(type: T.self)
}
return storedObject return storedObject
} }
@ -196,241 +115,60 @@ final public class Store {
/// Looks for an instance by id /// Looks for an instance by id
/// - Parameters: /// - Parameters:
/// - id: the id of the data /// - id: the id of the data
public func findById<T: Storable>(_ id: T.ID) -> T? { public func findById<T: Storable>(_ id: String) -> T? {
guard let collection = self._baseCollections[T.resourceName()] as? StoredCollection<T> else { guard let collection = self._collections[T.resourceName()] as? StoredCollection<T> else {
Logger.w("Collection \(T.resourceName()) not registered") Logger.w("Collection \(T.resourceName()) not registered")
return nil return nil
} }
return collection.findById(id) return collection.findById(id)
} }
/// Returns a collection by type /// Filters a collection by predicate
func syncedCollection<T: SyncedStorable>() throws -> SyncedCollection<T> { /// - Parameters:
if let collection = self._collections[T.resourceName()] as? SyncedCollection<T> { /// - isIncluded: a predicate to returns if a data should be filtered in
return collection 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 /// Returns a collection by type
func someCollection<T: Storable>(type: T.Type) throws -> any SomeCollection { func collection<T: Storable>() throws -> StoredCollection<T> {
if let collection = self._collections[T.resourceName()] { if let collection = self._collections[T.resourceName()] as? StoredCollection<T> {
return collection return collection
} }
throw StoreError.collectionNotRegistered(type: T.resourceName()) 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 /// Loads all collection with the data from the server
public func loadCollectionsFromServer(clear: Bool) { public func loadCollectionFromServer() {
for collection in self._syncedCollections() { for collection in self._collections.values {
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 { Task {
do { try? await collection.loadDataFromServerIfAllowed()
try await collection.loadCollectionsFromServerIfNoFile()
} catch {
Logger.error(error)
}
} }
} }
} }
/// 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 /// Resets all registered collection
public func reset() { public func reset() {
for collection in self._collections.values { for collection in self._collections.values {
collection.reset() 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)
} /// Returns the names of all collections
public func collectionNames() -> [String] {
fileprivate func _instance<T: SyncedStorable>(id: String, type: T.Type) -> T? { return self._collections.values.map { $0.resourceName }
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)
}
} }
// MARK: - Write // MARK: - Write
/// Returns the directory URL of the store /// Returns the directory URL of the store
fileprivate func _directoryPath() throws -> URL { fileprivate func _directoryPath() throws -> URL {
var url = try FileUtils.pathForDirectoryInDocuments(directory: storeCenter.directoryName) var url = try FileUtils.pathForDirectoryInDocuments(directory: Store.storageDirectory)
if let identifier { if let identifier = self.identifier?.value {
url.append(component: identifier) url.append(component: identifier)
} }
return url return url
@ -444,22 +182,14 @@ final public class Store {
var fileURL = try self._directoryPath() var fileURL = try self._directoryPath()
fileURL.append(component: fileName) fileURL.append(component: fileName)
try content.write(to: fileURL, atomically: false, encoding: .utf8) try content.write(to: fileURL, atomically: false, encoding: .utf8)
// Logger.log("write into \(fileURL)...")
} }
/// Returns the URL matching a Storable type /// Returns the URL matching a Storable type
/// - Parameters: /// - Parameters:
/// - type: a Storable type /// - type: a Storable type
func fileURL<T: Storable>(type: T.Type) throws -> URL { 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() let fileURL = try self._directoryPath()
return fileURL.appending(component: fileName) return fileURL.appending(component: T.fileName())
} }
/// Removes a file matching a Storable type /// Removes a file matching a Storable type
@ -477,27 +207,84 @@ final public class Store {
} }
/// Retrieves all the items on the server /// Retrieves all the items on the server
public func getItems<T: SyncedStorable>() async throws -> [T] { public func getItems<T: Storable>() async throws -> [T] {
if let identifier = self.identifier { if T.filterByStoreIdentifier() {
return try await self.storeCenter.getItems(identifier: identifier) return try await StoreCenter.main.getItems(identifier: self.identifier)
} else { } 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 {
try await StoreCenter.main.sendInsertion(instance)
}
func loadCollectionItems<T: SyncedStorable>(_ items: [T], clear: Bool) async { /// Requests an update to the StoreCenter
do { /// - Parameters:
let collection: SyncedCollection<T> = try self.syncedCollection() /// - instance: an object to update
await collection.loadItems(items, clear: clear) func sendUpdate<T: Storable>(_ instance: T) async throws {
} catch { try await StoreCenter.main.sendUpdate(instance)
Logger.error(error) }
/// Requests a deletion to the StoreCenter
/// - Parameters:
/// - instance: an object to delete
func sendDeletion<T: Storable>(_ instance: T) async throws {
try await StoreCenter.main.sendDeletion(instance)
}
public func loadCollectionsFromServerIfNoFile() {
for collection in self._collections.values {
// Logger.log("Load \(name)")
Task {
do {
try await collection.loadCollectionsFromServerIfNoFile()
} catch {
Logger.error(error)
}
}
} }
} }
/// Returns whether all collections have loaded locally fileprivate var _validIds: [String] = []
public func fileCollectionsAllLoaded() -> Bool {
let fileCollections = self._collections.values.filter { $0.inMemory == false } fileprivate func _migrate<T : Storable>(_ collection: StoredCollection<T>, identifier: StoreIdentifier, type: T.Type) {
return fileCollections.allSatisfy { $0.hasLoaded }
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,503 @@
// //
import Foundation import Foundation
import Combine
public protocol SomeCollection<Item>: Identifiable { enum StoredCollectionError: Error {
case unmanagedHTTPMethod(method: String)
associatedtype Item: Storable case missingApiCallCollection
case missingInstance
var hasLoaded: Bool { get }
var inMemory: Bool { get }
var type: any Storable.Type { get }
func reset()
func hasParentReferences<S: Storable>(type: S.Type, id: String) -> Bool
var items: [Item] { get }
func deleteAllItemsAndDependencies(actionOption: ActionOption)
func deleteDependencies(actionOption: ActionOption, _ isIncluded: (Item) -> Bool)
func findById(_ id: Item.ID) -> Item?
func requestWriteIfNecessary()
} }
protocol CollectionDelegate<Item> { protocol CollectionHolder {
associatedtype Item: Storable associatedtype Item
func loadingForMemoryCollection() async
func itemMerged(_ pendingOperation: PendingOperation<Item>) var items: [Item] { get }
func reset()
} }
enum CollectionMethod { protocol SomeCollection: CollectionHolder, Identifiable {
case insert var resourceName: String { get }
case update var synchronized: Bool { get }
case delete
}
public struct ActionResult<T> { func allItems() -> [any Storable]
var instance: T
var method: CollectionMethod func loadDataFromServerIfAllowed() async throws
var pending: Bool func loadCollectionsFromServerIfNoFile() async throws
} }
public struct ActionOption: Codable { extension Notification.Name {
var synchronize: Bool public static let CollectionDidLoad: Notification.Name = Notification.Name.init("notification.collectionDidLoad")
var cascade: Bool public static let CollectionDidChange: Notification.Name = Notification.Name.init("notification.collectionDidChange")
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)
} }
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 /// 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 /// The list of stored items
@Published public fileprivate(set) var items: [T] = [] @Published public fileprivate(set) var items: [T] = []
/// The reference to the Store /// The reference to the Store
fileprivate(set) var store: Store fileprivate var _store: Store
/// Notifies the closure when the loading is done
// fileprivate var loadCompletion: ((StoredCollection<T>) -> ())? = nil
/// Provides fast access for instances if the collection has been instanced with [indexed] = true /// Provides fast access for instances if the collection has been instanced with [indexed] = true
fileprivate var _indexes: [T.ID: T]? = nil fileprivate var _indexes: [String : T]? = nil
/// A PendingOperationManager instance that manages operations while the collection is not loaded
fileprivate(set) var pendingOperationManager: PendingOperationManager<T>? = nil
fileprivate var _writingTimer: Timer? = nil /// Collection of API calls used to store HTTP calls
// fileprivate var apiCallsCollection: ApiCallCollection<T>? = nil
/// Indicates whether the collection has changed, thus requiring a write operation /// Indicates whether the collection has changed, thus requiring a write operation
fileprivate var _triggerWrite: Bool = false { fileprivate var _hasChanged: Bool = false {
didSet { didSet {
if self._triggerWrite == true { if self._hasChanged == true {
self._scheduleWrite() self._scheduleWrite()
self._triggerWrite = false DispatchQueue.main.async {
} NotificationCenter.default.post(name: NSNotification.Name.CollectionDidChange, object: self)
}
DispatchQueue.main.async { self._hasChanged = false
NotificationCenter.default.post(
name: NSNotification.Name.CollectionDidChange, object: self)
} }
} }
} }
/// 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 /// Indicates if the collection has loaded objects from the server
fileprivate(set) public var hasLoaded: Bool = false fileprivate(set) public var hasLoadedFromServer: 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 { if indexed {
self._indexes = [:] self._indexes = [:]
} }
self.inMemory = inMemory self._inMemory = inMemory
self.store = store self._sendsUpdate = sendsUpdate
self.limit = limit self._store = store
if noLoad {
self.hasLoaded = true
} else {
Task {
await self.load()
}
}
self._load()
} }
init(store: Store) { fileprivate init() {
self.store = store self.synchronized = false
self._store = Store.main
} }
var storeCenter: StoreCenter { return self.store.storeCenter }
/// Returns the name of the managed resource public static func placeholder() -> StoredCollection<T> {
public var resourceName: String { return StoredCollection<T>()
return T.resourceName()
} }
public var storeId: String? { var resourceName: String {
return self.store.identifier return T.resourceName()
} }
// MARK: - Loading // 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 /// Migrates if necessary and asynchronously decodes the json file
func load() async { fileprivate func _load() {
if !self.inMemory {
await self.loadFromFile()
} else {
await MainActor.run {
self.setAsLoaded()
}
}
}
/// Starts the JSON file decoding asynchronously
func loadFromFile() async {
do { do {
try await self._decodeJSONFile() if self._inMemory {
Task {
try await self.loadDataFromServerIfAllowed()
}
} else {
try self._loadFromFile()
}
} catch { } catch {
Logger.error(error) Logger.error(error)
await MainActor.run { }
self.setAsLoaded()
} }
do {
let fileURL = try self.store.fileURL(type: T.self) /// Starts the JSON file decoding synchronously or asynchronously
let jsonString: String = try FileUtils.readFile(fileURL: fileURL) fileprivate func _loadFromFile() throws {
if !jsonString.isEmpty {
StoreCenter.main.log(message: "Could not decode: \(jsonString)") if self.asynchronousIO {
} Task(priority: .high) {
} catch { try self._decodeJSONFile()
} }
} else {
try self._decodeJSONFile()
} }
} }
/// Decodes the json file into the items array /// Decodes the json file into the items array
fileprivate func _decodeJSONFile() async throws { fileprivate func _decodeJSONFile() throws {
let fileURL = try self._store.fileURL(type: T.self)
let fileURL = try self.store.fileURL(type: T.self)
if FileManager.default.fileExists(atPath: fileURL.path()) { if FileManager.default.fileExists(atPath: fileURL.path()) {
let jsonString: String = try FileUtils.readFile(fileURL: fileURL) let jsonString: String = try FileUtils.readFile(fileURL: fileURL)
let decoded: [T] = try jsonString.decodeArray() ?? [] let decoded: [T] = try jsonString.decodeArray() ?? []
self.hasLoaded = true // avoid pending management for var item in decoded {
self.setItems(decoded) item.store = self._store
} }
await MainActor.run { if self.asynchronousIO {
self.setAsLoaded() DispatchQueue.main.async {
} self._setItems(decoded)
}
} else {
self._setItems(decoded)
}
}
// else if self.synchronized {
// Task {
// do {
// try await self.loadDataFromServerIfAllowed()
// } catch {
// Logger.error(error)
// }
// }
// }
} }
/// Sets the collection as loaded fileprivate func _setItems(_ items: [T]) {
/// Send a CollectionDidLoad event self.items = items
@MainActor self._updateIndexIfNecessary()
func setAsLoaded() { NotificationCenter.default.post(name: NSNotification.Name.CollectionDidLoad, object: self)
self.hasLoaded = true
self._mergePendingOperations()
NotificationCenter.default.post(
name: NSNotification.Name.CollectionDidLoad, object: self)
} }
/// Sets a collection of items and indexes them /// Updates the whole index with the items array
func setItems(_ items: [T]) { fileprivate func _updateIndexIfNecessary() {
self.clear() if let _ = self._indexes {
for item in items { self._indexes = self.items.dictionary { $0.stringId }
self._addItem(instance: item)
} }
} }
@MainActor /// Retrieves the data from the server and loads it into the items array
func loadAndWrite(_ items: [T], clear: Bool = false) { public func loadDataFromServerIfAllowed() async throws {
if clear { guard self.synchronized, !(self is StoredSingleton<T>) else {
self.setItems(items) throw StoreError.cannotSyncCollection(name: self.resourceName)
self.setAsLoaded() }
} else { do {
self.setAsLoaded() let items: [T] = try await self._store.getItems()
self.addOrUpdate(contentOfs: items) if items.count > 0 {
try self._addOrUpdate(contentOfs: items, shouldSync: false)
}
self.hasLoadedFromServer = true
DispatchQueue.main.async {
NotificationCenter.default.post(name: NSNotification.Name.CollectionDidLoad, object: self)
}
} catch {
Logger.error(error)
} }
self.requestWriteIfNecessary()
} }
/// Updates the whole index with the items array func loadCollectionsFromServerIfNoFile() async throws {
fileprivate func _updateIndexIfNecessary() { let fileURL: URL = try self._store.fileURL(type: T.self)
if self._indexes != nil { if !FileManager.default.fileExists(atPath: fileURL.path()) {
self._indexes = self.items.dictionary { $0.id } try await self.loadDataFromServerIfAllowed()
} }
} }
// MARK: - Basic operations // MARK: - Basic operations
/// Adds or updates the provided instance inside the collection /// Adds or updates the provided instance inside the collection
/// Adds it if its id is not found, and otherwise updates it /// Adds it if its id is not found, and otherwise updates it
@discardableResult public func addOrUpdate(instance: T) -> ActionResult<T> { public func addOrUpdate(instance: T) throws {
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 { defer {
self.requestWriteIfNecessary() self._hasChanged = true
}
for instance in sequence {
let result = self._rawAddOrUpdate(instance: instance)
handler?(result)
} }
} var item = instance
item.store = self._store
fileprivate func _rawAddOrUpdate(instance: T) -> ActionResult<T> {
// update
if let index = self.items.firstIndex(where: { $0.id == instance.id }) { if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
let updated = self._updateItem(instance, index: index, actionOption: .standard) self.items[index] = instance
return ActionResult(instance: instance, method: .update, pending: !updated) self._sendUpdateIfNecessary(instance)
} else { } else { // insert
let added = self._addItem(instance: instance) self.items.append(instance)
return ActionResult(instance: instance, method: .insert, pending: !added) self._sendInsertionIfNecessary(instance)
} }
} self._indexes?[instance.stringId] = instance
/// A method the treat the collection as a single instance holder
func setSingletonNoSync(instance: T) {
defer {
self.requestWriteIfNecessary()
}
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 writeChangeAndInsertOnServer(instance: T) {
public func delete(instance: T, actionOption: ActionOption) {
defer { defer {
self._triggerWrite = true self._hasChanged = true
} }
self.deleteItem(instance, actionOption: actionOption) self._sendInsertionIfNecessary(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) { /// A method the treat the collection as a single instance holder
func setSingletonNoSync(instance: T) {
defer { defer {
self._triggerWrite = true self._hasChanged = true
}
for instance in sequence {
let deleted = self.deleteItem(instance, actionOption: actionOption)
handler?(ActionResult(instance: instance, method: .delete, pending: !deleted))
} }
} self.items.removeAll()
self.items.append(instance)
/// 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")
}
}
}
func add(instance: T, actionOption: ActionOption) {
self._addItem(instance: instance, actionOption: actionOption)
} }
/// Adds an instance to the collection /// Deletes the instance in the collection by id
@discardableResult fileprivate func _addItem(instance: T, actionOption: ActionOption = .standard) -> Bool { public func delete(instance: T) throws {
if !self.hasLoaded { defer {
self.addPendingOperation(method: .add, instance: instance, actionOption: actionOption) self._hasChanged = true
return false
} }
self.invalidateCache()
self._affectStoreIdIfNecessary(instance: instance)
self.items.append(instance)
instance.store = self.store
self._indexes?[instance.id] = instance
self._applyLimitIfPresent()
if T.storeParent() { try instance.deleteDependencies()
_ = self.storeCenter.requestStore(identifier: instance.stringId) // make directory self.items.removeAll { $0.id == instance.id }
} self._indexes?.removeValue(forKey: instance.stringId)
return true self._sendDeletionIfNecessary(instance)
}
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 /// Deletes all items of the sequence by id
@discardableResult fileprivate func _updateItem(_ instance: T, index: Int, actionOption: ActionOption) -> Bool { public func delete(contentOfs sequence: any Sequence<T>) throws {
if !self.hasLoaded { defer {
self.addPendingOperation(method: .update, instance: instance, actionOption: actionOption) self._hasChanged = true
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 {
if !self.hasLoaded {
self.addPendingOperation(method: .delete, instance: instance, actionOption: actionOption)
return false
}
if actionOption.cascade {
instance.deleteDependencies(store: self.store, actionOption: actionOption)
} }
self.localDeleteOnly(instance: instance) for instance in sequence {
try instance.deleteDependencies()
if T.storeParent() { self.items.removeAll { $0.id == instance.id }
self.storeCenter.destroyStore(identifier: instance.stringId) self._indexes?.removeValue(forKey: instance.stringId)
self._sendDeletionIfNecessary(instance)
} }
return true
} }
/// Deletes an instance from the collection /// Adds or update a sequence of elements
@discardableResult func deleteUnusedShared(_ instance: T, actionOption: ActionOption) -> Bool { public func addOrUpdate(contentOfs sequence: any Sequence<T>) throws {
try self._addOrUpdate(contentOfs: sequence)
if !self.hasLoaded {
self.addPendingOperation(method: .deleteUnusedShared, instance: instance, actionOption: actionOption)
return false
}
// For shared objects, we need to check for dependencies that are also shared
// but not used elsewhere before deleting them
instance.deleteUnusedSharedDependencies(store: self.store)
self.localDeleteOnly(instance: instance)
return true
} }
func localDeleteOnly(instance: T) { func addOrUpdateNoSync(contentOfs sequence: any Sequence<T>) throws {
self.invalidateCache() try self._addOrUpdate(contentOfs: sequence, shouldSync: false)
self.items.removeAll { $0.id == instance.id }
self._indexes?.removeValue(forKey: instance.id)
}
/// If the collection has more instance that its limit, remove the surplus
fileprivate func _applyLimitIfPresent() {
if let limit {
self.items = self.items.suffix(limit)
}
} }
func deleteByStringId(_ id: String, actionOption: ActionOption = .cascade) { /// Inserts or updates all items in the sequence
let realId = T.buildRealId(id: id) fileprivate func _addOrUpdate(contentOfs sequence: any Sequence<T>, shouldSync: Bool = true) throws {
if let instance = self.findById(realId) { defer {
self.deleteItem(instance, actionOption: actionOption) 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] /// 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] { if let index = self._indexes, let instance = index[id] {
return instance return instance
} }
return self.items.first(where: { $0.id == id }) 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>) { public func deleteDependencies(_ items: any Sequence<T>) {
defer { defer {
self.requestWriteIfNecessary() self._hasChanged = true
} }
self.invalidateCache() for item in items {
let itemsArray = Array(items) // fix error if items is self.items
for item in itemsArray {
if let index = self.items.firstIndex(where: { $0.id == item.id }) { if let index = self.items.firstIndex(where: { $0.id == item.id }) {
self.items.remove(at: index) self.items.remove(at: index)
} }
// self.items.removeAll(where: { $0.id == item.id })
Task {
do {
try await StoreCenter.main.deleteApiCallByDataId(type: T.self, id: item.stringId)
} catch {
Logger.error(error)
}
/// Remove related API call if existing
// await self.apiCallsCollection?.deleteByDataId(item.stringId)
}
} }
}
public func deleteAllItemsAndDependencies(actionOption: ActionOption) {
self._delete(contentOfs: self.items, actionOption: actionOption)
} }
public func deleteDependencies(actionOption: ActionOption, _ isIncluded: (T) -> Bool) { /// Proceeds to delete all instance of the collection, properly cleaning up dependencies and sending API calls
let items = self.items.filter(isIncluded) public func deleteAll() throws {
self._delete(contentOfs: items, actionOption: actionOption) try self.delete(contentOfs: self.items)
} }
// MARK: - Some Collection
fileprivate func _delete(contentOfs sequence: any RandomAccessCollection<T>, actionOption: ActionOption) { /// Deletes an API Call by its id
for instance in sequence { /// - Parameters:
self.deleteItem(instance, actionOption: actionOption) /// - id: the id of the API Call
} // func deleteApiCallById(_ id: String) async throws {
// await self.apiCallsCollection?.deleteById(id)
// }
//
// /// Returns an API Call by its id
// /// - Parameters:
// /// - id: the id of the API Call
// func apiCallById(_ id: String) async -> (any SomeCall)? {
// return await self.apiCallsCollection?.findById(id)
// }
} // MARK: - SomeCall
// MARK: - Pending operations
func addPendingOperation(method: StorageMethod, instance: T, actionOption: ActionOption) { /// Returns the collection items as [any Storable]
if self.pendingOperationManager == nil { func allItems() -> [any Storable] {
self.pendingOperationManager = PendingOperationManager<T>(store: self.store, inMemory: self.inMemory) return self.items
}
self._addPendingOperationIfPossible(method: method, instance: instance, actionOption: actionOption)
} }
fileprivate func _addPendingOperationIfPossible(method: StorageMethod, instance: T, actionOption: ActionOption) {
self.pendingOperationManager?.addPendingOperation(method: method, instance: instance, actionOption: actionOption)
}
fileprivate func _mergePendingOperations() {
guard let manager = self.pendingOperationManager, manager.items.isNotEmpty else { return }
Logger.log(">>> Merge pending \(manager.typeName): \(manager.items.count)")
for item in manager.items {
let data = item.data
switch item.method {
case .add, .update:
self.addOrUpdate(instance: data)
case .delete:
self.deleteItem(data, actionOption: item.actionOption)
case .deleteUnusedShared:
self.deleteUnusedShared(data, actionOption: item.actionOption)
}
}
manager.reset()
self.pendingOperationManager = nil
}
// MARK: - File access // MARK: - File access
/// Schedules a write operation /// Schedules a write operation
fileprivate func _scheduleWrite() { fileprivate func _scheduleWrite() {
self._cleanTimer()
DispatchQueue.main.async { guard !self._inMemory else { return }
self._writingTimer = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(self._write), userInfo: nil, repeats: false)
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 /// Writes all the items as a json array inside a file
@objc fileprivate func _write() { fileprivate func _write() {
DispatchQueue(label: "lestorage.queue.write", qos: .utility).async { Logger.log("Start write to \(T.fileName())...")
do { do {
let jsonString: String = try self.items.jsonString() let jsonString: String = try self.items.jsonString()
try self.store.write(content: jsonString, fileName: T.fileName()) try self._store.write(content: jsonString, fileName: T.fileName())
} catch { // try T.writeToStorageDirectory(content: jsonString, fileName: T.fileName())
Logger.error(error) } catch {
self.storeCenter.log( Logger.error(error) // TODO how to notify the main project
message: "write failed for \(T.resourceName()): \(error.localizedDescription)")
}
} }
self._cleanTimer() Logger.log("End write")
} }
/// Simply clears the items of the collection /// Simply clears the items of the collection
public func clear() { func clear() {
self.invalidateCache()
self.items.removeAll() self.items.removeAll()
} }
/// Removes the items of the collection and deletes the corresponding file /// Removes the items of the collection, deletes the corresponding file, and also reset the related API calls collection
public func reset() { public func reset() {
self.clear() self.items.removeAll()
self.store.removeFile(type: T.self) self._store.removeFile(type: T.self)
} // do {
// let url: URL = try T.urlForJSONFile()
public var type: any Storable.Type { return T.self } // if FileManager.default.fileExists(atPath: url.path()) {
// try FileManager.default.removeItem(at: url)
// MARK: - Reference count // }
// } catch {
/// Counts the references to an object - given its type and id - inside the collection // Logger.error(error)
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 } // self.resetApiCalls()
for item in self.items {
for relationship in relationships {
if item[keyPath: relationship.keyPath] as? String == id {
return true
}
}
}
return false
} }
// MARK: - for Synced Collection // /// Removes the collection related API calls collection
// public func resetApiCalls() {
// if let apiCallsCollection = self.apiCallsCollection {
// Task {
// await apiCallsCollection.reset()
// }
// }
// }
@MainActor // MARK: - Reschedule calls
func updateLocalInstance(_ serverInstance: T) {
if let localInstance = self.findById(serverInstance.id) { /// Sends an insert api call for the provided [instance]
localInstance.copy(from: serverInstance) /// - Parameters:
self.requestWriteIfNecessary() /// - instance: the object to POST
fileprivate func _sendInsertionIfNecessary(_ instance: T) {
guard self.synchronized else {
return
}
Task {
try await self._store.sendInsertion(instance)
} }
} }
// MARK: - Cached queries /// Sends an update api call for the provided [instance]
/// - Parameters:
fileprivate var _cacheVersion = 0 /// - instance: the object to PUT
fileprivate var _queryCache: [AnyHashable: (version: Int, result: Any)] = [:] fileprivate func _sendUpdateIfNecessary(_ instance: T) {
guard self.synchronized, self._sendsUpdate else {
// Generic query method with caching return
public func cached<Result>( }
key: AnyHashable, Task {
compute: ([T]) -> Result try await self._store.sendUpdate(instance)
) -> Result {
if let cached = self._queryCache[key],
cached.version == self._cacheVersion,
let result = cached.result as? Result {
return result
} }
let result = compute(items)
self._queryCache[key] = (self._cacheVersion, result)
return result
} }
private func invalidateCache() { /// Sends an delete api call for the provided [instance]
self._cacheVersion += 1 /// - Parameters:
/// - instance: the object to DELETE
fileprivate func _sendDeletionIfNecessary(_ instance: T) {
guard self.synchronized else {
return
}
Task {
try await self._store.sendDeletion(instance)
}
} }
} /// Reschedule the api calls if possible
// func rescheduleApiCallsIfNecessary() {
extension StoredCollection: RandomAccessCollection { // Task {
// await self.apiCallsCollection?.rescheduleApiCallsIfNecessary()
// }
// }
public static func placeholder() -> StoredCollection<T> { // MARK: - RandomAccessCollection
return StoredCollection<T>(store: Store(storeCenter: StoreCenter.main))
}
public var startIndex: Int { return self.items.startIndex } public var startIndex: Int { return self.items.startIndex }
public var endIndex: Int { return self.items.endIndex } public var endIndex: Int { return self.items.endIndex }
public func index(after i: Int) -> Int { 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 { get {
return self.items[index] return self.items[index]
} }
set(newValue) { set(newValue) {
self.items[index] = newValue self.items[index] = newValue
self._triggerWrite = true self._hasChanged = true
} }
} }
} }

@ -8,53 +8,28 @@
import Foundation import Foundation
/// A class extending the capabilities of StoredCollection but supposedly manages only one item /// A class extending the capabilities of StoredCollection but supposedly manages only one item
public class StoredSingleton<T: SyncedStorable>: SyncedCollection<T> { public class StoredSingleton<T: Storable>: StoredCollection<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)
}
}
/// Sets the singleton to the collection without synchronizing it /// Sets the singleton to the collection without synchronizing it
public func setItemNoSync(_ instance: T) { public func setItemNoSync(_ instance: T) {
self.collection.setSingletonNoSync(instance: instance) self.setSingletonNoSync(instance: instance)
} }
/// updates the existing singleton /// updates the existing singleton
public func update() { public func update() throws {
if let item = self.item() { if let item = self.item() {
self.addOrUpdate(instance: item) try self.addOrUpdate(instance: item)
} }
} }
/// Returns the singleton /// Returns the singleton
public func item() -> T? { public func item() -> T? {
return self.collection.items.first return self.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)
}
} }
// MARK: - Protects from use // MARK: - Protects from use
public override func addOrUpdate(contentOfs sequence: any Sequence<T>) { public override func addOrUpdate(contentOfs sequence: any Sequence<T>) throws {
fatalError("method unavailable for StoredSingleton, use update")
}
func addOrUpdateIfNewer(_ instance: T) {
fatalError("method unavailable for StoredSingleton, use update") 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 import Foundation
public class JSON { fileprivate var jsonEncoder: JSONEncoder = {
let encoder = JSONEncoder()
public static var encoder: JSONEncoder = { encoder.keyEncodingStrategy = .convertToSnakeCase
let encoder = JSONEncoder() #if DEBUG
encoder.keyEncodingStrategy = .convertToSnakeCase encoder.outputFormatting = .prettyPrinted
#if DEBUG #endif
encoder.outputFormatting = .prettyPrinted encoder.dateEncodingStrategy = .iso8601
#endif return encoder
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
}()
public static var decoder: JSONDecoder = { fileprivate var jsonDecoder: JSONDecoder = {
let decoder = JSONDecoder() let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .custom { decoder in decoder.dateDecodingStrategy = .iso8601
let container = try decoder.singleValueContainer() return decoder
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
}()
}
extension Encodable { extension Encodable {
@ -53,11 +32,11 @@ extension Encodable {
} }
public func jsonData() throws -> Data { public func jsonData() throws -> Data {
return try JSON.encoder.encode(self) return try jsonEncoder.encode(self)
} }
public func prettyJSONString() throws -> String { public func prettyJSONString() throws -> String {
let data = try JSON.encoder.encode(self) let data = try jsonEncoder.encode(self)
return String(data: data, encoding: .utf8) ?? "" return String(data: data, encoding: .utf8) ?? ""
} }
@ -78,11 +57,11 @@ extension String {
extension Data { extension Data {
public func decode<T : Decodable>() throws -> T { 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] { 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 { 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,13 @@ public class ErrorUtils {
} }
public enum ServiceError: Error, LocalizedError { public enum ServiceError: Error {
case urlCreationError(url: String) case urlCreationError(url: String)
case cantConvertToUUID(id: String) case cantConvertToUUID(id: String)
case missingUserName case missingUserName
case responseError(response: String) 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) 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 { extension FileManager {
@discardableResult func createDirectoryInDocuments(directoryName: String) -> Bool { func createDirectoryInDocuments(directoryName: String) -> Bool {
let documentsDirectory = self.urls(for: .documentDirectory, in: .userDomainMask).first! let documentsDirectory = self.urls(for: .documentDirectory, in: .userDomainMask).first!
let directoryURL = documentsDirectory.appendingPathComponent(directoryName) let directoryURL = documentsDirectory.appendingPathComponent(directoryName)
if !self.fileExists(atPath: directoryURL.path) { if !self.fileExists(atPath: directoryURL.path) {
do { do {
try self.createDirectory(at: directoryURL, try self.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil)
withIntermediateDirectories: true,
attributes: nil)
Logger.log("directory created : \(directoryURL)")
return true
} catch { } catch {
Logger.error(error) Logger.error(error)
return false
} }
return true
} else { } else {
Logger.log("directory exists : \(directoryURL)")
return false return false
} }
} }

@ -7,15 +7,8 @@
import Foundation import Foundation
enum FileError: Error, LocalizedError { enum FileError : Error {
case documentDirectoryNotFound case documentDirectoryNotFound
var errorDescription: String? {
switch self {
case .documentDirectoryNotFound:
return "The document directory has not been found"
}
}
} }
class FileUtils { 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 // KeychainStore.swift
// Padel Club (from Le Countdown) // LeCountdown
// //
// Created by Laurent Morvillier on 20/12/2023. // Created by Laurent Morvillier on 20/12/2023.
// //
@ -11,27 +11,9 @@ enum KeychainError: Error {
case keychainItemNotFound(serverId: String) case keychainItemNotFound(serverId: String)
case unexpectedPasswordData case unexpectedPasswordData
case unhandledError(status: OSStatus) 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 { class KeychainStore {
func add(username: String, value: String) throws
func add(value: String) throws
func getValue() throws -> String
func deleteValue() throws
}
class KeychainStore: KeychainService {
let serverId: String let serverId: String
@ -39,28 +21,18 @@ class KeychainStore: KeychainService {
self.serverId = serverId self.serverId = serverId
} }
func add(username: String, value: String) throws { func add(username: String, token: String) throws {
let valueData = value.data(using: .utf8)! let tokenData = token.data(using: .utf8)!
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword, let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrAccount as String: username, kSecAttrAccount as String: username,
kSecAttrServer as String: self.serverId, kSecAttrServer as String: self.serverId,
kSecValueData as String: valueData] kSecValueData as String: tokenData]
let status: OSStatus = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) }
}
func add(value: String) throws {
let valueData = value.data(using: .utf8)!
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrServer as String: self.serverId,
kSecValueData as String: valueData]
let status: OSStatus = SecItemAdd(query as CFDictionary, nil) let status: OSStatus = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) } guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) }
} }
func getValue() throws -> String { func getToken() throws -> String {
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword, let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrServer as String: self.serverId, kSecAttrServer as String: self.serverId,
@ -81,7 +53,7 @@ class KeychainStore: KeychainService {
return token return token
} }
func deleteValue() throws { func deleteToken() throws {
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword, let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrServer as String: self.serverId] kSecAttrServer as String: self.serverId]

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

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

@ -1,22 +1,22 @@
# LeStorage # LeStorage
# Rules **1. RULES**
- To store data in the json format inside files, - To store data in the json format inside files,
you first need to create some model class, for example `Car` you first need to create some model class, for example `Car`
- You make `Car` inherit `ModelObject`, and implement `Storable` - You make `Car` inherit `ModelObject`, and implement `Storable`
- To get the `StoredCollection` that manages all your cars and stores them for you, you do - To get the `StoredCollection` that manages all your cars and stores them for you, you do
`Store.main.registerCollection()` to retrieve a collection. LeStorage stores data as JSON files inside the **storage** directory. `Store.main.registerCollection()` to retrieve a collection.
## Multi Store **A. Multi Store**
You can store collections inside separate folders by creating other stores: You can store collections inside separate folders by creating other stores
- Use StoreCenter.main.store(identifier: id, parameter: param) to create a new store. The directory will be named after the identifier under the **storage** directory. The parameter is used to retrieve data from server as the GET requests will add the parameter as an argument in the URL, like https://www.myurl.net/api/cars/?param=id - Use StoreCenter.main.store(identifier: id, parameter: param) to create a new store. The directory will be named after the identifier. The parameter is used to retrieve data from server as the GET requests will add the parameter as an argument in the URL, like https://www.myurl.net/api/cars/?param=id
# Sync **2. Sync**
- When registering your collection, you can choose to have it synchronized. To do that: - 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 - 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" - 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 - Synchronization is expected to be done with a rest_framework API on a django server

Loading…
Cancel
Save