Compare commits

..

1 Commits
main ... sync

Author SHA1 Message Date
Laurent 22753ae230 first draft 1 year ago
  1. 180
      LeStorage.xcodeproj/project.pbxproj
  2. 4
      LeStorage.xcodeproj/xcshareddata/xcschemes/LeStorage.xcscheme
  3. 55
      LeStorage.xcodeproj/xcshareddata/xcschemes/LeStorageTests.xcscheme
  4. 363
      LeStorage/ApiCallCollection.swift
  5. 11
      LeStorage/CLAUDE.md
  6. 186
      LeStorage/Codables/ApiCall.swift
  7. 83
      LeStorage/Codables/DataAccess.swift
  8. 44
      LeStorage/Codables/DataLog.swift
  9. 73
      LeStorage/Codables/FailedAPICall.swift
  10. 57
      LeStorage/Codables/GetSyncData.swift
  11. 59
      LeStorage/Codables/Log.swift
  12. 34
      LeStorage/Codables/PendingOperation.swift
  13. 15
      LeStorage/Codables/Settings.swift
  14. 83
      LeStorage/Codables/SyncData.swift
  15. 75
      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. 747
      LeStorage/Services.swift
  21. 67
      LeStorage/Storable.swift
  22. 495
      LeStorage/Store.swift
  23. 1062
      LeStorage/StoreCenter.swift
  24. 64
      LeStorage/StoreLibrary.swift
  25. 667
      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. 37
      LeStorage/Utils/Codable+Extensions.swift
  31. 4
      LeStorage/Utils/Collection+Extension.swift
  32. 33
      LeStorage/Utils/Date+Extensions.swift
  33. 26
      LeStorage/Utils/Dictionary+Extensions.swift
  34. 59
      LeStorage/Utils/Errors.swift
  35. 11
      LeStorage/Utils/FileManager+Extensions.swift
  36. 9
      LeStorage/Utils/FileUtils.swift
  37. 12
      LeStorage/Utils/Formatter.swift
  38. 22
      LeStorage/Utils/KeychainStore.swift
  39. 6
      LeStorage/Utils/Logger.swift
  40. 44
      LeStorage/Utils/MockKeychainStore.swift
  41. 17
      LeStorage/Utils/String+Extensions.swift
  42. 24
      LeStorage/Utils/UIDevice+Extensions.swift
  43. 39
      LeStorage/Utils/URLManager.swift
  44. 151
      LeStorage/WebSocketManager.swift
  45. 133
      LeStorageTests/ApiCallTests.swift
  46. 87
      LeStorageTests/CollectionsTests.swift
  47. 110
      LeStorageTests/IdentifiableTests.swift
  48. 141
      LeStorageTests/StoredCollectionTests.swift
  49. 2
      README.md

@ -3,26 +3,17 @@
archiveVersion = 1; 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,12 @@
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 */; }; 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 +46,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 +71,10 @@
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>"; }; 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 +83,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 +98,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 +107,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 +115,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 +134,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>";
@ -223,14 +168,9 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
C4A47D992B7CFFC500ADC637 /* ApiCall.swift */, C4A47D992B7CFFC500ADC637 /* ApiCall.swift */,
C4D4779C2CB923720077713D /* DataLog.swift */,
C45D35902C0A1DB5000F379F /* FailedAPICall.swift */, C45D35902C0A1DB5000F379F /* FailedAPICall.swift */,
C4FC2E302C353E7B0021F3BF /* Log.swift */, C4FC2E302C353E7B0021F3BF /* Log.swift */,
C4A47D9A2B7CFFC500ADC637 /* Settings.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 +207,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 +232,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 +257,7 @@
projectRoot = ""; projectRoot = "";
targets = ( targets = (
C425D4332B6D24E1002A7B48 /* LeStorage */, C425D4332B6D24E1002A7B48 /* LeStorage */,
C4C33F6A2C9B06B7006316DE /* LeStorageTests */, C425D43D2B6D24E1002A7B48 /* LeStorageTests */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@ -332,12 +267,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 = (
@ -354,63 +288,43 @@
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 */, 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 +365,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 +431,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 +457,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";
@ -578,11 +489,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";
@ -608,13 +518,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 +535,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 +573,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,6 +7,7 @@
import Foundation import Foundation
protocol SomeCallCollection { protocol SomeCallCollection {
func findCallById(_ id: String) async -> (any SomeCall)? func findCallById(_ id: String) async -> (any SomeCall)?
@ -16,30 +17,13 @@ protocol SomeCallCollection {
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>] = []
@ -48,9 +32,10 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
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 /// The task of waiting and executing ApiCalls
fileprivate var _reschedulingTask: 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
@ -63,19 +48,16 @@ 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
@ -84,13 +66,9 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
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,12 +76,14 @@ 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")
} }
} }
@ -120,13 +100,12 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
/// 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
} }
@ -144,10 +123,8 @@ 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._reschedulingTask?.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()
@ -159,279 +136,138 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
} }
} }
func resumeApiCalls() {
self._attemptLoops = -1
self.rescheduleApiCallsIfNecessary()
if self._schedulingTask != nil && self._attemptLoops > 2 {
self._schedulingTask?.cancel()
self._attemptLoops = -1
self.rescheduleApiCallsIfNecessary()
}
}
/// Reschedule API calls without waiting
func rescheduleImmediately() {
self._attemptLoops = -1
self.rescheduleApiCallsIfNecessary()
}
/// Reschedule API calls if necessary
func rescheduleApiCallsIfNecessary() {
if self.items.isNotEmpty && !self._isExecutingCalls {
self._schedulingTask = Task {
await self._waitAndExecuteApiCalls()
}
}
}
/// Reschedule the execution of API calls /// Reschedule the execution of API calls
fileprivate func _waitAndExecuteApiCalls() async { fileprivate func _rescheduleApiCalls() {
guard !self._isExecutingCalls, self.storeCenter.forceNoSynchronization == false else { return }
guard self.items.isNotEmpty else { return }
self._isExecutingCalls = true guard self.items.isNotEmpty else {
return
}
self._isRetryingCalls = true
self._attemptLoops += 1 self._attemptLoops += 1
await self._wait() self._reschedulingTask = Task {
let delay = pow(2, self._attemptLoops)
let seconds = NSDecimalNumber(decimal: delay).intValue
Logger.log("\(T.resourceName()): wait for \(seconds) sec")
try await Task.sleep(until: .now + .seconds(seconds))
await self._batchExecution() let apiCallsCopy = self.items
for apiCall in apiCallsCopy {
apiCall.attemptsCount += 1
apiCall.lastAttemptDate = Date()
// Logger.log("\(T.resourceName()) > EXECUTE CALLS: \(self.items.count)")
// let batches = Dictionary(grouping: self.items, by: { $0.transactionId })
//
// for batch in batches.values {
// do {
// if batch.count == 1, let apiCall = batch.first, apiCall.method == .get {
// try await self._executeGetCall(apiCall: apiCall)
// } else {
// let results = try await self._executeApiCalls(batch)
// if T.copyServerResponse {
// let instances = results.compactMap { $0.data }
// StoreCenter.main.updateLocalInstances(instances)
// }
// }
// } catch {
// Logger.error(error)
// }
// }
// Logger.log("\(T.resourceName()) > EXECUTE CALLS ENDED !")
self._isExecutingCalls = false
if self.items.isNotEmpty {
await self._waitAndExecuteApiCalls()
}
// Logger.log("\(T.resourceName()) > isRescheduling = \(self._isRescheduling)")
}
fileprivate func _batchExecution() async {
let batches = Dictionary(grouping: self.items, by: { $0.transactionId })
for batch in batches.values {
do { do {
if batch.count == 1, let apiCall = batch.first, apiCall.method == .get { let _ = try await self._executeApiCall(apiCall)
try await self._executeGetCall(apiCall: apiCall)
} else {
let results: [OperationResult<T>] = try await self._executeApiCalls(batch)
if T.copyServerResponse {
let instances: [T] = results.compactMap { $0.data }
self.storeCenter.updateLocalInstances(instances)
}
}
} catch { } catch {
Logger.error(error) Logger.error(error)
} }
} }
}
@discardableResult func _executeGetCall(apiCall: ApiCall<T>) async throws -> Data {
let data = try await self.storeCenter.executeGet(apiCall: apiCall) self._hasChanged = true
// Logger.log("GET received = \(T.resourceName())")
if T.self == GetSyncData.self { if self.items.isEmpty {
let syncData = try SyncData(data: data, storeCenter: self.storeCenter) self._isRetryingCalls = false
await self.storeCenter.synchronizeContent(syncData)
} else { } else {
let results: [T] = try self._decode(data: data) self._rescheduleApiCalls()
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 // MARK: - Synchronization
/// Returns an APICall instance for the Storable [instance] and an HTTP [method] /// Returns an APICall instance for the Storable [instance] and an HTTP [method]
/// The method makes some clean up when necessary: /// The method updates existing calls or creates a new one
/// - When deleting, we delete other calls as they are unecessary fileprivate func _callForInstance(_ instance: T, method: HTTPMethod) throws -> ApiCall<T>? {
/// - 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 if let existingCall = self.items.first(where: { $0.dataId == instance.id }) {
switch method { switch method {
case .delete: // we don't want anything else than a DELETE in the queue case .delete:
let existingCalls = self.items.filter { $0.data?.stringId == instance.stringId } self.deleteById(existingCall.id) // delete the existing call as we don't need it
self._deleteCalls(existingCalls) if existingCall.method == HTTPMethod.post {
case .put: // we don't want mixed PUT calls so we delete the others return nil // if the post has not been done, we can just stop here
let existingPuts = self.items.filter { $0.data?.stringId == instance.stringId && $0.method == .put } } else {
self._deleteCalls(existingPuts) return try self._createCall(instance, method: method) // otherwise it's a put and we want to send the delete
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)
} }
default: // here we should only trying to PUT, so we update the existing POST/PUT with the instance new values
existingCall.body = try instance.jsonString()
return existingCall
} }
} else {
/// we want to avoid sending the same GET twice return try self._createCall(instance, method: method)
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] /// Reschedule API calls if necessary
func sendGetRequest(instance: URLParameterConvertible) async throws { func rescheduleApiCallsIfNecessary() {
let parameters = instance.queryParameters(storeCenter: self.storeCenter) if !self._isRetryingCalls {
try await self._sendGetRequest(parameters: parameters) self._rescheduleApiCalls()
}
/// Sends a GET request with an optional [storeId]
func sendGetRequest(storeId: String?, clear: Bool = true) async throws {
var parameters: [String : String]? = nil
if let storeId {
parameters = [Services.storeIdURLParameter : storeId]
} }
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) async throws -> T? {
if let getCall = self._createGetCallIfNonExistent(parameters, clear: clear) {
do { do {
try await self._prepareAndSendGetCall(getCall) return try await self._synchronize(instance, method: HTTPMethod.post)
} catch { } catch {
self.rescheduleApiCallsIfNecessary() self.rescheduleApiCallsIfNecessary()
Logger.error(error) Logger.error(error)
} }
} else { return nil
self.rescheduleImmediately()
}
}
/// Creates and execute the ApiCalls corresponding to the [batch]
func executeBatch(_ batch: OperationBatch<T>) {
self._prepareCalls(batch: batch)
self.rescheduleImmediately()
} }
func singleBatchExecution(_ batch: OperationBatch<T>) async { /// Sends an update api call for the provided [instance]
self._prepareCalls(batch: batch) func sendUpdate(_ instance: T) async throws -> T? {
await self._batchExecution() do {
return try await self._synchronize(instance, method: HTTPMethod.put)
} catch {
self.rescheduleApiCallsIfNecessary()
Logger.error(error)
} }
return nil
func executeSingleGet(instance: T) async throws -> Data where T : URLParameterConvertible {
let call = self._createCall(.get, instance: instance, option: .none)
call.urlParameters = instance.queryParameters(storeCenter: self.storeCenter)
self._addCallToWaitingList(call)
return try await self._executeGetCall(apiCall: call)
} }
fileprivate func _prepareCalls(batch: OperationBatch<T>) { /// Sends an delete api call for the provided [instance]
let transactionId = Store.randomId() func sendDeletion(_ instance: T) async throws -> T? {
for insert in batch.inserts { do {
self._prepareCall(instance: insert, method: .post, transactionId: transactionId) return try await self._synchronize(instance, method: HTTPMethod.delete)
} } catch {
for update in batch.updates { self.rescheduleApiCallsIfNecessary()
self._prepareCall(instance: update, method: .put, transactionId: transactionId) Logger.error(error)
}
for delete in batch.deletes {
self._prepareCall(instance: delete, method: .delete, transactionId: transactionId)
} }
return nil
} }
/// Prepares and executes a GET call /// Initiates the process of sending the data with the server
fileprivate func _prepareAndSendGetCall(_ apiCall: ApiCall<T>) async throws { fileprivate func _synchronize(_ instance: T, method: HTTPMethod) async throws -> T? {
self._addCallToWaitingList(apiCall) if let apiCall = try self._callForInstance(instance, method: method) {
try await self._executeGetCall(apiCall: apiCall) try self._prepareCall(apiCall: apiCall)
return try await self._executeApiCall(apiCall)
} else {
return nil
}
} }
/// 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 -> T {
return try await StoreCenter.main.execute(apiCall: apiCall)
// Logger.log("/// \(T.resourceName()) > Start \(apiCalls.count) calls execution...")
let results = try await self.storeCenter.execute(apiCalls: apiCalls)
for result in results {
switch result.status {
case 200..<300:
self.deleteById(result.apiCallId)
default:
break
}
}
return results
} }
/// Returns the content of the API call file as a String /// Returns the content of the API call file as a String
@ -445,36 +281,7 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
/// 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 { 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 {
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 /// The http URL of the call
var transactionId: String? = Store.randomId() // var url: String
/// Creation date of the call
var creationDate: Date? = Date()
/// 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,24 +7,11 @@
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()
@ -52,62 +39,6 @@ class FailedAPICall: SyncedModelObject, SyncedStorable {
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 [] }
}

@ -7,67 +7,20 @@
import Foundation import Foundation
class Log: SyncedModelObject, SyncedStorable { class Log: ModelObject, Storable {
static func resourceName() -> String { return "logs" } static func resourceName() -> String { return "logs" }
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() {
super.init()
}
var id: String = Store.randomId() 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 { var date: Date = Date()
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) { var message: String
guard let log = other as? Log else { return }
self.date = log.date
self.message = log.message
}
public func copyForUpdate(from other: any Storable) { init(message: String) {
fatalError("should not happen") self.message = message
} }
} }

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

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

@ -1,83 +0,0 @@
//
// SyncData.swift
// LeStorage
//
// Created by Laurent Morvillier on 02/05/2025.
//
import Foundation
enum SyncDataError: Error {
case invalidFormat
}
struct SyncedStorableArray {
var type: any SyncedStorable.Type
var items: [any SyncedStorable]
}
struct ObjectIdentifierArray {
var type: any SyncedStorable.Type
var items: [ObjectIdentifier]
}
class SyncData {
var updates: [SyncedStorableArray] = []
var deletions: [ObjectIdentifierArray] = []
var shared: [SyncedStorableArray] = []
var grants: [SyncedStorableArray] = []
var revocations: [ObjectIdentifierArray] = []
var revocationParents: [[ObjectIdentifierArray]] = []
// var relationshipSets: [SyncedStorableArray] = []
// var relationshipRemovals: [ObjectIdentifierArray] = []
var sharedRelationshipSets: [SyncedStorableArray] = []
var sharedRelationshipRemovals: [ObjectIdentifierArray] = []
var date: String?
init(data: Data, storeCenter: StoreCenter) throws {
guard let json = try JSONSerialization.jsonObject(with: data, options: [])
as? [String : Any]
else {
throw SyncDataError.invalidFormat
}
if let updates = json["updates"] as? [String: Any] {
self.updates = try storeCenter.decodeDictionary(updates)
}
if let deletions = json["deletions"] as? [String: Any] {
self.deletions = try storeCenter.decodeObjectIdentifierDictionary(deletions)
}
if let shared = json["shared"] as? [String: Any] {
self.shared = try storeCenter.decodeDictionary(shared)
}
if let grants = json["grants"] as? [String: Any] {
self.grants = try storeCenter.decodeDictionary(grants)
}
if let revocations = json["revocations"] as? [String: Any] {
self.revocations = try storeCenter.decodeObjectIdentifierDictionary(revocations)
}
if let revocationParents = json["revocated_relations"] as? [[String: Any]] {
for level in revocationParents {
let decodedLevel = try storeCenter.decodeObjectIdentifierDictionary(level)
self.revocationParents.append(decodedLevel)
}
}
// if let relationshipSets = json["relationship_sets"] as? [String: Any] {
// self.relationshipSets = try storeCenter.decodeDictionary(relationshipSets)
// }
// if let relationshipRemovals = json["relationship_removals"] as? [String: Any] {
// self.relationshipRemovals = try storeCenter.decodeObjectIdentifierDictionary(relationshipRemovals)
// }
if let sharedRelationshipSets = json["shared_relationship_sets"] as? [String: Any] {
self.sharedRelationshipSets = try storeCenter.decodeDictionary(sharedRelationshipSets)
}
if let sharedRelationshipRemovals = json["shared_relationship_removals"] as? [String: Any] {
self.sharedRelationshipRemovals = try storeCenter.decodeObjectIdentifierDictionary(sharedRelationshipRemovals)
}
self.date = json["date"] as? String
}
}

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

@ -6,7 +6,15 @@
// //
import Foundation import Foundation
import UIKit
public enum ServiceError: Error {
case urlCreationError(url: String)
case cantConvertToUUID(id: String)
case missingUserName
case missingUserId
case responseError(response: String)
case cantDecodeData(content: String)
}
public enum HTTPMethod: String, CaseIterable, Codable { public enum HTTPMethod: String, CaseIterable, Codable {
case get = "GET" case get = "GET"
@ -21,92 +29,115 @@ struct ServiceCall {
var requiresToken: Bool var requiresToken: Bool
} }
let createAccountCall: ServiceCall = ServiceCall( let createAccountCall: ServiceCall = ServiceCall(path: "users/", method: .post, requiresToken: false)
path: "users/", method: .post, requiresToken: false) let requestTokenCall: ServiceCall = ServiceCall(path: "token-auth/", method: .post, requiresToken: false)
let requestTokenCall: ServiceCall = ServiceCall( let logoutCall: ServiceCall = ServiceCall(path: "api-token-logout/", method: .post, requiresToken: true)
path: "token-auth/", method: .post, requiresToken: false) let getUserCall: ServiceCall = ServiceCall(path: "user-by-token/", method: .get, requiresToken: true)
let logoutCall: ServiceCall = ServiceCall( let changePasswordCall: ServiceCall = ServiceCall(path: "change-password/", method: .put, requiresToken: true)
path: "api-token-logout/", method: .post, requiresToken: true) let postDeviceTokenCall: ServiceCall = ServiceCall(path: "device-token/", method: .post, requiresToken: true)
let getUserCall: ServiceCall = ServiceCall(
path: "user-by-token/", method: .get, requiresToken: true) //fileprivate enum ServiceConf: String {
let changePasswordCall: ServiceCall = ServiceCall( // case createAccount = "users/"
path: "change-password/", method: .put, requiresToken: true) // case requestToken = "token-auth/"
let postDeviceTokenCall: ServiceCall = ServiceCall( // case logout = "api-token-logout/"
path: "device-token/", method: .post, requiresToken: true) // case getUser = "user-by-token/"
let getUserDataAccessCallContent: ServiceCall = ServiceCall( // case changePassword = "change-password/"
path: "data-access-content/", method: .get, requiresToken: true) // case postDeviceToken = "device-token/"
let userAgentsCall: ServiceCall = ServiceCall( //
path: "user-supervisors/", method: .get, requiresToken: true) // var method: HTTPMethod {
// switch self {
// case .createAccount, .requestToken, .logout, .postDeviceToken:
// return .post
// case .changePassword:
// return .put
// default:
// return .get
// }
// }
//
// var requiresToken: Bool? {
// switch self {
// case .createAccount, .requestToken:
// return false
// case .getUser, .changePassword, .logout, .postDeviceToken:
// return true
//// default:
//// return nil
// }
// }
//
//}
/// A class used to send HTTP request to the django server /// A class used to send HTTP request to the django server
public class Services { public class Services {
fileprivate let storeCenter: StoreCenter /// A KeychainStore object used to store the user's token
let keychainStore: KeychainStore
/// The base API URL to send requests // fileprivate var _storeIdentifier: StoreIdentifier?
fileprivate(set) var baseURL: String
public init(storeCenter: StoreCenter, url: String) { public init(url: String) {
self.storeCenter = storeCenter
self.baseURL = url self.baseURL = url
} self.keychainStore = KeychainStore(serverId: url)
// self._storeIdentifier = storeId
Logger.log("create keystore with id: \(url)")
static let storeIdURLParameter = "store_id" }
// MARK: - Base /// The base API URL to send requests
fileprivate(set) var baseURL: String
/// Runs a request on the API and returns the appropriate response fileprivate var jsonEncoder: JSONEncoder = {
/// - Parameters: let encoder = JSONEncoder()
/// - path: the path of the service in the api, ie. "create-users/" encoder.keyEncodingStrategy = .convertToSnakeCase
/// - method: the HTTP method to call encoder.outputFormatting = .prettyPrinted
/// - requiresToken: whether the token must be included in the request encoder.dateEncodingStrategy = .iso8601
public func run<U: Decodable>(path: String, method: HTTPMethod, requiresToken: Bool) async throws -> U { return encoder
return try await self._runRequest(serviceCall: ServiceCall(path: path, method: method, requiresToken: requiresToken)) }()
}
fileprivate var jsonDecoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601
return decoder
}()
/// Runs a request using a configuration object // MARK: - Base
/// - Parameters:
/// - serviceConf: A instance of ServiceConf
/// - apiCallId: an optional id referencing an ApiCall
fileprivate func _runRequest<U: Decodable>(serviceCall: ServiceCall)
async throws -> U {
let request = try self._baseRequest(call: serviceCall)
return try await _runRequest(request)
}
/// Runs a request using a configuration object /// Runs a request using a configuration object
/// - Parameters: /// - Parameters:
/// - serviceConf: A instance of ServiceConf /// - serviceConf: A instance of ServiceConf
/// - payload: a codable value stored in the body of the request /// - payload: a codable value stored in the body of the request
/// - apiCallId: an optional id referencing an ApiCall /// - apiCallId: an optional id referencing an ApiCall
fileprivate func _runRequest<T: Encodable, U: Decodable>(serviceCall: ServiceCall, payload: T) fileprivate func _runRequest<T: Encodable, U: Decodable>(serviceCall: ServiceCall, payload: T, apiCallId: String? = nil) async throws -> U {
async throws -> U {
var request = try self._baseRequest(call: serviceCall) var request = try self._baseRequest(call: serviceCall)
request.httpBody = try JSON.encoder.encode(payload) request.httpBody = try jsonEncoder.encode(payload)
return try await _runRequest(request) return try await _runRequest(request, apiCallId: apiCallId)
} }
/// Runs a request using a traditional URLRequest /// Runs a request using a traditional URLRequest
/// - Parameters: /// - Parameters:
/// - request: the URLRequest to run /// - request: the URLRequest to run
/// - apiCallId: the id of the ApiCall to delete in case of success, or to schedule for a rerun in case of failure /// - apiCallId: the id of the ApiCall to delete in case of success, or to schedule for a rerun in case of failure
fileprivate func _runGetApiCallRequest<T: SyncedStorable>( fileprivate func _runRequest<T: Decodable>(_ request: URLRequest, apiCallId: String? = nil) async throws -> T {
_ request: URLRequest, apiCall: ApiCall<T> Logger.log("Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
) async throws -> Data {
let debugURL = request.url?.absoluteString ?? ""
// print("Run \(request.httpMethod ?? "") \(debugURL)")
let task: (Data, URLResponse) = try await URLSession.shared.data(for: request) let task: (Data, URLResponse) = try await URLSession.shared.data(for: request)
// print("\(apiCall.method.rawValue) \(String(describing: T.self)) => \(String(data: task.0, encoding: .utf8) ?? "")") Logger.log("response = \(String(data: task.0, encoding: .utf8) ?? "")")
if let response = task.1 as? HTTPURLResponse { if let response = task.1 as? HTTPURLResponse {
let statusCode = response.statusCode let statusCode = response.statusCode
Logger.log("request ended with status code = \(statusCode)")
switch statusCode { switch statusCode {
case 200..<300: // success case 200..<300: // success
try await self.storeCenter.deleteApiCallById(type: T.self, id: apiCall.id) if let apiCallId {
if let collectionName = (T.self as? any Storable.Type)?.resourceName() {
try await StoreCenter.main.deleteApiCallById(apiCallId, collectionName: collectionName)
} else {
StoreCenter.main.log(message: "collectionName not found for \(type(of: T.self)), could not delete ApiCall \(apiCallId)")
}
}
default: // error default: // error
print("\(debugURL) ended, status code = \(statusCode)") Logger.log("Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
Logger.log(
"Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
let errorString: String = String(data: task.0, encoding: .utf8) ?? "" let errorString: String = String(data: task.0, encoding: .utf8) ?? ""
var errorMessage = ErrorMessage(error: errorString, domain: "") var errorMessage = ErrorMessage(error: errorString, domain: "")
@ -114,70 +145,29 @@ public class Services {
errorMessage = message errorMessage = message
} }
try await self.storeCenter.rescheduleApiCalls(type: T.self) if let apiCallId, let type = (T.self as? any Storable.Type) {
self.storeCenter.logFailedAPICall( try await StoreCenter.main.rescheduleApiCalls(id: apiCallId, type: type)
apiCall.id, request: request, collectionName: T.resourceName(), StoreCenter.main.logFailedAPICall(apiCallId, request: request, collectionName: type.resourceName(), error: errorMessage.message)
error: errorMessage.message)
throw ServiceError.responseError(response: errorMessage.error)
}
} else { } else {
let message: String = "Unexpected and unmanaged URL Response \(task.1)" StoreCenter.main.logFailedAPICall(request: request, error: errorMessage.message)
self.storeCenter.log(message: message)
Logger.w(message)
} }
return task.0 //try self._decode(data: task.0)
}
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)!)
}
}
/// Runs a request using a traditional URLRequest
/// - Parameters:
/// - request: the URLRequest to run
/// - apiCallId: the id of the ApiCall to delete in case of success, or to schedule for a rerun in case of failure
fileprivate func _runRequest<V: Decodable>(_ request: URLRequest) async throws -> V {
let debugURL = request.url?.absoluteString ?? ""
// print("Run \(request.httpMethod ?? "") \(debugURL)")
let task: (Data, URLResponse) = try await URLSession.shared.data(for: request)
// print("\(request.httpMethod ?? "") \(debugURL) => \(String(data: task.0, encoding: .utf8) ?? "")")
if let response = task.1 as? HTTPURLResponse {
let statusCode = response.statusCode
switch statusCode {
case 200..<300: // success
break
default: // error
print("\(debugURL) ended, status code = \(statusCode)")
Logger.log(
"Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
let errorString: String = String(data: task.0, encoding: .utf8) ?? ""
var errorMessage = ErrorMessage(error: errorString, domain: "")
if let message = self.errorMessageFromResponse(data: task.0) {
errorMessage = message
}
throw ServiceError.responseError(response: errorMessage.error) throw ServiceError.responseError(response: errorMessage.error)
} }
} else { } else {
let message: String = "Unexpected and unmanaged URL Response \(task.1)" let message: String = "Unexpected and unmanaged URL Response \(task.1)"
self.storeCenter.log(message: message) StoreCenter.main.log(message: message)
Logger.w(message) Logger.w(message)
} }
return try self._decode(data: task.0)
return try jsonDecoder.decode(T.self, from: task.0)
} }
/// Returns if the token is required for a request /// Returns if the token is required for a request
/// - Parameters: /// - Parameters:
/// - type: the type of the request resource /// - type: the type of the request resource
/// - method: the HTTP method of the request /// - method: the HTTP method of the request
fileprivate func _isTokenRequired<T: SyncedStorable>(type: T.Type, method: HTTPMethod) -> Bool { fileprivate func _isTokenRequired<T : Storable>(type: T.Type, method: HTTPMethod) -> Bool {
let methods = T.tokenExemptedMethods() let methods = T.tokenExemptedMethods()
if methods.contains(method) { if methods.contains(method) {
return false return false
@ -189,48 +179,34 @@ public class Services {
/// Returns a GET request for the resource /// Returns a GET request for the resource
/// - Parameters: /// - Parameters:
/// - type: the type of the request resource /// - type: the type of the request resource
fileprivate func _getRequest<T: SyncedStorable>(type: T.Type, identifier: String?) fileprivate func _getRequest<T: Storable>(type: T.Type, identifier: StoreIdentifier?) throws -> URLRequest {
throws
-> URLRequest
{
let requiresToken = self._isTokenRequired(type: T.self, method: .get) let requiresToken = self._isTokenRequired(type: T.self, method: .get)
return try self._baseRequest( return try self._baseRequest(servicePath: T.path(), method: .get, requiresToken: requiresToken, identifier: identifier)
servicePath: T.path(), method: .get, requiresToken: requiresToken,
identifier: identifier)
} }
/// Returns the base URLRequest for a ServiceConf instance /// Returns a POST request for the resource
/// - Parameters: /// - Parameters:
/// - conf: a ServiceConf instance /// - type: the type of the request resource
fileprivate func _baseRequest(call: ServiceCall, getArguments: [String: String]? = nil) throws -> URLRequest { fileprivate func _postRequest<T: Storable>(type: T.Type) throws -> URLRequest {
return try self._baseRequest( let requiresToken = self._isTokenRequired(type: T.self, method: .post)
servicePath: call.path, method: call.method, requiresToken: call.requiresToken, getArguments: getArguments) return try self._baseRequest(servicePath: T.path(), method: .post, requiresToken: requiresToken)
} }
// /// Returns a PUT request for the resource
// /// Returns a POST request for the resource /// - Parameters:
// /// - Parameters: /// - type: the type of the request resource
// /// - type: the type of the request resource fileprivate func _putRequest<T: Storable>(type: T.Type, id: String) throws -> URLRequest {
// fileprivate func _postRequest<T: Storable>(type: T.Type) throws -> URLRequest { let requiresToken = self._isTokenRequired(type: T.self, method: .put)
// let requiresToken = self._isTokenRequired(type: T.self, method: .post) return try self._baseRequest(servicePath: T.path(id: id), method: .put, requiresToken: requiresToken)
// return try self._baseRequest(servicePath: T.path(), method: .post, requiresToken: requiresToken) }
// }
// /// Returns a DELETE request for the resource
// /// Returns a PUT request for the resource /// - Parameters:
// /// - Parameters: /// - type: the type of the request resource
// /// - type: the type of the request resource fileprivate func _deleteRequest<T: Storable>(type: T.Type, id: String) throws -> URLRequest {
// fileprivate func _putRequest<T: Storable>(type: T.Type, id: String) throws -> URLRequest { let requiresToken = self._isTokenRequired(type: T.self, method: .delete)
// let requiresToken = self._isTokenRequired(type: T.self, method: .put) return try self._baseRequest(servicePath: T.path(id: id), method: .delete, requiresToken: requiresToken)
// return try self._baseRequest(servicePath: T.path(id: id), method: .put, requiresToken: requiresToken) }
// }
//
// /// Returns a DELETE request for the resource
// /// - Parameters:
// /// - type: the type of the request resource
// fileprivate func _deleteRequest<T: Storable>(type: T.Type, id: String) throws -> URLRequest {
// let requiresToken = self._isTokenRequired(type: T.self, method: .delete)
// return try self._baseRequest(servicePath: T.path(id: id), method: .delete, requiresToken: requiresToken)
// }
/// Returns the base URLRequest for a ServiceConf instance /// Returns the base URLRequest for a ServiceConf instance
/// - Parameters: /// - Parameters:
@ -245,314 +221,128 @@ public class Services {
/// - method: the HTTP method to execute /// - method: the HTTP method to execute
/// - requiresToken: An optional boolean to indicate if the token is required /// - requiresToken: An optional boolean to indicate if the token is required
/// - identifier: an optional StoreIdentifier that allows to filter GET requests with the StoreIdentifier values /// - identifier: an optional StoreIdentifier that allows to filter GET requests with the StoreIdentifier values
public func _baseRequest( fileprivate func _baseSyncRequest(method: HTTPMethod, payload: Encodable) throws -> URLRequest {
servicePath: String, method: HTTPMethod, requiresToken: Bool? = nil, let urlString = baseURL + "data/"
identifier: String? = nil, getArguments: [String : String]? = nil
) throws -> URLRequest {
var urlString = baseURL + servicePath
var arguments: [String : String] = getArguments ?? [:]
if let identifier {
arguments[Services.storeIdURLParameter] = identifier
}
urlString.append(arguments.toQueryString())
guard let url = URL(string: urlString) else { guard let url = URL(string: urlString) else {
throw ServiceError.urlCreationError(url: urlString) throw ServiceError.urlCreationError(url: urlString)
} }
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = method.rawValue request.httpMethod = method.rawValue
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.addAppVersion() request.httpBody = try jsonEncoder.encode(payload)
if !(requiresToken == false) {
let token = try self.storeCenter.token()
request.addValue("Token \(token)", forHTTPHeaderField: "Authorization")
}
return request
}
// MARK: - Synchronization
/// Runs a request using a traditional URLRequest
/// - Parameters:
/// - request: the URLRequest to run
/// - apiCallId: the id of the ApiCall to delete in case of success, or to schedule for a rerun in case of failure
fileprivate func _runSyncPostRequest<T: SyncedStorable>(
_ request: URLRequest, type: T.Type) async throws -> [OperationResult<T>] {
let debugURL = request.url?.absoluteString ?? ""
// print("Run \(request.httpMethod ?? "") \(debugURL)")
let task: (Data, URLResponse) = try await URLSession.shared.data(for: request)
// print("sync POST \(String(describing: T.self)) => \(String(data: task.0, encoding: .utf8) ?? "")")
var rescheduleApiCalls: Bool = false
var results: [OperationResult<T>] = []
if let response = task.1 as? HTTPURLResponse {
let statusCode = response.statusCode
switch statusCode {
case 200..<300: // success
let decoded: BatchResponse<T> = try self._decode(data: task.0)
results = decoded.results
for result in decoded.results {
switch result.status {
case 200..<300:
break
default:
if let message = result.message {
let type = String(describing: T.self)
print("*** \(type) - \(result.data?.stringId ?? ""): \(result.status) > \(message)")
}
rescheduleApiCalls = true
break
}
}
default: // error
print("\(String(describing: T.self))> \(debugURL) ended, status code = \(statusCode)")
Logger.log(
"Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
let errorString: String = String(data: task.0, encoding: .utf8) ?? ""
var errorMessage = ErrorMessage(error: errorString, domain: "")
if let message = self.errorMessageFromResponse(data: task.0) {
errorMessage = message
}
try await self.storeCenter.rescheduleApiCalls(type: T.self)
// self.storeCenter.logFailedAPICall( let token = try self.keychainStore.getValue()
// apiCall.id, request: request, collectionName: T.resourceName(), request.addValue("Token \(token)", forHTTPHeaderField: "Authorization")
// error: errorMessage.message)
throw ServiceError.responseError(response: errorMessage.error)
}
} else {
let message: String = "Unexpected and unmanaged URL Response \(task.1)"
self.storeCenter.log(message: message)
Logger.w(message)
}
if rescheduleApiCalls {
try? await self.storeCenter.rescheduleApiCalls(type: T.self)
}
return results return request
} }
/// Returns the URLRequest for an ApiCall /// Returns a base request for a path and method
/// - Parameters: /// - Parameters:
/// - apiCall: An ApiCall instance to configure the returned request /// - servicePath: the path to add to the API base URL
fileprivate func _syncGetRequest<T: SyncedStorable>(from apiCall: ApiCall<T>) throws -> URLRequest { /// - method: the HTTP method to execute
/// - requiresToken: An optional boolean to indicate if the token is required
var urlString = "\(baseURL)\(T.resourceName())/" // baseURL + T.resourceName() // "data/" /// - identifier: an optional StoreIdentifier that allows to filter GET requests with the StoreIdentifier values
if let urlParameters = apiCall.formattedURLParameters() { fileprivate func _baseRequest(servicePath: String, method: HTTPMethod, requiresToken: Bool? = nil, identifier: StoreIdentifier? = nil) throws -> URLRequest {
urlString.append(urlParameters) var urlString = baseURL + servicePath
if let identifier {
urlString.append(identifier.urlComponent)
} }
guard let url = URL(string: urlString) else { guard let url = URL(string: urlString) else {
throw ServiceError.urlCreationError(url: urlString) throw ServiceError.urlCreationError(url: urlString)
} }
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = HTTPMethod.get.rawValue request.httpMethod = method.rawValue
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.addAppVersion() if !(requiresToken == false) {
let token = try self.keychainStore.getValue()
if self._isTokenRequired(type: T.self, method: apiCall.method), self.storeCenter.isAuthenticated {
let token = try self.storeCenter.token()
request.addValue("Token \(token)", forHTTPHeaderField: "Authorization") request.addValue("Token \(token)", forHTTPHeaderField: "Authorization")
} }
return request return request
} }
// MARK: - Services
// /// Executes a PUT request /// Executes a GET request
// public func put<T: Storable>(_ instance: T) async throws -> T { public func get<T: Storable>(identifier: StoreIdentifier? = nil) async throws -> [T] {
// var postRequest = try self._putRequest(type: T.self, id: instance.stringId) let getRequest = try _getRequest(type: T.self, identifier: identifier)
// postRequest.httpBody = try jsonEncoder.encode(instance) return try await self._runRequest(getRequest)
// return try await self._runRequest(postRequest)
// }
//
// public func delete<T: Storable>(_ instance: T) async throws -> T {
// let deleteRequest = try self._deleteRequest(type: T.self, id: instance.stringId)
// return try await self._runRequest(deleteRequest)
// }
//
// /// Executes an ApiCall
// func runApiCall<T: Storable, V: Decodable>(_ apiCall: ApiCall<T>) async throws -> V {
// let request = try self._request(from: apiCall)
// print("HTTP \(request.httpMethod ?? "") : id = \(apiCall.dataId)")
// return try await self._runRequest(request, apiCall: apiCall)
//>>>>>>> main
// }
/// Returns the URLRequest for an ApiCall
/// - Parameters:
/// - apiCall: An ApiCall instance to configure the returned request
fileprivate func _syncPostRequest<T: SyncedStorable>(from apiCalls: [ApiCall<T>]) throws -> URLRequest {
let urlString = "\(baseURL)\(GetSyncData.resourceName())/"
guard let url = URL(string: urlString) else {
throw ServiceError.urlCreationError(url: urlString)
} }
var request = URLRequest(url: url) /// Executes a POST request
request.httpMethod = HTTPMethod.post.rawValue public func post<T: Storable>(_ instance: T) async throws -> T {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let token = try self.storeCenter.token()
request.addValue("Token \(token)", forHTTPHeaderField: "Authorization")
request.addAppVersion()
let modelName = String(describing: T.self) let method: HTTPMethod = .post
let operations = apiCalls.map { apiCall in let payload = SyncPayload(operation: method.rawValue, modelName: T.resourceName(), data: instance)
let syncRequest = try self._baseSyncRequest(method: .post, payload: payload)
return try await self._runRequest(syncRequest)
return Operation(apiCallId: apiCall.id, // var postRequest = try self._postRequest(type: T.self)
operation: apiCall.method.rawValue, // postRequest.httpBody = try jsonEncoder.encode(instance)
modelName: modelName, // return try await self._runRequest(postRequest)
data: apiCall.data,
storeId: apiCall.data?.getStoreId())
} }
// let posts = apiCalls.filter({ $0.method == .post }) /// Executes a PUT request
// for post in posts { public func put<T: Storable>(_ instance: T) async throws -> T {
// print("POST \(T.resourceName()): id = \(post.dataId ?? "")") var postRequest = try self._putRequest(type: T.self, id: instance.stringId)
// } postRequest.httpBody = try jsonEncoder.encode(instance)
return try await self._runRequest(postRequest)
let payload = SyncPayload(operations: operations,
deviceId: self.storeCenter.deviceId())
request.httpBody = try JSON.encoder.encode(payload)
return request
} }
// /// Starts a request to retrieve the synchronization updates /// Executes an ApiCall
// /// - Parameters: func runApiCall<T: Storable>(_ apiCall: ApiCall<T>) async throws -> T {
// /// - since: The date from which updates are retrieved let request = try self._syncRequest(from: apiCall)
// func synchronizeLastUpdates(since: Date?) async throws { print("HTTP \(request.httpMethod ?? "") : id = \(apiCall.dataId)")
// let request = try self._getSyncLogRequest(since: since) return try await self._runRequest(request, apiCallId: apiCall.id)
// if let data = try await self._runRequest(request) { }
// await self.storeCenter.synchronizeContent(data)
// }
// }
/// Returns the URLRequest for an ApiCall /// Returns the URLRequest for an ApiCall
/// - Parameters: /// - Parameters:
/// - since: The date from which updates are retrieved /// - apiCall: An ApiCall instance to configure the returned request
fileprivate func _getSyncLogRequest(since: Date?) throws -> URLRequest { fileprivate func _syncRequest<T: Storable>(from apiCall: ApiCall<T>) throws -> URLRequest {
let formattedDate = ISO8601DateFormatter().string(from: since ?? Date.distantPast) let urlString = baseURL + "data/"
let encodedDate =
formattedDate.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
let encodedDateWithPlus = encodedDate.replacingOccurrences(of: "+", with: "%2B")
let urlString = baseURL + "\(GetSyncData.resourceName())/?last_update=\(encodedDateWithPlus)"
Logger.log("urlString = \(urlString)")
guard let url = URL(string: urlString) else { guard let url = URL(string: urlString) else {
throw ServiceError.urlCreationError(url: urlString) throw ServiceError.urlCreationError(url: urlString)
} }
guard let bodyData = apiCall.body.data(using: .utf8) else {
throw ServiceError.cantDecodeData(content: apiCall.body)
}
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = HTTPMethod.get.rawValue request.httpMethod = HTTPMethod.post.rawValue
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let token = try self.storeCenter.token() // moyennement fan de decoder pour recoder derriere
request.addValue("Token \(token)", forHTTPHeaderField: "Authorization") let data = try jsonDecoder.decode(T.self, from: bodyData)
let payload = SyncPayload(operation: apiCall.method.rawValue, modelName: T.resourceName(), data: data)
return request
}
/// Runs the a sync request and forwards the response to the StoreCenter for processing
/// - Parameters:
/// - request: The synchronization request
fileprivate func _runRequest(_ request: URLRequest) async throws -> Data? {
let debugURL = request.url?.absoluteString ?? ""
// print("Run \(request.httpMethod ?? "") \(debugURL)")
let task: (Data, URLResponse) = try await URLSession.shared.data(for: request)
// print("\(request.httpMethod ?? "") \(debugURL) => \(String(data: task.0, encoding: .utf8) ?? "")")
if let response = task.1 as? HTTPURLResponse {
let statusCode = response.statusCode
switch statusCode {
case 200..<300: // success
return task.0
// success(task.0)
default: // error
print("\(debugURL) ended, status code = \(statusCode)")
Logger.log(
"Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
let errorString: String = String(data: task.0, encoding: .utf8) ?? ""
var errorMessage = ErrorMessage(error: errorString, domain: "")
if let message = self.errorMessageFromResponse(data: task.0) {
errorMessage = message
}
throw ServiceError.responseError(response: errorMessage.error)
}
} else {
let message: String = "Unexpected and unmanaged URL Response \(task.1)"
self.storeCenter.log(message: message)
Logger.w(message)
}
return nil
}
// MARK: - Services
/// Executes a GET request
public func get<T: SyncedStorable>(identifier: String? = nil) async throws -> [T] {
let getRequest = try _getRequest(type: T.self, identifier: identifier)
return try await self._runRequest(getRequest)
}
/// Executes a POST request on the generated DRF services corresponding to T
public func rawPost<T: Storable>(_ instance: T) async throws -> T {
var postRequest = try self._postRequest(type: T.self)
postRequest.httpBody = try JSON.encoder.encode(instance)
return try await self._runRequest(postRequest)
}
/// Executes a PUT request on the generated DRF services corresponding to T
public func rawPut<T: Storable>(_ instance: T) async throws -> T {
var postRequest = try self._putRequest(type: T.self, id: instance.stringId)
postRequest.httpBody = try JSON.encoder.encode(instance)
return try await self._runRequest(postRequest)
}
/// Executes a DELETE request on the generated DRF services corresponding to T request.httpBody = try jsonEncoder.encode(payload)
public func rawDelete<T: Storable>(_ instance: T) async throws -> T {
let deleteRequest = try self._deleteRequest(type: T.self, id: instance.stringId)
return try await self._runRequest(deleteRequest)
}
/// Executes an ApiCall if self._isTokenRequired(type: T.self, method: apiCall.method) {
func runGetApiCall<T: SyncedStorable>(_ apiCall: ApiCall<T>) async throws -> Data { let token = try self.keychainStore.getValue()
let request = try self._syncGetRequest(from: apiCall) request.addValue("Token \(token)", forHTTPHeaderField: "Authorization")
return try await self._runGetApiCallRequest(request, apiCall: apiCall)
} }
/// Executes an ApiCall return request
func runApiCalls<T: SyncedStorable>(_ apiCalls: [ApiCall<T>]) async throws -> [OperationResult<T>] {
let request = try self._syncPostRequest(from: apiCalls)
return try await self._runSyncPostRequest(request, type: T.self)
} }
/// Returns the URLRequest for an ApiCall /// Returns the URLRequest for an ApiCall
/// - Parameters: /// - Parameters:
/// - apiCall: An ApiCall instance to configure the returned request /// - apiCall: An ApiCall instance to configure the returned request
fileprivate func _request<T: SyncedStorable>(from apiCall: ApiCall<T>) throws -> URLRequest { fileprivate func _request<T: Storable>(from apiCall: ApiCall<T>) throws -> URLRequest {
let url = try self._url(from: apiCall) let url = try self._url(from: apiCall)
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = apiCall.method.rawValue request.httpMethod = apiCall.method.rawValue
request.httpBody = try apiCall.data?.jsonData() request.httpBody = apiCall.body.data(using: .utf8)
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.addAppVersion()
if self._isTokenRequired(type: T.self, method: apiCall.method) { if self._isTokenRequired(type: T.self, method: apiCall.method) {
do { do {
let token = try self.storeCenter.token() let token = try self.keychainStore.getValue()
request.setValue("Token \(token)", forHTTPHeaderField: "Authorization") request.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
} catch { } catch {
Logger.log("missing token") Logger.log("missing token")
@ -562,13 +352,18 @@ public class Services {
return request return request
} }
/// Returns the URL corresponding to the ApiCall /// Returns the URL corresponding to the ApiCall
/// - Parameters: /// - Parameters:
/// - apiCall: an instance of ApiCall to build to URL /// - apiCall: an instance of ApiCall to build to URL
fileprivate func _url<T: Storable>(from apiCall: ApiCall<T>) throws -> URL { fileprivate func _url<T: Storable>(from apiCall: ApiCall<T>) throws -> URL {
var stringURL: String = self.baseURL var stringURL: String = self.baseURL
stringURL += apiCall.urlExtension() switch apiCall.method {
case HTTPMethod.put, HTTPMethod.delete:
stringURL += T.path(id: apiCall.dataId)
default:
stringURL += T.path()
}
if let url = URL(string: stringURL) { if let url = URL(string: stringURL) {
return url return url
} else { } else {
@ -576,12 +371,6 @@ public class Services {
} }
} }
// MARK: - Others
public func getUserAgents() async throws -> [ShortUser] {
return try await self._runRequest(serviceCall: userAgentsCall)
}
// MARK: - Authentication // MARK: - Authentication
/// Creates an account /// Creates an account
@ -597,28 +386,38 @@ public class Services {
/// - password: the account's password /// - password: the account's password
public func requestToken(username: String, password: String) async throws -> String { public func requestToken(username: String, password: String) async throws -> String {
var postRequest = try self._baseRequest(call: requestTokenCall) var postRequest = try self._baseRequest(call: requestTokenCall)
let deviceId = self.storeCenter.deviceId() let deviceId = StoreCenter.main.deviceId()
let credentials = Credentials(username: username, password: password, deviceId: deviceId)
let deviceModel = await UIDevice.current.deviceModel() postRequest.httpBody = try jsonEncoder.encode(credentials)
let credentials = Credentials(username: username, password: password, deviceId: deviceId, deviceModel: deviceModel)
postRequest.httpBody = try JSON.encoder.encode(credentials)
let response: AuthResponse = try await self._runRequest(postRequest) let response: AuthResponse = try await self._runRequest(postRequest)
try self.storeCenter.storeToken(username: username, token: response.token) self._storeToken(username: username, token: response.token)
return response.token return response.token
} }
/// Stores a token for a corresponding username
/// - Parameters:
/// - username: the key used to store the token
/// - token: the token to store
fileprivate func _storeToken(username: String, token: String) {
do {
try self.keychainStore.deleteValue()
try self.keychainStore.add(username: username, value: token)
} catch {
Logger.error(error)
}
}
/// A login method that actually requests a token from the server, and stores the appropriate data for later usage /// A login method that actually requests a token from the server, and stores the appropriate data for later usage
/// - Parameters: /// - Parameters:
/// - username: the account's username /// - username: the account's username
/// - password: the account's password /// - password: the account's password
public func login<U: UserBase>(username: String, password: String) async throws -> U { public func login<U: UserBase>(username: String, password: String) async throws -> U {
_ = try await requestToken(username: username, password: password) _ = try await requestToken(username: username, password: password)
let postRequest = try self._baseRequest(call: getUserCall) let postRequest = try self._baseRequest(call: getUserCall)
let loggingDate = Date() // ideally we want the date of the latest retrieved object when loading collection objects
let user: U = try await self._runRequest(postRequest) let user: U = try await self._runRequest(postRequest)
self.storeCenter.userDidLogIn(user: user, at: loggingDate) // StoreCenter.main.setUserUUID(uuidString: user.id)
// StoreCenter.main.setUserName(user.username)
StoreCenter.main.setUserInfo(user: user)
return user return user
} }
@ -627,9 +426,8 @@ public class Services {
/// - username: the account's username /// - username: the account's username
/// - password: the account's password /// - password: the account's password
public func logout() async throws { public func logout() async throws {
let deviceId: String = self.storeCenter.deviceId() let deviceId: String = StoreCenter.main.deviceId()
let _: Empty = try await self._runRequest( let _: Empty = try await self._runRequest(serviceCall: logoutCall, payload: Logout(deviceId: deviceId))
serviceCall: logoutCall, payload: Logout(deviceId: deviceId))
} }
/// A login method that actually requests a token from the server, and stores the appropriate data for later usage /// A login method that actually requests a token from the server, and stores the appropriate data for later usage
@ -640,15 +438,7 @@ public class Services {
let tokenString = deviceToken.map { String(format: "%02x", $0) }.joined() let tokenString = deviceToken.map { String(format: "%02x", $0) }.joined()
let token = DeviceToken(value: tokenString) let token = DeviceToken(value: tokenString)
// Logger.log("Send device token = \(tokenString)") // Logger.log("Send device token = \(tokenString)")
let _: Empty = try await self._runRequest(serviceCall: postDeviceTokenCall, payload: token) let _: DeviceToken = try await self._runRequest(serviceCall: postDeviceTokenCall, payload: token)
}
/// Returns the list of DataAccess
func getUserDataAccessContent() async throws {
let request = try self._baseRequest(call: getUserDataAccessCallContent)
if let data = try await self._runRequest(request) {
await self.storeCenter.userDataAccessRetrieved(data)
}
} }
/// A method that sends a request to change a user's password /// A method that sends a request to change a user's password
@ -656,11 +446,9 @@ public class Services {
/// - oldPassword: the account's old password /// - oldPassword: the account's old password
/// - password1: the account's new password /// - password1: the account's new password
/// - password2: a repeat of the new password /// - password2: a repeat of the new password
public func changePassword(oldPassword: String, password1: String, password2: String) public func changePassword(oldPassword: String, password1: String, password2: String) async throws {
async throws
{
guard let username = self.storeCenter.userName else { guard let username = StoreCenter.main.userName() else {
throw ServiceError.missingUserName throw ServiceError.missingUserName
} }
@ -670,12 +458,10 @@ public class Services {
var new_password2: String var new_password2: String
} }
let params = ChangePasswordParams( let params = ChangePasswordParams(old_password: oldPassword, new_password1: password1, new_password2: password2)
old_password: oldPassword, new_password1: password1, new_password2: password2) let response: Token = try await self._runRequest(serviceCall: changePasswordCall, payload: params)
let response: Token = try await self._runRequest(
serviceCall: changePasswordCall, payload: params)
try self.storeCenter.storeToken(username: username, token: response.token) self._storeToken(username: username, token: response.token)
} }
/// The method send a request to reset the user's password /// The method send a request to reset the user's password
@ -683,7 +469,7 @@ public class Services {
/// - email: the email of the user /// - email: the email of the user
public func forgotPassword(email: String) async throws { public func forgotPassword(email: String) async throws {
var postRequest = try self._baseRequest(servicePath: "dj-rest-auth/password/reset/", method: .post, requiresToken: false) var postRequest = try self._baseRequest(servicePath: "dj-rest-auth/password/reset/", method: .post, requiresToken: false)
postRequest.httpBody = try JSON.encoder.encode(Email(email: email)) postRequest.httpBody = try jsonEncoder.encode(Email(email: email))
let response: Email = try await self._runRequest(postRequest) let response: Email = try await self._runRequest(postRequest)
Logger.log("response = \(response)") Logger.log("response = \(response)")
} }
@ -693,8 +479,8 @@ public class Services {
/// - username: the account's username /// - username: the account's username
/// - password: the account's password /// - password: the account's password
public func deleteAccount() async throws { public func deleteAccount() async throws {
guard let userId = self.storeCenter.userId else { guard let userId = StoreCenter.main.userId else {
throw StoreError.missingUserId throw ServiceError.missingUserId
} }
let path = "users/\(userId)/" let path = "users/\(userId)/"
let deleteAccount = ServiceCall(path: path, method: .delete, requiresToken: true) let deleteAccount = ServiceCall(path: path, method: .delete, requiresToken: true)
@ -703,14 +489,27 @@ public class Services {
let _: Empty = try await self._runRequest(request) let _: Empty = try await self._runRequest(request)
} }
/// Deletes the locally stored token
func deleteToken() throws {
try self.keychainStore.deleteValue()
}
/// Returns whether the Service has an associated token
public func hasToken() -> Bool {
do {
_ = try self.keychainStore.getValue()
return true
} catch {
return false
}
}
/// Parse a json data and tries to extract its error message /// Parse a json data and tries to extract its error message
/// - Parameters: /// - Parameters:
/// - data: some JSON data /// - data: some JSON data
fileprivate func errorMessageFromResponse(data: Data) -> ErrorMessage? { fileprivate func errorMessageFromResponse(data: Data) -> ErrorMessage? {
do { do {
if let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) if let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
as? [String: Any]
{
if let tuple = jsonObject.first { if let tuple = jsonObject.first {
var error = "" var error = ""
if let stringsArray = tuple.value as? [String], let first = stringsArray.first { if let stringsArray = tuple.value as? [String], let first = stringsArray.first {
@ -727,73 +526,16 @@ public class Services {
return nil return nil
} }
// MARK: - Convenience method for tests func migrateToken(_ services: Services, userName: String) throws {
try self._storeToken(username: userName, token: services.keychainStore.getValue())
/// Executes a POST request
public func post<T: SyncedStorable>(_ instance: T) async throws -> T? {
let apiCall: ApiCall<T> = ApiCall(method: .post, data: instance)
let results: [OperationResult<T>] = try await self.runApiCalls([apiCall])
return results.first?.data
}
/// Executes a PUT request
public func put<T: SyncedStorable>(_ instance: T) async throws -> T? {
let apiCall: ApiCall<T> = ApiCall(method: .put, data: instance)
let results: [OperationResult<T>] = try await self.runApiCalls([apiCall])
return results.first?.data
}
public func delete<T: SyncedStorable>(_ instance: T) async throws -> T? {
let apiCall: ApiCall<T> = ApiCall(method: .delete, data: instance)
let results: [OperationResult<T>] = try await self.runApiCalls([apiCall])
return results.first?.data
}
/// Returns a POST request for the resource
/// - Parameters:
/// - type: the type of the request resource
fileprivate func _postRequest<T: Storable>(type: T.Type) throws -> URLRequest {
return try self._baseRequest(servicePath: T.path(), method: .post, requiresToken: true)
}
/// Returns a PUT request for the resource
/// - Parameters:
/// - type: the type of the request resource
fileprivate func _putRequest<T: Storable>(type: T.Type, id: String) throws -> URLRequest {
return try self._baseRequest(servicePath: T.path(id: id), method: .put, requiresToken: true)
}
/// Returns a DELETE request for the resource
/// - Parameters:
/// - type: the type of the request resource
fileprivate func _deleteRequest<T: Storable>(type: T.Type, id: String) throws -> URLRequest {
return try self._baseRequest(servicePath: T.path(id: id), method: .delete, requiresToken: true)
} }
} }
struct SyncPayload<T: Encodable>: Encodable { struct SyncPayload<T: Encodable>: Encodable {
var operations: [Operation<T>]
var deviceId: String?
}
struct Operation<T: Encodable>: Encodable {
var apiCallId: String
var operation: String var operation: String
var modelName: String var modelName: String
var data: T var data: T
var storeId: String?
}
struct BatchResponse<T: Decodable>: Decodable {
var results: [OperationResult<T>]
}
public struct OperationResult<T: Decodable>: Decodable {
var apiCallId: String
public var status: Int
var data: T?
public var message: String?
} }
struct ErrorMessage { struct ErrorMessage {
@ -812,7 +554,6 @@ struct Credentials: Codable {
var username: String var username: String
var password: String var password: String
var deviceId: String var deviceId: String
var deviceModel: String?
} }
struct Token: Codable { struct Token: Codable {
var token: String var token: String
@ -841,19 +582,3 @@ public protocol UserBase: Codable {
public protocol UserPasswordBase: UserBase { public protocol UserPasswordBase: UserBase {
var password: String { get } var password: String { get }
} }
public struct ShortUser: Codable, Identifiable, Equatable {
public var id: String
public var firstName: String
public var lastName: String
}
fileprivate extension URLRequest {
mutating func addAppVersion() {
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"
let appVersion = "\(version) (\(build))"
self.setValue(appVersion, forHTTPHeaderField: "App-Version")
}
}

@ -8,7 +8,7 @@
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
/// and not referenced by other objects in the store
/// This is used when cleaning up shared objects that are no longer in use
func deleteUnusedSharedDependencies(store: Store)
/// Copies the content of another item into the instance /// A method called to retrieve data added by the server on a POST request
/// This behavior has been made to get live updates when looking at properties in SwiftUI screens /// The method will be called after a POST has succeeded,
func copy(from other: any Storable) /// and will provide a copy of what's on the server
func copyFromServerInstance(_ instance: any Storable) -> Bool
/// This method returns RelationShips objects of the type static var relationshipNames: [String] { get }
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,61 @@
import Foundation import Foundation
import UIKit import UIKit
public enum StoreError: Error, LocalizedError { 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 var localizedDescription: String {
switch self {
case .missingService:
return "L'instance des services est nulle"
case .missingUsername:
return "Le nom d'utilisateur est manquant"
case .missingUserId:
return "L'identifiant utilisateur est manquant"
case .missingToken:
return "Aucun token n'est stocké"
case .missingKeychainStore:
return "Aucun magasin de trousseau n'est disponible"
case .collectionNotRegistered(let type):
return "La collection \(type) n'est pas enregistrée"
case .apiCallCollectionNotRegistered(let type):
return "La collection d'appels API n'a pas été enregistrée pour \(type)"
case .synchronizationInactive:
return "La synchronisation n'est pas active sur ce StoreCenter"
case .storeNotRegistered(let id):
return "Le magasin avec l'identifiant \(id) n'est pas enregistré"
case .castIssue(let type):
return "Problème de typage: \(type)"
case .invalidStoreLookup(let from, let to):
return "Mauvaise recherche dans le magasin de \(from) à \(to)"
}
}
public var errorDescription: String? {
switch self {
case .missingService:
return "Services instance is nil"
case .missingUsername:
return "The username is missing"
case .missingUserId:
return "The user id is missing"
case .missingToken:
return "There is no stored token"
case .missingKeychainStore:
return "There is no keychain store"
case .collectionNotRegistered(let type):
return "The collection \(type) is not registered"
case .apiCallCollectionNotRegistered(let type):
return "The api call collection has not been registered for \(type)"
case .synchronizationInactive:
return "The synchronization is not active on this StoreCenter"
case .storeNotRegistered(let id):
return "The store with identifier \(id) is not registered"
case .castIssue(let type):
return "Can't cast to \(type)"
case .invalidStoreLookup(let from, let to):
return "invalid store lookup from \(from) to \(to)"
}
}
}
final public class Store {
public fileprivate(set) var storeCenter: StoreCenter
/// The dictionary of registered collections
fileprivate var _collections: [String : any SomeCollection] = [:]
/// The dictionary of all StoredCollection public struct StoreIdentifier {
fileprivate var _baseCollections: [String : any SomeCollection] = [:] var value: String
var parameterName: String
/// The store identifier, used to name the store directory, and to perform filtering requests to the server public init(value: String, parameterName: String) {
public fileprivate(set) var identifier: String? = nil self.value = value
self.parameterName = parameterName
}
public init(storeCenter: StoreCenter) { var urlComponent: String {
self.storeCenter = storeCenter return "?\(self.parameterName)=\(self.value)"
}
} }
public required init(storeCenter: StoreCenter, identifier: String) { open class Store {
self.storeCenter = storeCenter
self.identifier = identifier
let directory = "\(storeCenter.directoryName)/\(identifier)" /// The Store singleton
self._createDirectory(directory: directory) public static let main = Store()
}
/// The dictionary of registered StoredCollections
fileprivate var _collections: [String : any SomeCollection] = [:]
/// The name of the directory to store the json files
static let storageDirectory = "storage"
public static var main: Store { return StoreCenter.main.mainStore } /// The store identifier, used to name the store directory, and to perform filtering requests to the server
fileprivate(set) var identifier: StoreIdentifier? = nil
public func alternateStore(identifier: String) throws -> Store { /// Indicates whether the store directory has been created at the init
return try self.storeCenter.store(identifier: identifier) fileprivate var _created: Bool = false
public init() {
self._createDirectory(directory: Store.storageDirectory)
}
public required init(identifier: String, parameter: String) {
self.identifier = StoreIdentifier(value: identifier, parameterName: parameter)
let directory = "\(Store.storageDirectory)/\(identifier)"
self._createDirectory(directory: directory)
} }
/// Creates the store directory /// 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,54 +72,23 @@ 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)
self._collections[T.resourceName()] = collection
self._baseCollections[T.resourceName()] = collection
return collection
}
/// Registers a synchronized collection
/// - Parameters:
/// - indexed: Creates an index to quickly access the data
/// - inMemory: Indicates if the collection should only live in memory, and not write into a file
public func registerSynchronizedCollection<T : SyncedStorable>(indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil, noLoad: Bool = false) -> SyncedCollection<T> {
if let collection: SyncedCollection<T> = try? self.syncedCollection() {
return collection
}
let collection = SyncedCollection<T>(store: self, indexed: indexed, inMemory: inMemory, limit: limit, noLoad: noLoad) 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.collection
self.storeCenter.loadApiCallCollection(type: T.self) if synchronized {
return collection StoreCenter.main.loadApiCallCollection(type: T.self)
} }
func asyncLoadingSynchronizedCollection<T : SyncedStorable>(inMemory: Bool = false) async -> SyncedCollection<T> { if self._created, let identifier {
let collection = await SyncedCollection<T>(store: self, inMemory: inMemory) self._migrate(collection, identifier: identifier, type: T.self)
self._collections[T.resourceName()] = collection
self._baseCollections[T.resourceName()] = collection.collection
self.storeCenter.loadApiCallCollection(type: T.self)
return collection
} }
func asyncLoadingStoredCollection<T : Storable>(inMemory: Bool = false) async -> StoredCollection<T> {
let collection = await StoredCollection<T>(store: self, inMemory: inMemory)
self._collections[T.resourceName()] = collection
self._baseCollections[T.resourceName()] = collection
return collection return collection
} }
@ -178,14 +97,13 @@ final public class Store {
/// - 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 { if synchronized {
self.storeCenter.loadApiCallCollection(type: T.self) StoreCenter.main.loadApiCallCollection(type: T.self)
} }
return storedObject return storedObject
@ -196,74 +114,44 @@ 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 {
throw StoreError.collectionNotRegistered(type: T.resourceName()) return try self.collection().filter(isIncluded)
} catch {
return []
} }
/// Returns a collection by type
func syncedCollection<T: SyncedStorable>(type: T.Type) throws -> SyncedCollection<T> {
return try self.syncedCollection()
} }
/// Returns a collection by type /// 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 loadCollectionsFromServer() {
for collection in self._syncedCollections() { for collection in self._collections.values {
Task { if collection.synchronized {
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 {
@ -271,166 +159,17 @@ final public class Store {
} }
} }
// MARK: - Synchronization /// Returns the names of all collections
public func collectionNames() -> [String] {
fileprivate func _requestWrite<T: SyncedStorable>(type: T.Type) { return self._collections.values.map { $0.resourceName }
self._baseCollections[T.resourceName()]?.requestWriteIfNecessary()
}
@MainActor
func synchronizationAddOrUpdate<T: SyncedStorable>(_ instances: [T], shared: SharingStatus?) {
for item in instances {
if !self.storeCenter.hasAlreadyBeenDeleted(item) {
self.addOrUpdateIfNewer(item, shared: shared)
}
}
self._requestWrite(type: T.self)
}
/// Calls addOrUpdateIfNewer from the collection corresponding to the instance
@MainActor
func addOrUpdateIfNewer<T: SyncedStorable>(_ instance: T, shared: SharingStatus?) {
let collection: SyncedCollection<T> = self.registerOrGetSyncedCollection(T.self)
collection.addOrUpdateIfNewer(instance, shared: shared)
}
@MainActor
func synchronizationDelete<T: SyncedStorable>(_ identifiers: [ObjectIdentifier], type: T.Type) {
for identifier in identifiers {
do {
try self.deleteNoSyncNoCascadeNoWrite(type: type, id: identifier.modelId)
} catch {
Logger.error(error)
}
self.storeCenter.cleanupDataLog(dataId: identifier.modelId)
}
self._requestWrite(type: T.self)
}
@MainActor
func synchronizationRevoke<T: SyncedStorable>(_ identifiers: [ObjectIdentifier], type: T.Type) {
for identifier in identifiers {
do {
if let instance = self._instance(id: identifier.modelId, type: type) {
if instance.sharing != nil && !self.storeCenter.isReferenced(instance: instance) {
try self.deleteNoSyncNoCascadeNoWrite(type: type, id: identifier.modelId)
}
}
} catch {
Logger.error(error)
}
}
self._requestWrite(type: T.self)
}
fileprivate func _instance<T: SyncedStorable>(id: String, type: T.Type) -> T? {
let realId: T.ID = T.buildRealId(id: id)
return self.findById(realId)
}
/// Calls deleteById from the collection corresponding to the instance
func deleteNoSyncNoCascadeNoWrite<T: SyncedStorable>(type: T.Type, id: String) throws {
let collection: SyncedCollection<T> = try self.syncedCollection()
collection.deleteByStringId(id, actionOption: .noCascadeNoWrite)
}
func isReferenced<T: Storable, S: Storable>(collectionType: S.Type, type: T.Type, id: String) -> Bool {
if let collection = self._baseCollections[S.resourceName()] {
return collection.hasParentReferences(type: type, id: id)
} else {
return false
}
}
public func deleteUnusedGrantedIfNecessary<T: SyncedStorable>(_ instance: T, originStoreId: String?) {
if !self.storeCenter.isReferenced(instance: instance) {
do {
let collection: SyncedCollection<T> = try self.syncedCollection()
collection.deleteUnusedGranted(instance: instance)
} catch {
Logger.error(error)
}
}
}
public func deleteUnusedSharedDependencies<T: SyncedStorable>(type: T.Type, _ handler: (T) throws -> Bool) {
do {
let collection: SyncedCollection<T> = try self.syncedCollection()
let items = try collection.items.filter(handler)
self.deleteUnusedSharedDependencies(items)
} catch {
Logger.error(error)
}
}
public func deleteUnusedSharedDependencies<T: SyncedStorable>(type: T.Type) {
do {
let collection: SyncedCollection<T> = try self.syncedCollection()
self.deleteUnusedSharedDependencies(collection.items)
} catch {
Logger.error(error)
}
}
/// Deletes dependencies of shared objects that are not used elsewhere in the system
/// Similar to _deleteDependencies but only for unused shared objects
public func deleteUnusedSharedDependencies<T: SyncedStorable>(_ items: [T]) {
do {
for item in items {
guard item.sharing != nil else { continue }
if !self.storeCenter.isReferenced(instance: item) {
// Only delete if the shared item has no references
item.deleteUnusedSharedDependencies(store: self)
let collection: SyncedCollection<T> = try self.syncedCollection()
collection.deleteUnusedGranted(instance: item)
}
}
} catch {
Logger.error(error)
}
}
public func deleteAllDependencies<T: Storable>(type: T.Type, actionOption: ActionOption) {
do {
let collection = try self.someCollection(type: type)
collection.deleteAllItemsAndDependencies(actionOption: actionOption)
} catch {
Logger.error(error)
}
}
public func deleteDependencies<T>(type: T.Type, actionOption: ActionOption, _ isIncluded: (T) -> Bool) where T: SyncedStorable {
do {
let collection = try self.someCollection(type: type)
if let syncCollection = collection as? SyncedCollection<T> {
syncCollection.deleteDependencies(actionOption: actionOption, isIncluded)
}
} catch {
Logger.error(error)
}
}
public func deleteDependencies<T>(type: T.Type, actionOption: ActionOption, _ isIncluded: (T) -> Bool) where T: Storable {
do {
let collection = try self.someCollection(type: type)
if let syncCollection = collection as? StoredCollection<T> {
syncCollection.deleteDependencies(actionOption: actionOption, isIncluded)
}
} catch {
Logger.error(error)
}
} }
// 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 +183,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 +208,91 @@ 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 -> T? {
return try await StoreCenter.main.sendInsertion(instance)
}
/// Requests an update to the StoreCenter
/// - Parameters:
/// - instance: an object to update
@discardableResult func sendUpdate<T: Storable>(_ instance: T) async throws -> T? {
return try await StoreCenter.main.sendUpdate(instance)
} }
/// Requests a deletion to the StoreCenter
/// - Parameters:
/// - instance: an object to delete
@discardableResult func sendDeletion<T: Storable>(_ instance: T) async throws -> T? {
return try await StoreCenter.main.sendDeletion(instance)
} }
func loadCollectionItems<T: SyncedStorable>(_ items: [T], clear: Bool) async { /// Loads all synchronized collection with server data if they don't already have a local file
public func loadCollectionsFromServerIfNoFile() {
for collection in self._collections.values {
if collection.synchronized {
Task {
do { do {
let collection: SyncedCollection<T> = try self.syncedCollection() try await collection.loadCollectionsFromServerIfNoFile()
await collection.loadItems(items, clear: clear)
} catch { } catch {
Logger.error(error) Logger.error(error)
} }
} }
}
}
}
/// Returns whether all collections have loaded locally /// Returns whether all collections have loaded locally
public func fileCollectionsAllLoaded() -> Bool { public func collectionsAllLoaded() -> Bool {
let fileCollections = self._collections.values.filter { $0.inMemory == false } return self._collections.values.allSatisfy { $0.hasLoaded }
return fileCollections.allSatisfy { $0.hasLoaded } }
fileprivate var _validIds: [String] = []
fileprivate func _migrate<T : Storable>(_ collection: StoredCollection<T>, identifier: StoreIdentifier, type: T.Type) {
self._validIds.append(identifier.value)
let oldCollection: StoredCollection<T> = StoredCollection<T>(synchronized: false, store: Store.main, asynchronousIO: false)
let filtered: [T] = oldCollection.items.filter { item in
var propertyValue: String? = item.stringForPropertyName(identifier.parameterName)
if propertyValue == nil {
let values = T.relationshipNames.map { item.stringForPropertyName($0) }
propertyValue = values.compactMap { $0 }.first
}
return self._validIds.first(where: { $0 == propertyValue }) != nil
}
if filtered.count > 0 {
self._validIds.append(contentsOf: filtered.map { $0.stringId })
try? collection.addOrUpdateNoSync(contentOfs: filtered)
Logger.log("Migrated \(filtered.count) \(T.resourceName())")
}
}
}
fileprivate extension Storable {
func stringForPropertyName(_ propertyName: String) -> String? {
let mirror = Mirror(reflecting: self)
for child in mirror.children {
if let label = child.label, label == "_\(propertyName)" {
return child.value as? String
}
}
return nil
} }
} }

File diff suppressed because it is too large Load Diff

@ -1,64 +0,0 @@
//
// StoreLibrary.swift
// LeStorage
//
// Created by Laurent Morvillier on 02/06/2025.
//
import Foundation
class StoreLibrary {
private let storeCenter: StoreCenter
/// A dictionary of Stores associated to their id
fileprivate var _stores: [String: Store] = [:]
init(storeCenter: StoreCenter) {
self.storeCenter = storeCenter
}
subscript(identifier: String) -> Store? {
get {
return self._stores[identifier]
}
}
/// Registers a store into the list of stores
/// - Parameters:
/// - store: A store to save
fileprivate func _registerStore(store: Store) {
guard let identifier = store.identifier else {
fatalError("The store has no identifier")
}
if self._stores[identifier] != nil {
fatalError("A store with this identifier has already been registered: \(identifier)")
}
self._stores[identifier] = store
}
/// Returns a store using its identifier, and registers it if it does not exists
/// - Parameters:
/// - identifier: The store identifer
/// - parameter: The parameter name used to filter data on the server
func requestStore(identifier: String) -> Store {
if let store = self._stores[identifier] {
return store
} else {
let store = Store(storeCenter: self.storeCenter, identifier: identifier)
self._registerStore(store: store)
return store
}
}
public func destroyStore(identifier: String) {
let directory = "\(self.storeCenter.directoryName)/\(identifier)"
FileManager.default.deleteDirectoryInDocuments(directoryName: directory)
self._stores[identifier]?.reset()
self._stores.removeValue(forKey: identifier)
}
func reset() {
self._stores.removeAll()
}
}

@ -6,609 +6,472 @@
// //
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() protocol CollectionHolder {
func hasParentReferences<S: Storable>(type: S.Type, id: String) -> Bool associatedtype Item
var items: [Item] { get } var items: [Item] { get }
func reset()
func deleteAllItemsAndDependencies(actionOption: ActionOption)
func deleteDependencies(actionOption: ActionOption, _ isIncluded: (Item) -> Bool)
func findById(_ id: Item.ID) -> Item?
func requestWriteIfNecessary()
} }
protocol CollectionDelegate<Item> { protocol SomeCollection: CollectionHolder, Identifiable {
associatedtype Item: Storable var resourceName: String { get }
func loadingForMemoryCollection() async var synchronized: Bool { get }
func itemMerged(_ pendingOperation: PendingOperation<Item>) var hasLoaded: Bool { get }
}
enum CollectionMethod { func allItems() -> [any Storable]
case insert
case update
case delete
}
public struct ActionResult<T> { func loadDataFromServerIfAllowed() async throws
var instance: T func loadCollectionsFromServerIfNoFile() async throws
var method: CollectionMethod
var pending: Bool
}
public struct ActionOption: Codable { }
var synchronize: Bool
var cascade: Bool
var write: Bool
static let standard: ActionOption = ActionOption(synchronize: false, cascade: false, write: true) extension Notification.Name {
static let noCascadeNoWrite: ActionOption = ActionOption(synchronize: false, cascade: false, write: false) public static let CollectionDidLoad: Notification.Name = Notification.Name.init("notification.collectionDidLoad")
static let cascade: ActionOption = ActionOption(synchronize: false, cascade: true, write: true) public static let CollectionDidChange: Notification.Name = Notification.Name.init("notification.collectionDidChange")
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
/// 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
/// 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._triggerWrite = false
}
self._scheduleWrite()
DispatchQueue.main.async { DispatchQueue.main.async {
NotificationCenter.default.post( NotificationCenter.default.post(name: NSNotification.Name.CollectionDidChange, object: self)
name: NSNotification.Name.CollectionDidChange, object: self) }
self._hasChanged = false
} }
} }
} }
/// Denotes a collection that loads and writes asynchronously
fileprivate var asynchronousIO: Bool = true
/// Indicates if the collection has loaded locally, with or without a file /// Indicates if the collection has loaded locally, with or without a file
fileprivate(set) public var hasLoaded: Bool = false fileprivate(set) public var hasLoaded: Bool = false
/// Sets a max number of items inside the collection init(synchronized: Bool, store: Store, indexed: Bool = false, asynchronousIO: Bool = true, inMemory: Bool = false, sendsUpdate: Bool = true) {
fileprivate(set) var limit: Int? = nil self.synchronized = synchronized
self.asynchronousIO = asynchronousIO
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) {
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._load()
self.hasLoaded = true
} else {
Task {
await self.load()
}
} }
fileprivate init() {
self.synchronized = false
self._store = Store.main
} }
init(store: Store) { public static func placeholder() -> StoredCollection<T> {
self.store = store return StoredCollection<T>()
} }
var storeCenter: StoreCenter { return self.store.storeCenter } var resourceName: String {
/// Returns the name of the managed resource
public var resourceName: String {
return T.resourceName() return T.resourceName()
} }
public var storeId: String? {
return self.store.identifier
}
// 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 {
} catch { Task {
Logger.error(error) try await self.loadDataFromServerIfAllowed()
await MainActor.run {
self.setAsLoaded()
} }
do { } else {
let fileURL = try self.store.fileURL(type: T.self) try self._loadFromFile()
let jsonString: String = try FileUtils.readFile(fileURL: fileURL)
if !jsonString.isEmpty {
StoreCenter.main.log(message: "Could not decode: \(jsonString)")
} }
} catch { } catch {
Logger.error(error)
}
} }
/// Starts the JSON file decoding synchronously or asynchronously
fileprivate func _loadFromFile() throws {
if self.asynchronousIO {
Task(priority: .high) {
try self._decodeJSONFile()
}
} else {
try self._decodeJSONFile()
} }
} }
/// Decodes the json file into the items array /// 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
}
if self.asynchronousIO {
DispatchQueue.main.async {
self._setItems(decoded)
self._setAsLoaded()
} }
await MainActor.run { } else {
self.setAsLoaded() self._setItems(decoded)
self._setAsLoaded()
}
} else {
self._setAsLoaded()
} }
} }
/// Sets the collection as loaded /// Sets the collection as loaded
/// Send a CollectionDidLoad event /// Send a CollectionDidLoad event
@MainActor fileprivate func _setAsLoaded() {
func setAsLoaded() {
self.hasLoaded = true self.hasLoaded = true
self._mergePendingOperations() DispatchQueue.main.async {
NotificationCenter.default.post(name: NSNotification.Name.CollectionDidLoad, object: self)
NotificationCenter.default.post(
name: NSNotification.Name.CollectionDidLoad, object: self)
}
/// Sets a collection of items and indexes them
func setItems(_ items: [T]) {
self.clear()
for item in items {
self._addItem(instance: item)
} }
} }
@MainActor /// Sets a collection of items and indexes them
func loadAndWrite(_ items: [T], clear: Bool = false) { fileprivate func _setItems(_ items: [T]) {
if clear { self.items = items
self.setItems(items) self._updateIndexIfNecessary()
self.setAsLoaded()
} else {
self.setAsLoaded()
self.addOrUpdate(contentOfs: items)
}
self.requestWriteIfNecessary()
} }
/// Updates the whole index with the items array /// Updates the whole index with the items array
fileprivate func _updateIndexIfNecessary() { fileprivate func _updateIndexIfNecessary() {
if self._indexes != nil { if let _ = self._indexes {
self._indexes = self.items.dictionary { $0.id } self._indexes = self.items.dictionary { $0.stringId }
} }
} }
// MARK: - Basic operations /// Retrieves the data from the server and loads it into the items array
public func loadDataFromServerIfAllowed() async throws {
/// Adds or updates the provided instance inside the collection guard self.synchronized, !(self is StoredSingleton<T>) else {
/// Adds it if its id is not found, and otherwise updates it throw StoreError.cannotSyncCollection(name: self.resourceName)
@discardableResult public func addOrUpdate(instance: T) -> ActionResult<T> {
defer {
self.requestWriteIfNecessary()
} }
return self._rawAddOrUpdate(instance: instance) do {
let items: [T] = try await self._store.getItems()
if items.count > 0 {
DispatchQueue.main.async {
self._addOrUpdate(contentOfs: items, shouldSync: false)
} }
/// Adds or update a sequence of elements
public func addOrUpdate(contentOfs sequence: any Sequence<T>, _ handler: ((ActionResult<T>) -> ())? = nil) {
defer {
self.requestWriteIfNecessary()
} }
} catch {
for instance in sequence { Logger.error(error)
let result = self._rawAddOrUpdate(instance: instance)
handler?(result)
} }
self._setAsLoaded()
} }
fileprivate func _rawAddOrUpdate(instance: T) -> ActionResult<T> { /// Loads the collection using the server data only if the collection file doesn't exists
if let index = self.items.firstIndex(where: { $0.id == instance.id }) { func loadCollectionsFromServerIfNoFile() async throws {
let updated = self._updateItem(instance, index: index, actionOption: .standard) let fileURL: URL = try self._store.fileURL(type: T.self)
return ActionResult(instance: instance, method: .update, pending: !updated) if !FileManager.default.fileExists(atPath: fileURL.path()) {
} else { try await self.loadDataFromServerIfAllowed()
let added = self._addItem(instance: instance)
return ActionResult(instance: instance, method: .insert, pending: !added)
} }
} }
/// A method the treat the collection as a single instance holder // MARK: - Basic operations
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 /// Adds or updates the provided instance inside the collection
public func delete(instance: T) { /// Adds it if its id is not found, and otherwise updates it
self.delete(instance: instance, actionOption: .cascade) public func addOrUpdate(instance: T) throws {
}
/// Deletes the instance in the collection and sets the collection as changed to trigger a write
public func delete(instance: T, actionOption: ActionOption) {
defer { defer {
self._triggerWrite = true self._hasChanged = true
}
self.deleteItem(instance, actionOption: actionOption)
} }
/// Deletes all items of the sequence by id and sets the collection as changed to trigger a write var item = instance
public func delete(contentOfs sequence: any RandomAccessCollection<T>, _ handler: ((ActionResult<T>) -> ())? = nil) { item.store = self._store
self.delete(contentOfs: sequence, actionOption: .cascade, handler: handler)
}
func delete(contentOfs sequence: any RandomAccessCollection<T>, actionOption: ActionOption, handler: ((ActionResult<T>) -> ())? = nil) {
defer { // update
self._triggerWrite = true if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
self.items[index] = instance
self._sendUpdateIfNecessary(instance)
} else { // insert
self.items.append(instance)
self._sendInsertionIfNecessary(instance)
} }
self._indexes?[instance.stringId] = instance
for instance in sequence {
let deleted = self.deleteItem(instance, actionOption: actionOption)
handler?(ActionResult(instance: instance, method: .delete, pending: !deleted))
}
} }
/// This method sets the storeId for the given instance if the collection belongs to a store with an id /// Sends a POST request for the instance, and changes the collection to perform a write
fileprivate func _affectStoreIdIfNecessary(instance: T) { public func writeChangeAndInsertOnServer(instance: T) {
if let storeId = self.store.identifier { defer {
if var altStorable = instance as? SideStorable { self._hasChanged = true
altStorable.storeId = storeId
} else {
fatalError("instance does not implement SideStorable, thus sync cannot work")
}
}
} }
self._sendInsertionIfNecessary(instance)
func add(instance: T, actionOption: ActionOption) {
self._addItem(instance: instance, actionOption: actionOption)
} }
/// Adds an instance to the collection /// A method the treat the collection as a single instance holder
@discardableResult fileprivate func _addItem(instance: T, actionOption: ActionOption = .standard) -> Bool { func setSingletonNoSync(instance: T) {
defer {
if !self.hasLoaded { self._hasChanged = true
self.addPendingOperation(method: .add, instance: instance, actionOption: actionOption)
return false
} }
self.invalidateCache() self.items.removeAll()
self._affectStoreIdIfNecessary(instance: instance)
self.items.append(instance) self.items.append(instance)
instance.store = self.store
self._indexes?[instance.id] = instance
self._applyLimitIfPresent()
if T.storeParent() {
_ = self.storeCenter.requestStore(identifier: instance.stringId) // make directory
} }
return true /// Deletes the instance in the collection by id
} public func delete(instance: T) throws {
func update(_ instance: T, index: Int, actionOption: ActionOption) { defer {
self._updateItem(instance, index: index, actionOption: actionOption) self._hasChanged = true
// self.requestWrite()
} }
/// Updates an instance to the collection by index try instance.deleteDependencies()
@discardableResult fileprivate func _updateItem(_ instance: T, index: Int, actionOption: ActionOption) -> Bool { self.items.removeAll { $0.id == instance.id }
self._indexes?.removeValue(forKey: instance.stringId)
if !self.hasLoaded { self._sendDeletionIfNecessary(instance)
self.addPendingOperation(method: .update, instance: instance, actionOption: actionOption)
return false
} }
self.invalidateCache()
let item = self.items[index] /// Deletes all items of the sequence by id
if item !== instance { public func delete(contentOfs sequence: any Sequence<T>) throws {
self.items[index].copy(from: instance)
}
instance.store = self.store defer {
self._indexes?[instance.id] = instance self._hasChanged = true
return true
} }
/// Deletes an instance from the collection for instance in sequence {
@discardableResult fileprivate func deleteItem(_ instance: T, actionOption: ActionOption = .cascade) -> Bool { try instance.deleteDependencies()
self.items.removeAll { $0.id == instance.id }
if !self.hasLoaded { self._indexes?.removeValue(forKey: instance.stringId)
self.addPendingOperation(method: .delete, instance: instance, actionOption: actionOption) self._sendDeletionIfNecessary(instance)
return false
} }
if actionOption.cascade {
instance.deleteDependencies(store: self.store, actionOption: actionOption)
} }
self.localDeleteOnly(instance: instance) /// Adds or update a sequence of elements
public func addOrUpdate(contentOfs sequence: any Sequence<T>) throws {
if T.storeParent() { self._addOrUpdate(contentOfs: sequence)
self.storeCenter.destroyStore(identifier: instance.stringId)
} }
return true /// Adds or update a sequence of elements without synchronizing it
func addOrUpdateNoSync(contentOfs sequence: any Sequence<T>) throws {
self._addOrUpdate(contentOfs: sequence, shouldSync: false)
} }
/// Deletes an instance from the collection /// Inserts or updates all items in the sequence
@discardableResult func deleteUnusedShared(_ instance: T, actionOption: ActionOption) -> Bool { fileprivate func _addOrUpdate(contentOfs sequence: any Sequence<T>, shouldSync: Bool = true) {
defer {
if !self.hasLoaded { self._hasChanged = true
self.addPendingOperation(method: .deleteUnusedShared, instance: instance, actionOption: actionOption)
return false
} }
// For shared objects, we need to check for dependencies that are also shared for var instance in sequence {
// but not used elsewhere before deleting them if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
instance.deleteUnusedSharedDependencies(store: self.store) self.items[index] = instance
if shouldSync {
self.localDeleteOnly(instance: instance) self._sendUpdateIfNecessary(instance)
return true
} }
} else { // insert
func localDeleteOnly(instance: T) { self.items.append(instance)
self.invalidateCache() if shouldSync {
self.items.removeAll { $0.id == instance.id } self._sendInsertionIfNecessary(instance)
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)
} }
instance.store = self._store
self._indexes?[instance.stringId] = instance
} }
func deleteByStringId(_ id: String, actionOption: ActionOption = .cascade) {
let realId = T.buildRealId(id: id)
if let instance = self.findById(realId) {
self.deleteItem(instance, actionOption: actionOption)
}
if actionOption.write {
self.requestWriteIfNecessary()
}
} }
/// Returns the instance corresponding to the provided [id] /// 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)
} }
}
}
public func deleteAllItemsAndDependencies(actionOption: ActionOption) { Task {
self._delete(contentOfs: self.items, actionOption: actionOption) do {
try await StoreCenter.main.deleteApiCallByDataId(type: T.self, id: item.stringId)
} catch {
Logger.error(error)
}
} }
public func deleteDependencies(actionOption: ActionOption, _ isIncluded: (T) -> Bool) {
let items = self.items.filter(isIncluded)
self._delete(contentOfs: items, actionOption: actionOption)
} }
fileprivate func _delete(contentOfs sequence: any RandomAccessCollection<T>, actionOption: ActionOption) {
for instance in sequence {
self.deleteItem(instance, actionOption: actionOption)
} }
/// Proceeds to delete all instance of the collection, properly cleaning up dependencies and sending API calls
public func deleteAll() throws {
try self.delete(contentOfs: self.items)
} }
// MARK: - Pending operations // MARK: - Migrations
func addPendingOperation(method: StorageMethod, instance: T, actionOption: ActionOption) { /// Makes POST ApiCall for all items in the collection
if self.pendingOperationManager == nil { public func insertAllIntoCurrentService() {
self.pendingOperationManager = PendingOperationManager<T>(store: self.store, inMemory: self.inMemory) for item in self.items {
} self._sendInsertionIfNecessary(item)
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() { /// Makes POST ApiCall for the provided item
public func insertIntoCurrentService(item: T) {
guard let manager = self.pendingOperationManager, manager.items.isNotEmpty else { return } self._sendInsertionIfNecessary(item)
Logger.log(">>> Merge pending \(manager.typeName): \(manager.items.count)")
for item in manager.items {
let data = item.data
switch item.method {
case .add, .update:
self.addOrUpdate(instance: data)
case .delete:
self.deleteItem(data, actionOption: item.actionOption)
case .deleteUnusedShared:
self.deleteUnusedShared(data, actionOption: item.actionOption)
} }
} // MARK: - SomeCall
manager.reset()
self.pendingOperationManager = nil /// Returns the collection items as [any Storable]
func allItems() -> [any Storable] {
return self.items
} }
// MARK: - File access // 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 { } catch {
Logger.error(error) Logger.error(error) // TODO how to notify the main project
self.storeCenter.log(
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 and deletes the corresponding file
public func reset() { public func reset() {
self.clear() self.items.removeAll()
self.store.removeFile(type: T.self) self._store.removeFile(type: T.self)
} }
public var type: any Storable.Type { return T.self } // MARK: - Reschedule calls
// MARK: - Reference count
/// Counts the references to an object - given its type and id - inside the collection
public func hasParentReferences<S: Storable>(type: S.Type, id: String) -> Bool {
let relationships = T.parentRelationships().filter { $0.type == type }
guard relationships.count > 0 else { return false }
for item in self.items { /// Sends an insert api call for the provided
for relationship in relationships { /// Calls copyFromServerInstance on the instance with the result of the HTTP call
if item[keyPath: relationship.keyPath] as? String == id { /// - Parameters:
return true /// - instance: the object to POST
fileprivate func _sendInsertionIfNecessary(_ instance: T) {
guard self.synchronized else {
return
}
Task {
do {
if let result = try await self._store.sendInsertion(instance) {
DispatchQueue.main.async {
self._hasChanged = instance.copyFromServerInstance(result)
}
} }
} catch {
Logger.error(error)
} }
} }
return false
} }
// MARK: - for Synced Collection /// Sends an update api call for the provided [instance]
/// - Parameters:
@MainActor /// - instance: the object to PUT
func updateLocalInstance(_ serverInstance: T) { fileprivate func _sendUpdateIfNecessary(_ instance: T) {
if let localInstance = self.findById(serverInstance.id) { guard self.synchronized, self._sendsUpdate else {
localInstance.copy(from: serverInstance) return
self.requestWriteIfNecessary()
} }
Task {
do {
try await self._store.sendUpdate(instance)
} catch {
Logger.error(error)
} }
// MARK: - Cached queries
fileprivate var _cacheVersion = 0
fileprivate var _queryCache: [AnyHashable: (version: Int, result: Any)] = [:]
// Generic query method with caching
public func cached<Result>(
key: AnyHashable,
compute: ([T]) -> Result
) -> Result {
if let cached = self._queryCache[key],
cached.version == self._cacheVersion,
let result = cached.result as? Result {
return result
} }
let result = compute(items)
self._queryCache[key] = (self._cacheVersion, result)
return result
} }
private func invalidateCache() { /// 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 {
do {
try await self._store.sendDeletion(instance)
} catch {
Logger.error(error)
} }
} }
extension StoredCollection: RandomAccessCollection {
public static func placeholder() -> StoredCollection<T> {
return StoredCollection<T>(store: Store(storeCenter: StoreCenter.main))
} }
// MARK: - RandomAccessCollection
public var startIndex: Int { return self.items.startIndex } public var startIndex: Int { return self.items.startIndex }
public var endIndex: Int { return self.items.endIndex } public var endIndex: Int { return self.items.endIndex }
@ -617,13 +480,13 @@ extension StoredCollection: RandomAccessCollection {
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,44 +7,23 @@
import Foundation import Foundation
public class JSON { fileprivate var jsonEncoder: JSONEncoder = {
public static var encoder: JSONEncoder = {
let encoder = JSONEncoder() let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase encoder.keyEncodingStrategy = .convertToSnakeCase
#if DEBUG #if DEBUG
encoder.outputFormatting = .prettyPrinted encoder.outputFormatting = .prettyPrinted
#endif #endif
encoder.dateEncodingStrategy = .custom { date, encoder in encoder.dateEncodingStrategy = .iso8601
let dateString = Date.iso8601FractionalFormatter.string(from: date)
var container = encoder.singleValueContainer()
try container.encode(dateString)
} // need dates with thousandth precision
return encoder 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()
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 return decoder
}() }()
}
extension Encodable { extension Encodable {
public func jsonString() throws -> String { public func jsonString() throws -> String {
@ -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,6 @@ public class ErrorUtils {
} }
public enum ServiceError: Error, LocalizedError { public enum UUIDError: Error {
case urlCreationError(url: String)
case cantConvertToUUID(id: String)
case missingUserName
case responseError(response: String)
case cantDecodeData(resource: String, method: String, content: String?)
public var errorDescription: String? {
switch self {
case .urlCreationError(let url):
return "Can't create URL from \(url)"
case .cantConvertToUUID(let id):
return "Cant convert \(id) to UUID"
case .missingUserName:
return "There is no userName defined in the Settings"
case .responseError(let response):
return "The server returned an error: \(response)"
case .cantDecodeData(let resource, let method, let content):
return "cannot decode data from \(resource), method: \(method): \(content ?? "")"
}
}
}
public enum UUIDError: Error, LocalizedError {
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 {
func add(username: String, value: String) throws
func add(value: String) throws
func getValue() throws -> String
func deleteValue() throws
} }
class KeychainStore: KeychainService { class KeychainStore {
let serverId: String let serverId: String

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

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

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

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

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

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

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

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

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

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

@ -16,7 +16,7 @@ You can store collections inside separate folders by creating other stores:
# Sync # 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