Compare commits

...

171 Commits
sync ... main

Author SHA1 Message Date
Laurent a10f8c78c4 add cache system 4 days ago
Laurent 17148d498d improvements 5 days ago
Laurent d019e491af improvements and cleanup 5 days ago
Laurent 601c38654f create delete log collection 2 weeks ago
Laurent 4afdfb0334 Fix issues with failing file loading 2 weeks ago
Laurent ffda88145f improvement 3 weeks ago
Laurent 75fbe7d7a1 Adds websocket infos 3 weeks ago
Laurent 360fd647eb Minor fix for collection loading 1 month ago
Laurent 7d7ed5b714 logs 1 month ago
Laurent 6f4dc9d5f5 fixes issues where the scheduled timer failed 1 month ago
Laurent c8131d3009 Adds CLAUDE.md file 1 month ago
Laurent 866578675f adds log when sync fails 1 month ago
Laurent 4c3cba20bd fix issue with inMemory parameter not being used 1 month ago
Laurent ffe9794f28 Fixes issue with failing syncs 1 month ago
Laurent 6d3ed27151 fix service uss 1 month ago
Laurent 92516f135f fix 1 month ago
Laurent f738092e43 adds a way for other project to call services on the api 2 months ago
Razmig Sarkissian a972f48885 Merge remote-tracking branch 'refs/remotes/origin/main' 3 months ago
Razmig Sarkissian 94ae4ae654 update 2026 rules 3 months ago
Laurent 678236c7fa add a protection from sync 4 months ago
Laurent 7cd7a5b908 remove log 4 months ago
Laurent 572a2e9bec Improves syncing 5 months ago
Laurent 1f73660832 fixes and improvements 5 months ago
Laurent 47e0ea17aa Put deleteNoSync as public 5 months ago
Laurent 007f7d6bf1 Avoid doing too many writes when synchronizing 5 months ago
Laurent 5910bfffd4 fix data getting pending when unnecessary 5 months ago
Laurent 98f95eb73f fix unused data deletion issue 5 months ago
Laurent c6804b3e81 optimize reference checking 5 months ago
Laurent 14252dfce0 fix issue with reference computing 5 months ago
Laurent eb5577a366 change pending storage directory 5 months ago
Laurent a774d1dd74 logs update 5 months ago
Laurent 978b7ed0c3 fix pending operation cleanup 5 months ago
Laurent 42da48d31b fix regression 5 months ago
Laurent dbd50eced0 Adds StoreLibrary to manage stores + fix init bug 5 months ago
Laurent a3ec0820e4 Refactor reference counting 5 months ago
Laurent 0a83bddaf6 Rename shared property of Synced objects 6 months ago
Laurent 7e9de23986 minor changes 6 months ago
Laurent ec1f6825c0 Adds storeParent to manage substorage directories 6 months ago
Laurent 96b6c657e3 Add storeParent logic to manage directories when data is added/removed + sharingStatus refactoring to handle shared/granted states 6 months ago
Laurent 27bb855c8d Fixes issues 6 months ago
Laurent 9efc8b14c8 Refactor SyncedCollection to own StoredCollection instead of inheriting it 6 months ago
Laurent 28687133f6 cleanup and minor refactoring 6 months ago
Laurent 307180a88b Manage data sharing cleanups 6 months ago
Laurent d70c649fc2 cleanup 6 months ago
Laurent 7ce71a4921 Fix bad issue with dependency deletion 6 months ago
Laurent 1f78cc7be4 improves delete dependencies system 6 months ago
Laurent 775bed665b Fixes and improvements 6 months ago
Laurent fb6999a66c Sync improvements 6 months ago
Laurent a3680f14bd testing changes 6 months ago
Laurent 9c5ddf30fa Fix testing issues 6 months ago
Laurent 002cec1634 remove inappropriate logs 6 months ago
Laurent e55f183053 Refactor and cleanup for tests 6 months ago
Laurent 369c71ba4e Add async init for collections 6 months ago
Laurent 7aaaadcf2b settings update 6 months ago
Laurent 600fca4fdc fix collection loading 6 months ago
Laurent fd413ff86a merge main 6 months ago
Laurent 711827ba80 compilation requirements 6 months ago
Laurent 0145072771 De-singletonize StoreCenter and enable testing for multiple instances 6 months ago
Raz 28239c1110 fix memory leak 7 months ago
Laurent c8f204462a Refactoring to pass a reference of StoreCenter in the various classes 7 months ago
Laurent b32b0f2a74 add asynchronous function for testing purpose 7 months ago
Laurent f32bc866f5 Sync fixes 7 months ago
Raz 16e38f79fb fix online payment stuff 7 months ago
Laurent 5570309d6e adds missing write on tryPutBeforeUpdating 7 months ago
Laurent 4686a42a80 Fix issue with tryPutBeforeUpdating 7 months ago
Laurent a8b79cc402 Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/LeStorage 7 months ago
Laurent 1ed6a9a295 Fix tryPutBeforeUpdating to graciously fail 7 months ago
Raz 044900fc1d add refund system 7 months ago
Laurent e4dac9ff43 Fixes issue with copyServerResponse 7 months ago
Laurent 787d0d7366 adds tryPutBeforeUpdating method 7 months ago
Laurent c5f5c67737 add app version to sync service 7 months ago
Laurent e8dd101af1 Fix issue with additive loading and inMemory collections 7 months ago
Laurent 6424d1146f make some methods public 7 months ago
Laurent cf4d26370a fix tests 7 months ago
Laurent af5c677ded Adds direct post/call calls before adding to the collection 7 months ago
Laurent 51163af3a3 Merge branch 'main' of https://gitea.staxriver.com/staxriver/LeStorage 7 months ago
Laurent 76e5491cda Fix lots of crap 7 months ago
Laurent 27a403c99b Make collection loading asynchronous + manage state when collection are not loaded + separate StoredCollection from synced collection 7 months ago
Laurent a5a2ede299 added comment 7 months ago
Laurent 1d416ebdd6 Fix decoding migration 7 months ago
Laurent eab91fcd8c fixes 8 months ago
Laurent c5168f1ace Improve rescheduling 8 months ago
Laurent ba3bacb906 sends the device model to the server 8 months ago
Laurent 26efbdd27d Put the keychainStore inside StoreCenter + authentication refactoring 8 months ago
Laurent 6748c825df replace hasToken by a more robust isAuthenticated 8 months ago
Laurent 6d8ce05dae Fix nasty bug ! 8 months ago
Laurent b151fe8674 disconnect previous websocket before creating new one 8 months ago
Laurent 7c02b6036f cleanup rescheduling 8 months ago
Laurent 633d50a616 improve waiting times 8 months ago
Laurent 760a3135ad fix bugs 8 months ago
Laurent cca9812b86 Improve loadDataFromServer to provision the GET request 8 months ago
Laurent 4c4cc246b9 merge main 8 months ago
Laurent 5a9b2db57e Fix broken overriding 8 months ago
Laurent fc05ee25ed Fix sync issues when login in 8 months ago
Laurent 948f1f3444 Allow ApiCall execute from external projects 8 months ago
Laurent 89582de30c expose APICall for external use 8 months ago
Laurent 4765bc4e89 Adds storeId method on StoredCollection 8 months ago
Laurent 7125d868ae merge main 8 months ago
Laurent ea0655de37 Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/LeStorage 8 months ago
Laurent 1ed8286213 Fix api call provisioning 8 months ago
Raz 541aa5e297 fix loadDataFromServerIfAllowed 8 months ago
Laurent 34055f3333 Fixes an issue where dependencies deletes were not being synced 9 months ago
Laurent 3cf90172d4 Reduce log collection size from 1000 to 50 9 months ago
Laurent 61db14f003 Merge main 9 months ago
Laurent 407640b35c add more resilience for calls 9 months ago
Laurent 389f5b851e Fix issue with call creation 9 months ago
Laurent 2b26950d67 Fix api call creation 9 months ago
Laurent be67b229e0 Adds flexible json date decoder + adds app version in http header 9 months ago
Laurent c12a041e70 upgrade apicall + test conveniences + fix 9 months ago
Laurent dc885d4a72 merge main followup 9 months ago
Laurent 70cb758dba merge with main 9 months ago
Laurent 150543f98e StoreCenter exposes the lastSyncDate for debug purposes 9 months ago
Laurent 1d333e6f03 Makes the call creation strategy more resilient 9 months ago
Laurent a638947ea6 Fixes sync issues 9 months ago
Laurent 339df068a8 attempt to fix issue where race condition can happen 9 months ago
Laurent a3f6ad618e Minor improvements 9 months ago
Laurent d1091b926e Call scheduling improvement 9 months ago
Laurent 190528e964 improvements 9 months ago
Laurent 64130849e8 Fix issue with items being copied for no reason 10 months ago
Laurent ae1aa39c5d Fix sync crash 10 months ago
Laurent d41bb138dd Dependency delete now sends delete requests 10 months ago
Laurent 5b63a40851 Various fixes and improvements 10 months ago
Laurent b993d920a0 merge main 10 months ago
Laurent 529fefaef4 change method position 10 months ago
Laurent 5a112f8c44 refactor the server into local copy system + doc 10 months ago
Laurent 97d8405732 Improve sync response 10 months ago
Laurent b8077f231c Batches api calls by transactionId 10 months ago
Laurent bbf7e8fa8b adds method to clear before loading 11 months ago
Laurent 4423d3f52a simplify call 11 months ago
Laurent 1e47f2009f Adds documentation 11 months ago
Laurent 8b6770f9d2 Improvements 11 months ago
Laurent c51b6e1906 Fix missing method 11 months ago
Laurent f4b135c281 merge main 11 months ago
Laurent 3368cbae01 Fix minor issues 11 months ago
Laurent 01dbb8b1b6 Adds deviceId to sync POST request 11 months ago
Laurent 4542e42985 Adds relatedUser to SyncedModel and other changes 11 months ago
Laurent a03ee52c70 Fix code for logging reset 11 months ago
Laurent fb9d60ffd2 remove logs 11 months ago
Laurent cef665f166 cleanup and add user names services 11 months ago
Laurent 4c5105f4c3 Stop syncing log collection 11 months ago
Laurent bd7ec4dcc9 Improvements to handle user search and data access retrieval 11 months ago
Laurent 5b86728d77 Improve data hierarchy 11 months ago
Laurent b068f3a57b Fix crash 11 months ago
Laurent c32c01568b Adds limits for logs and failed api call + method to reset them 11 months ago
Laurent d3431c0c55 add logs 12 months ago
Laurent 30afabffa8 work on revocation 12 months ago
Laurent c3c9718cb2 Improvements 12 months ago
Laurent 2cf2bf9c61 Fixes and improvements 12 months ago
Laurent f6418a725a Fix websocket reconnection 12 months ago
Laurent e625e39eb2 Add websockets 12 months ago
Laurent 23a34838e5 Merge main 12 months ago
Laurent b9a5e7c482 Added error message to Error enums 12 months ago
Laurent 1ef5725029 Improve logging 1 year ago
Laurent 2977306330 Fix issue 1 year ago
Laurent 8b7c39c0de Removed the StoreIdentifier concept to just use a String and a default parameter named store_id to send to the server 1 year ago
Laurent 7059cc913b cleanup 1 year ago
Laurent 12587aabdb fixes for data access 1 year ago
Laurent 214420f98a add logs 1 year ago
Laurent 7a21f27550 Add / update / delete sync obtained! 1 year ago
Laurent b5b32892dc Adds network monitor to resume api calls 1 year ago
Laurent f926a1fcbe fix issue where updated items needs to copy properties instead of the instance being replaced 1 year ago
Laurent 56a2f6e618 first commit 1 year ago
Laurent aa78348e98 Fixes coding issues 1 year ago
Laurent cfd2ccb9fc Adds a hasBeenDeleted method on Storable + doc + cleanup 1 year ago
Laurent 67f07cfb6f Improve request execution to handle various return parameters + fixes 1 year ago
Laurent 30306d2d50 Adds creationDate on ApiCall 1 year ago
Laurent e02b12e8e2 Fix nasty bug executing api calls forever 1 year ago
Laurent e8f2b21563 Cleanup failed api call when disconnecting 1 year ago
Laurent 2567df5dd2 Fix potential crash 1 year ago
Raz 27dd034a6b LeStorage blue print identifier 1 year ago
Laurent 65b2f75160 Accept storable ids to not only be strings 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. 717
      LeStorage/Services.swift
  21. 67
      LeStorage/Storable.swift
  22. 483
      LeStorage/Store.swift
  23. 1066
      LeStorage/StoreCenter.swift
  24. 64
      LeStorage/StoreLibrary.swift
  25. 669
      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. 41
      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. 55
      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,17 +3,26 @@
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objectVersion = 70;
objects = {
/* Begin PBXBuildFile section */
C400D7232CC2AF560092237C /* GetSyncData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C400D7222CC2AF560092237C /* GetSyncData.swift */; };
C40EC3E52D9BDFA3007372D7 /* PendingOperationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40EC3E42D9BDFA3007372D7 /* PendingOperationManager.swift */; };
C425D4392B6D24E1002A7B48 /* LeStorage.docc in Sources */ = {isa = PBXBuildFile; fileRef = C425D4382B6D24E1002A7B48 /* LeStorage.docc */; };
C425D43F2B6D24E1002A7B48 /* LeStorage.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C425D4342B6D24E1002A7B48 /* LeStorage.framework */; };
C425D4442B6D24E1002A7B48 /* LeStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C425D4432B6D24E1002A7B48 /* LeStorageTests.swift */; };
C425D4452B6D24E1002A7B48 /* LeStorage.h in Headers */ = {isa = PBXBuildFile; fileRef = C425D4372B6D24E1002A7B48 /* LeStorage.h */; settings = {ATTRIBUTES = (Public, ); }; };
C425D4582B6D2519002A7B48 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = C425D4572B6D2519002A7B48 /* Store.swift */; };
C4339BFF2CFF86B3004E5F09 /* Dictionary+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4339BFE2CFF86B3004E5F09 /* Dictionary+Extensions.swift */; };
C456EFE22BE52379007388E2 /* StoredSingleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C456EFE12BE52379007388E2 /* StoredSingleton.swift */; };
C45D35912C0A1DB5000F379F /* FailedAPICall.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45D35902C0A1DB5000F379F /* FailedAPICall.swift */; };
C462E0DC2D37B61100F3E6E4 /* Notification+Name.swift in Sources */ = {isa = PBXBuildFile; fileRef = C462E0DB2D37B61100F3E6E4 /* Notification+Name.swift */; };
C467AAE32CD2467500D76CD2 /* Formatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C467AAE22CD2466400D76CD2 /* Formatter.swift */; };
C471F2582DB10649006317F4 /* MockKeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C471F2572DB10649006317F4 /* MockKeychainStore.swift */; };
C48638B32D9BC6A8007E3E06 /* PendingOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C48638B22D9BC6A8007E3E06 /* PendingOperation.swift */; };
C488C8802CCBDC210082001F /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C488C87F2CCBDC210082001F /* NetworkMonitor.swift */; };
C49774DF2DC4B3D7005CD239 /* SyncData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49774DE2DC4B3D7005CD239 /* SyncData.swift */; };
C49779FC2DDB5D89005CD239 /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49779FB2DDB5D89005CD239 /* String+Extensions.swift */; };
C4977BA92DEDFE6D005CD239 /* StoreLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4977BA82DEDFE6D005CD239 /* StoreLibrary.swift */; };
C49B6E502C2089B6002BDE1B /* ApiCallCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */; };
C49EF0242BD6BDC50077B5AA /* FileManager+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */; };
C4A47D4F2B6D280200ADC637 /* StoredCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */; };
@ -31,12 +40,24 @@
C4A47D9B2B7CFFDA00ADC637 /* ApiCall.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D992B7CFFC500ADC637 /* ApiCall.swift */; };
C4A47D9C2B7CFFE000ADC637 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D9A2B7CFFC500ADC637 /* Settings.swift */; };
C4A47DAF2B85FD3800ADC637 /* Errors.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47DAE2B85FD3800ADC637 /* Errors.swift */; };
C4AC9CE52CEFB12100CC13DF /* DataAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4AC9CE42CEFB12100CC13DF /* DataAccess.swift */; };
C4AC9CE82CF0A13B00CC13DF /* ClassLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4AC9CE72CF0A13B00CC13DF /* ClassLoader.swift */; };
C4AC9CEA2CF754D200CC13DF /* Relationship.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4AC9CE92CF754CC00CC13DF /* Relationship.swift */; };
C4B96E1D2D8C53D700C2955F /* UIDevice+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4B96E1C2D8C53D700C2955F /* UIDevice+Extensions.swift */; };
C4C33F6F2C9B06B7006316DE /* LeStorage.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C425D4342B6D24E1002A7B48 /* LeStorage.framework */; };
C4D477972CB66EEA0077713D /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D477962CB66EEA0077713D /* Date+Extensions.swift */; };
C4D4779D2CB923720077713D /* DataLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D4779C2CB923720077713D /* DataLog.swift */; };
C4D4779F2CB92FD80077713D /* SyncedStorable.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D4779E2CB92FD80077713D /* SyncedStorable.swift */; };
C4D477A12CB9586A0077713D /* SyncedCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D477A02CB9586A0077713D /* SyncedCollection.swift */; };
C4E2A7D12E8D6A4D007E5186 /* CLAUDE.md in Resources */ = {isa = PBXBuildFile; fileRef = C4E2A7D02E8D6A48007E5186 /* CLAUDE.md */; };
C4FAE69A2CEB84B300790446 /* WebSocketManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FAE6992CEB84B300790446 /* WebSocketManager.swift */; };
C4FAE69C2CEB8E9500790446 /* URLManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FAE69B2CEB8E9500790446 /* URLManager.swift */; };
C4FC2E292C2B2EC30021F3BF /* StoreCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FC2E282C2B2EC30021F3BF /* StoreCenter.swift */; };
C4FC2E312C353E7B0021F3BF /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FC2E302C353E7B0021F3BF /* Log.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
C425D4402B6D24E1002A7B48 /* PBXContainerItemProxy */ = {
C4C33F702C9B06B7006316DE /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = C425D42B2B6D24E1002A7B48 /* Project object */;
proxyType = 1;
@ -46,14 +67,23 @@
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
C400D7222CC2AF560092237C /* GetSyncData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetSyncData.swift; sourceTree = "<group>"; };
C40EC3E42D9BDFA3007372D7 /* PendingOperationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingOperationManager.swift; sourceTree = "<group>"; };
C425D4342B6D24E1002A7B48 /* LeStorage.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = LeStorage.framework; sourceTree = BUILT_PRODUCTS_DIR; };
C425D4372B6D24E1002A7B48 /* LeStorage.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = LeStorage.h; sourceTree = "<group>"; };
C425D4382B6D24E1002A7B48 /* LeStorage.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = LeStorage.docc; sourceTree = "<group>"; };
C425D43E2B6D24E1002A7B48 /* LeStorageTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LeStorageTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
C425D4432B6D24E1002A7B48 /* LeStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeStorageTests.swift; sourceTree = "<group>"; };
C425D4572B6D2519002A7B48 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = "<group>"; };
C4339BFE2CFF86B3004E5F09 /* Dictionary+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dictionary+Extensions.swift"; sourceTree = "<group>"; };
C456EFE12BE52379007388E2 /* StoredSingleton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSingleton.swift; sourceTree = "<group>"; };
C45D35902C0A1DB5000F379F /* FailedAPICall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedAPICall.swift; sourceTree = "<group>"; };
C462E0DB2D37B61100F3E6E4 /* Notification+Name.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Name.swift"; sourceTree = "<group>"; };
C467AAE22CD2466400D76CD2 /* Formatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formatter.swift; sourceTree = "<group>"; };
C471F2572DB10649006317F4 /* MockKeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockKeychainStore.swift; sourceTree = "<group>"; };
C48638B22D9BC6A8007E3E06 /* PendingOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingOperation.swift; sourceTree = "<group>"; };
C488C87F2CCBDC210082001F /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = "<group>"; };
C49774DE2DC4B3D7005CD239 /* SyncData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncData.swift; sourceTree = "<group>"; };
C49779FB2DDB5D89005CD239 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = "<group>"; };
C4977BA82DEDFE6D005CD239 /* StoreLibrary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreLibrary.swift; sourceTree = "<group>"; };
C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCallCollection.swift; sourceTree = "<group>"; };
C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Extensions.swift"; sourceTree = "<group>"; };
C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredCollection.swift; sourceTree = "<group>"; };
@ -71,10 +101,26 @@
C4A47D992B7CFFC500ADC637 /* ApiCall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCall.swift; sourceTree = "<group>"; };
C4A47D9A2B7CFFC500ADC637 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = "<group>"; };
C4A47DAE2B85FD3800ADC637 /* Errors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Errors.swift; sourceTree = "<group>"; };
C4AC9CE42CEFB12100CC13DF /* DataAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataAccess.swift; sourceTree = "<group>"; };
C4AC9CE72CF0A13B00CC13DF /* ClassLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassLoader.swift; sourceTree = "<group>"; };
C4AC9CE92CF754CC00CC13DF /* Relationship.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Relationship.swift; sourceTree = "<group>"; };
C4B96E1C2D8C53D700C2955F /* UIDevice+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIDevice+Extensions.swift"; sourceTree = "<group>"; };
C4C33F6B2C9B06B7006316DE /* LeStorageTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LeStorageTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
C4D477962CB66EEA0077713D /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = "<group>"; };
C4D4779C2CB923720077713D /* DataLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLog.swift; sourceTree = "<group>"; };
C4D4779E2CB92FD80077713D /* SyncedStorable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncedStorable.swift; sourceTree = "<group>"; };
C4D477A02CB9586A0077713D /* SyncedCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncedCollection.swift; sourceTree = "<group>"; };
C4E2A7D02E8D6A48007E5186 /* CLAUDE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CLAUDE.md; sourceTree = "<group>"; };
C4FAE6992CEB84B300790446 /* WebSocketManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocketManager.swift; sourceTree = "<group>"; };
C4FAE69B2CEB8E9500790446 /* URLManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLManager.swift; sourceTree = "<group>"; };
C4FC2E282C2B2EC30021F3BF /* StoreCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreCenter.swift; sourceTree = "<group>"; };
C4FC2E302C353E7B0021F3BF /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
C4C33F6C2C9B06B7006316DE /* LeStorageTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = LeStorageTests; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
C425D4312B6D24E1002A7B48 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
@ -83,11 +129,11 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
C425D43B2B6D24E1002A7B48 /* Frameworks */ = {
C4C33F682C9B06B7006316DE /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
C425D43F2B6D24E1002A7B48 /* LeStorage.framework in Frameworks */,
C4C33F6F2C9B06B7006316DE /* LeStorage.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -98,7 +144,7 @@
isa = PBXGroup;
children = (
C425D4362B6D24E1002A7B48 /* LeStorage */,
C425D4422B6D24E1002A7B48 /* LeStorageTests */,
C4C33F6C2C9B06B7006316DE /* LeStorageTests */,
C425D4352B6D24E1002A7B48 /* Products */,
);
sourceTree = "<group>";
@ -107,7 +153,7 @@
isa = PBXGroup;
children = (
C425D4342B6D24E1002A7B48 /* LeStorage.framework */,
C425D43E2B6D24E1002A7B48 /* LeStorageTests.xctest */,
C4C33F6B2C9B06B7006316DE /* LeStorageTests.xctest */,
);
name = Products;
sourceTree = "<group>";
@ -115,18 +161,27 @@
C425D4362B6D24E1002A7B48 /* LeStorage */ = {
isa = PBXGroup;
children = (
C4E2A7D02E8D6A48007E5186 /* CLAUDE.md */,
C4A47D6E2B7154F600ADC637 /* README.md */,
C425D4372B6D24E1002A7B48 /* LeStorage.h */,
C425D4382B6D24E1002A7B48 /* LeStorage.docc */,
C4A47D9D2B7CFFF500ADC637 /* Codables */,
C49B6E4F2C2089B6002BDE1B /* ApiCallCollection.swift */,
C4A47D6C2B71364600ADC637 /* ModelObject.swift */,
C488C87F2CCBDC210082001F /* NetworkMonitor.swift */,
C462E0DB2D37B61100F3E6E4 /* Notification+Name.swift */,
C40EC3E42D9BDFA3007372D7 /* PendingOperationManager.swift */,
C4AC9CE92CF754CC00CC13DF /* Relationship.swift */,
C4A47D602B6D3C1300ADC637 /* Services.swift */,
C425D4572B6D2519002A7B48 /* Store.swift */,
C4977BA82DEDFE6D005CD239 /* StoreLibrary.swift */,
C4FC2E282C2B2EC30021F3BF /* StoreCenter.swift */,
C4A47D642B6E92FE00ADC637 /* Storable.swift */,
C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */,
C4D477A02CB9586A0077713D /* SyncedCollection.swift */,
C456EFE12BE52379007388E2 /* StoredSingleton.swift */,
C4D4779E2CB92FD80077713D /* SyncedStorable.swift */,
C4FAE6992CEB84B300790446 /* WebSocketManager.swift */,
C4A47D932B7CF7C500ADC637 /* MicroStorage.swift */,
C4A47D822B7665BC00ADC637 /* Wip */,
C4A47D582B6D352900ADC637 /* Utils */,
@ -134,24 +189,24 @@
path = LeStorage;
sourceTree = "<group>";
};
C425D4422B6D24E1002A7B48 /* LeStorageTests */ = {
isa = PBXGroup;
children = (
C425D4432B6D24E1002A7B48 /* LeStorageTests.swift */,
);
path = LeStorageTests;
sourceTree = "<group>";
};
C4A47D582B6D352900ADC637 /* Utils */ = {
isa = PBXGroup;
children = (
C4AC9CE72CF0A13B00CC13DF /* ClassLoader.swift */,
C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */,
C4A47D6A2B71244100ADC637 /* Collection+Extension.swift */,
C4D477962CB66EEA0077713D /* Date+Extensions.swift */,
C4339BFE2CFF86B3004E5F09 /* Dictionary+Extensions.swift */,
C4A47DAE2B85FD3800ADC637 /* Errors.swift */,
C49EF0232BD6BDC50077B5AA /* FileManager+Extensions.swift */,
C4A47D542B6D2DBF00ADC637 /* FileUtils.swift */,
C467AAE22CD2466400D76CD2 /* Formatter.swift */,
C4A47D832B7B97F000ADC637 /* KeychainStore.swift */,
C471F2572DB10649006317F4 /* MockKeychainStore.swift */,
C4A47D522B6D2C5F00ADC637 /* Logger.swift */,
C4B96E1C2D8C53D700C2955F /* UIDevice+Extensions.swift */,
C4FAE69B2CEB8E9500790446 /* URLManager.swift */,
C49779FB2DDB5D89005CD239 /* String+Extensions.swift */,
);
path = Utils;
sourceTree = "<group>";
@ -168,9 +223,14 @@
isa = PBXGroup;
children = (
C4A47D992B7CFFC500ADC637 /* ApiCall.swift */,
C4D4779C2CB923720077713D /* DataLog.swift */,
C45D35902C0A1DB5000F379F /* FailedAPICall.swift */,
C4FC2E302C353E7B0021F3BF /* Log.swift */,
C4A47D9A2B7CFFC500ADC637 /* Settings.swift */,
C400D7222CC2AF560092237C /* GetSyncData.swift */,
C4AC9CE42CEFB12100CC13DF /* DataAccess.swift */,
C48638B22D9BC6A8007E3E06 /* PendingOperation.swift */,
C49774DE2DC4B3D7005CD239 /* SyncData.swift */,
);
path = Codables;
sourceTree = "<group>";
@ -207,22 +267,27 @@
productReference = C425D4342B6D24E1002A7B48 /* LeStorage.framework */;
productType = "com.apple.product-type.framework";
};
C425D43D2B6D24E1002A7B48 /* LeStorageTests */ = {
C4C33F6A2C9B06B7006316DE /* LeStorageTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = C425D44B2B6D24E1002A7B48 /* Build configuration list for PBXNativeTarget "LeStorageTests" */;
buildConfigurationList = C4C33F722C9B06B7006316DE /* Build configuration list for PBXNativeTarget "LeStorageTests" */;
buildPhases = (
C425D43A2B6D24E1002A7B48 /* Sources */,
C425D43B2B6D24E1002A7B48 /* Frameworks */,
C425D43C2B6D24E1002A7B48 /* Resources */,
C4C33F672C9B06B7006316DE /* Sources */,
C4C33F682C9B06B7006316DE /* Frameworks */,
C4C33F692C9B06B7006316DE /* Resources */,
);
buildRules = (
);
dependencies = (
C425D4412B6D24E1002A7B48 /* PBXTargetDependency */,
C4C33F712C9B06B7006316DE /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
C4C33F6C2C9B06B7006316DE /* LeStorageTests */,
);
name = LeStorageTests;
packageProductDependencies = (
);
productName = LeStorageTests;
productReference = C425D43E2B6D24E1002A7B48 /* LeStorageTests.xctest */;
productReference = C4C33F6B2C9B06B7006316DE /* LeStorageTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */
@ -232,14 +297,14 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1520;
LastUpgradeCheck = 1520;
LastSwiftUpdateCheck = 1600;
LastUpgradeCheck = 1630;
TargetAttributes = {
C425D4332B6D24E1002A7B48 = {
CreatedOnToolsVersion = 15.2;
};
C425D43D2B6D24E1002A7B48 = {
CreatedOnToolsVersion = 15.2;
C4C33F6A2C9B06B7006316DE = {
CreatedOnToolsVersion = 16.0;
};
};
};
@ -257,7 +322,7 @@
projectRoot = "";
targets = (
C425D4332B6D24E1002A7B48 /* LeStorage */,
C425D43D2B6D24E1002A7B48 /* LeStorageTests */,
C4C33F6A2C9B06B7006316DE /* LeStorageTests */,
);
};
/* End PBXProject section */
@ -267,11 +332,12 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
C4E2A7D12E8D6A4D007E5186 /* CLAUDE.md in Resources */,
C4A47D6F2B7154F600ADC637 /* README.md in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
C425D43C2B6D24E1002A7B48 /* Resources */ = {
C4C33F692C9B06B7006316DE /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
@ -288,43 +354,63 @@
C4A47D532B6D2C5F00ADC637 /* Logger.swift in Sources */,
C4A47D842B7B97F000ADC637 /* KeychainStore.swift in Sources */,
C4FC2E312C353E7B0021F3BF /* Log.swift in Sources */,
C4D477A12CB9586A0077713D /* SyncedCollection.swift in Sources */,
C4A47D512B6D2C4E00ADC637 /* Codable+Extensions.swift in Sources */,
C40EC3E52D9BDFA3007372D7 /* PendingOperationManager.swift in Sources */,
C4AC9CE52CEFB12100CC13DF /* DataAccess.swift in Sources */,
C4FAE69A2CEB84B300790446 /* WebSocketManager.swift in Sources */,
C4D4779F2CB92FD80077713D /* SyncedStorable.swift in Sources */,
C425D4392B6D24E1002A7B48 /* LeStorage.docc in Sources */,
C4AC9CE82CF0A13B00CC13DF /* ClassLoader.swift in Sources */,
C4A47DAF2B85FD3800ADC637 /* Errors.swift in Sources */,
C4AC9CEA2CF754D200CC13DF /* Relationship.swift in Sources */,
C4A47D612B6D3C1300ADC637 /* Services.swift in Sources */,
C4A47D552B6D2DBF00ADC637 /* FileUtils.swift in Sources */,
C456EFE22BE52379007388E2 /* StoredSingleton.swift in Sources */,
C467AAE32CD2467500D76CD2 /* Formatter.swift in Sources */,
C4A47D652B6E92FE00ADC637 /* Storable.swift in Sources */,
C4339BFF2CFF86B3004E5F09 /* Dictionary+Extensions.swift in Sources */,
C4D477972CB66EEA0077713D /* Date+Extensions.swift in Sources */,
C488C8802CCBDC210082001F /* NetworkMonitor.swift in Sources */,
C4A47D6D2B71364600ADC637 /* ModelObject.swift in Sources */,
C4977BA92DEDFE6D005CD239 /* StoreLibrary.swift in Sources */,
C49779FC2DDB5D89005CD239 /* String+Extensions.swift in Sources */,
C400D7232CC2AF560092237C /* GetSyncData.swift in Sources */,
C4A47D4F2B6D280200ADC637 /* StoredCollection.swift in Sources */,
C4A47D9C2B7CFFE000ADC637 /* Settings.swift in Sources */,
C49774DF2DC4B3D7005CD239 /* SyncData.swift in Sources */,
C4FC2E292C2B2EC30021F3BF /* StoreCenter.swift in Sources */,
C462E0DC2D37B61100F3E6E4 /* Notification+Name.swift in Sources */,
C4A47D812B7665AD00ADC637 /* Migration.swift in Sources */,
C4A47D9B2B7CFFDA00ADC637 /* ApiCall.swift in Sources */,
C4B96E1D2D8C53D700C2955F /* UIDevice+Extensions.swift in Sources */,
C4A47D942B7CF7C500ADC637 /* MicroStorage.swift in Sources */,
C4FAE69C2CEB8E9500790446 /* URLManager.swift in Sources */,
C49EF0242BD6BDC50077B5AA /* FileManager+Extensions.swift in Sources */,
C425D4582B6D2519002A7B48 /* Store.swift in Sources */,
C48638B32D9BC6A8007E3E06 /* PendingOperation.swift in Sources */,
C4D4779D2CB923720077713D /* DataLog.swift in Sources */,
C45D35912C0A1DB5000F379F /* FailedAPICall.swift in Sources */,
C471F2582DB10649006317F4 /* MockKeychainStore.swift in Sources */,
C49B6E502C2089B6002BDE1B /* ApiCallCollection.swift in Sources */,
C4A47D6B2B71244100ADC637 /* Collection+Extension.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
C425D43A2B6D24E1002A7B48 /* Sources */ = {
C4C33F672C9B06B7006316DE /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
C425D4442B6D24E1002A7B48 /* LeStorageTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
C425D4412B6D24E1002A7B48 /* PBXTargetDependency */ = {
C4C33F712C9B06B7006316DE /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = C425D4332B6D24E1002A7B48 /* LeStorage */;
targetProxy = C425D4402B6D24E1002A7B48 /* PBXContainerItemProxy */;
targetProxy = C4C33F702C9B06B7006316DE /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
@ -365,6 +451,7 @@
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 526E96RFNP;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@ -431,6 +518,7 @@
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 526E96RFNP;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@ -457,10 +545,11 @@
C425D4492B6D24E1002A7B48 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUILD_LIBRARY_FOR_DISTRIBUTION = YES;
CODE_SIGN_IDENTITY = "";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 526E96RFNP;
DYLIB_COMPATIBILITY_VERSION = 1;
DYLIB_CURRENT_VERSION = 1;
DYLIB_INSTALL_NAME_BASE = "@rpath";
@ -489,10 +578,11 @@
C425D44A2B6D24E1002A7B48 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUILD_LIBRARY_FOR_DISTRIBUTION = YES;
CODE_SIGN_IDENTITY = "";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 526E96RFNP;
DYLIB_COMPATIBILITY_VERSION = 1;
DYLIB_CURRENT_VERSION = 1;
DYLIB_INSTALL_NAME_BASE = "@rpath";
@ -518,14 +608,13 @@
};
name = Release;
};
C425D44C2B6D24E1002A7B48 /* Debug */ = {
C4C33F732C9B06B7006316DE /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 526E96RFNP;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.staxriver.LeStorageTests;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -535,14 +624,13 @@
};
name = Debug;
};
C425D44D2B6D24E1002A7B48 /* Release */ = {
C4C33F742C9B06B7006316DE /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 526E96RFNP;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.staxriver.LeStorageTests;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -573,11 +661,11 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
C425D44B2B6D24E1002A7B48 /* Build configuration list for PBXNativeTarget "LeStorageTests" */ = {
C4C33F722C9B06B7006316DE /* Build configuration list for PBXNativeTarget "LeStorageTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
C425D44C2B6D24E1002A7B48 /* Debug */,
C425D44D2B6D24E1002A7B48 /* Release */,
C4C33F732C9B06B7006316DE /* Debug */,
C4C33F742C9B06B7006316DE /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;

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

@ -0,0 +1,55 @@
<?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 @@
//
// SafeCollection.swift
// ApiCallCollection.swift
// LeStorage
//
// Created by Laurent Morvillier on 17/06/2024.
@ -7,7 +7,6 @@
import Foundation
protocol SomeCallCollection {
func findCallById(_ id: String) async -> (any SomeCall)?
@ -17,13 +16,30 @@ protocol SomeCallCollection {
func contentOfFile() async -> String?
func reset() async
func resumeApiCalls() async
func type() async -> any Storable.Type
func resourceName() async -> String
}
enum ApiCallError: Error, LocalizedError {
case encodingError(id: String, type: String)
var errorDescription: String? {
switch self {
case .encodingError(let id, let type):
return "Can't encode instance \(type) with id: \(id)"
}
}
}
/// ApiCallCollection is an object communicating with a server to synchronize data managed locally
/// The Api calls are serialized and stored in a JSON file
/// Failing Api calls are stored forever and will be executed again later
actor ApiCallCollection<T: Storable>: SomeCallCollection {
actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
fileprivate var storeCenter: StoreCenter
/// The list of api calls
fileprivate(set) var items: [ApiCall<T>] = []
@ -32,10 +48,9 @@ actor ApiCallCollection<T: Storable>: SomeCallCollection {
fileprivate var _attemptLoops: Int = 0
/// Indicates if the collection is currently retrying ApiCalls
fileprivate var _isRetryingCalls: Bool = false
fileprivate var _isExecutingCalls: Bool = false
/// The task of waiting and executing ApiCalls
fileprivate var _reschedulingTask: Task<Void, any Error>? = nil
fileprivate var _schedulingTask: Task<(), Never>? = nil
/// Indicates whether the collection content has changed
/// Initiates a write when true
@ -48,16 +63,19 @@ actor ApiCallCollection<T: Storable>: SomeCallCollection {
}
}
init(storeCenter: StoreCenter) {
self.storeCenter = storeCenter
}
/// Starts the JSON file decoding synchronously or asynchronously
/// Reschedule Api calls if not empty
func loadFromFile() throws {
try self._decodeJSONFile()
self.rescheduleApiCallsIfNecessary()
}
/// Returns the file URL of the collection
fileprivate func _urlForJSONFile() throws -> URL {
return try ApiCall<T>.urlForJSONFile()
return try self.storeCenter.jsonFileURL(for: ApiCall<T>.self)
}
/// Decodes the json file into the items array
@ -66,9 +84,13 @@ actor ApiCallCollection<T: Storable>: SomeCallCollection {
if FileManager.default.fileExists(atPath: fileURL.path()) {
let jsonString: String = try FileUtils.readFile(fileURL: fileURL)
do {
let decoded: [ApiCall<T>] = try jsonString.decodeArray() ?? []
// Logger.log("loaded \(fileURL.lastPathComponent) with \(decoded.count) items")
self.items = decoded
} catch {
let decoded: [OldApiCall<T>] = try jsonString.decodeArray() ?? []
self.items = decoded.compactMap { $0.toNewApiCall() }
}
}
}
@ -76,14 +98,12 @@ actor ApiCallCollection<T: Storable>: SomeCallCollection {
fileprivate func _write() {
let fileName = ApiCall<T>.fileName()
DispatchQueue(label: "lestorage.queue.write", qos: .utility).asyncAndWait {
// Logger.log("Start write to \(fileName)...")
do {
let jsonString: String = try self.items.jsonString()
try T.writeToStorageDirectory(content: jsonString, fileName: fileName)
try self.storeCenter.write(content: jsonString, fileName: fileName)
} catch {
Logger.error(error)
}
// Logger.log("End write")
}
}
@ -100,12 +120,13 @@ actor ApiCallCollection<T: Storable>: SomeCallCollection {
/// Deletes an API call by [id]
func deleteById(_ id: String) {
self.items.removeAll(where: { $0.id == id })
// Logger.log("\(T.resourceName()) > Delete by id, count after deletion = \(self.items.count)")
self._hasChanged = true
}
/// Deletes a call by a data id
func deleteByDataId(_ dataId: String) {
if let apiCallIndex = self.items.firstIndex(where: { $0.dataId == dataId }) {
if let apiCallIndex = self.items.firstIndex(where: { $0.data?.stringId == dataId }) {
self.items.remove(at: apiCallIndex)
self._hasChanged = true
}
@ -123,8 +144,10 @@ actor ApiCallCollection<T: Storable>: SomeCallCollection {
/// Removes all objects in memory and deletes the JSON file
func reset() {
self._reschedulingTask?.cancel()
self._isExecutingCalls = false
self._schedulingTask?.cancel()
self.items.removeAll()
self._hasChanged = true
do {
let url: URL = try self._urlForJSONFile()
@ -136,138 +159,279 @@ actor ApiCallCollection<T: Storable>: SomeCallCollection {
}
}
/// Reschedule the execution of API calls
fileprivate func _rescheduleApiCalls() {
func resumeApiCalls() {
self._attemptLoops = -1
self.rescheduleApiCallsIfNecessary()
guard self.items.isNotEmpty else {
return
if self._schedulingTask != nil && self._attemptLoops > 2 {
self._schedulingTask?.cancel()
self._attemptLoops = -1
self.rescheduleApiCallsIfNecessary()
}
}
self._isRetryingCalls = true
self._attemptLoops += 1
/// 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()
}
}
}
self._reschedulingTask = Task {
/// Reschedule the execution of API calls
fileprivate func _waitAndExecuteApiCalls() async {
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))
guard !self._isExecutingCalls, self.storeCenter.forceNoSynchronization == false else { return }
guard self.items.isNotEmpty else { return }
let apiCallsCopy = self.items
for apiCall in apiCallsCopy {
apiCall.attemptsCount += 1
apiCall.lastAttemptDate = Date()
self._isExecutingCalls = true
self._attemptLoops += 1
await self._wait()
await self._batchExecution()
// Logger.log("\(T.resourceName()) > EXECUTE CALLS: \(self.items.count)")
// let batches = Dictionary(grouping: self.items, by: { $0.transactionId })
//
// for batch in batches.values {
// do {
// if batch.count == 1, let apiCall = batch.first, apiCall.method == .get {
// try await self._executeGetCall(apiCall: apiCall)
// } else {
// let results = try await self._executeApiCalls(batch)
// if T.copyServerResponse {
// let instances = results.compactMap { $0.data }
// StoreCenter.main.updateLocalInstances(instances)
// }
// }
// } catch {
// Logger.error(error)
// }
// }
// Logger.log("\(T.resourceName()) > EXECUTE CALLS ENDED !")
self._isExecutingCalls = false
if self.items.isNotEmpty {
await self._waitAndExecuteApiCalls()
}
// Logger.log("\(T.resourceName()) > isRescheduling = \(self._isRescheduling)")
}
fileprivate func _batchExecution() async {
let batches = Dictionary(grouping: self.items, by: { $0.transactionId })
for batch in batches.values {
do {
let _ = try await self._executeApiCall(apiCall)
if batch.count == 1, let apiCall = batch.first, apiCall.method == .get {
try await self._executeGetCall(apiCall: apiCall)
} else {
let results: [OperationResult<T>] = try await self._executeApiCalls(batch)
if T.copyServerResponse {
let instances: [T] = results.compactMap { $0.data }
self.storeCenter.updateLocalInstances(instances)
}
}
} catch {
Logger.error(error)
}
}
}
self._hasChanged = true
@discardableResult func _executeGetCall(apiCall: ApiCall<T>) async throws -> Data {
if self.items.isEmpty {
self._isRetryingCalls = false
let data = try await self.storeCenter.executeGet(apiCall: apiCall)
// Logger.log("GET received = \(T.resourceName())")
if T.self == GetSyncData.self {
let syncData = try SyncData(data: data, storeCenter: self.storeCenter)
await self.storeCenter.synchronizeContent(syncData)
} else {
self._rescheduleApiCalls()
let results: [T] = try self._decode(data: data)
await self.storeCenter.itemsRetrieved(results, storeId: apiCall.storeId, clear: apiCall.option != .additive)
}
return data
}
fileprivate func _decode<V: Decodable>(data: Data) throws -> V {
if !(V.self is Empty?.Type || V.self is Empty.Type) {
return try JSON.decoder.decode(V.self, from: data)
} else {
return try JSON.decoder.decode(V.self, from: "{}".data(using: .utf8)!)
}
}
/// Wait for an exponentionnaly long time depending on the number of attemps
fileprivate func _wait() async {
guard self._attemptLoops > 0 else { return }
var seconds = self._attemptLoops
if self._attemptLoops > 5 {
let delay = pow(2, self._attemptLoops - 2) // starts at 16s
seconds = NSDecimalNumber(decimal: delay).intValue
}
Logger.log("\(T.resourceName()): wait for \(seconds) sec")
do {
try await Task.sleep(until: .now + .seconds(seconds))
} catch {
Logger.w("*** WAITING CRASHED !!!")
Logger.error(error)
}
}
// MARK: - Synchronization
/// Returns an APICall instance for the Storable [instance] and an HTTP [method]
/// The method updates existing calls or creates a new one
fileprivate func _callForInstance(_ instance: T, method: HTTPMethod) throws -> ApiCall<T>? {
/// The method makes some clean up when necessary:
/// - When deleting, we delete other calls as they are unecessary
/// - When updating, we delete other PUT as we don't want them to be executed in random orders
fileprivate func _prepareCall(instance: T, method: HTTPMethod, transactionId: String? = nil) {
if let existingCall = self.items.first(where: { $0.dataId == instance.id }) {
// cleanup if necessary
switch method {
case .delete:
self.deleteById(existingCall.id) // delete the existing call as we don't need it
if existingCall.method == HTTPMethod.post {
return nil // if the post has not been done, we can just stop here
} else {
return try self._createCall(instance, method: method) // otherwise it's a put and we want to send the delete
case .delete: // we don't want anything else than a DELETE in the queue
let existingCalls = self.items.filter { $0.data?.stringId == instance.stringId }
self._deleteCalls(existingCalls)
case .put: // we don't want mixed PUT calls so we delete the others
let existingPuts = self.items.filter { $0.data?.stringId == instance.stringId && $0.method == .put }
self._deleteCalls(existingPuts)
default:
break
}
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
let call: ApiCall<T> = self._createCall(method, instance: instance, transactionId: transactionId)
self._addCallToWaitingList(call)
}
} else {
return try self._createCall(instance, method: method)
/// deletes an array of ApiCall by id
fileprivate func _deleteCalls(_ calls: [ApiCall<T>]) {
for call in calls {
self.deleteById(call.id)
}
}
/// we want to avoid sending the same GET twice
fileprivate func _createGetCallIfNonExistent(_ parameters: [String : String]?, clear: Bool) -> ApiCall<T>? {
if let _ = self.items.first(where: { $0.method == .get && $0.urlParameters == parameters }) {
return nil
}
let option: CallOption? = !clear ? .additive : nil
let call = self._createCall(.get, instance: nil, option: option)
call.urlParameters = parameters
return call
}
/// Creates an API call for the Storable [instance] and an HTTP [method]
fileprivate func _createCall(_ instance: T, method: HTTPMethod) throws -> ApiCall<T> {
let jsonString = try instance.jsonString()
return ApiCall(method: method, dataId: String(instance.id), body: jsonString)
fileprivate func _createCall(_ method: HTTPMethod, instance: T?, transactionId: String? = nil, option: CallOption? = nil) -> ApiCall<T> {
if let instance {
return ApiCall(method: method, data: instance, transactionId: transactionId, option: option)
} else {
return ApiCall(method: .get, data: nil, option: option)
}
}
/// Prepares a call for execution by updating its properties and adding it to its collection for storage
fileprivate func _prepareCall(apiCall: ApiCall<T>) throws {
fileprivate func _addCallToWaitingList(_ apiCall: ApiCall<T>) {
apiCall.lastAttemptDate = Date()
apiCall.attemptsCount += 1
self.addOrUpdate(apiCall)
}
/// Reschedule API calls if necessary
func rescheduleApiCallsIfNecessary() {
if !self._isRetryingCalls {
self._rescheduleApiCalls()
/// Sends a GET request with an URLParameterConvertible [instance]
func sendGetRequest(instance: URLParameterConvertible) async throws {
let parameters = instance.queryParameters(storeCenter: self.storeCenter)
try await self._sendGetRequest(parameters: parameters)
}
/// Sends a GET request with an optional [storeId]
func sendGetRequest(storeId: String?, clear: Bool = true) async throws {
var parameters: [String : String]? = nil
if let storeId {
parameters = [Services.storeIdURLParameter : storeId]
}
try await self._sendGetRequest(parameters: parameters, clear: clear)
}
/// Sends an insert api call for the provided [instance]
func sendInsertion(_ instance: T) async throws -> T? {
fileprivate func _sendGetRequest(parameters: [String : String]?, clear: Bool = true) async throws {
if let getCall = self._createGetCallIfNonExistent(parameters, clear: clear) {
do {
return try await self._synchronize(instance, method: HTTPMethod.post)
try await self._prepareAndSendGetCall(getCall)
} catch {
self.rescheduleApiCallsIfNecessary()
Logger.error(error)
}
return nil
} else {
self.rescheduleImmediately()
}
}
/// Creates and execute the ApiCalls corresponding to the [batch]
func executeBatch(_ batch: OperationBatch<T>) {
self._prepareCalls(batch: batch)
self.rescheduleImmediately()
}
/// Sends an update api call for the provided [instance]
func sendUpdate(_ instance: T) async throws -> T? {
do {
return try await self._synchronize(instance, method: HTTPMethod.put)
} catch {
self.rescheduleApiCallsIfNecessary()
Logger.error(error)
func singleBatchExecution(_ batch: OperationBatch<T>) async {
self._prepareCalls(batch: batch)
await self._batchExecution()
}
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)
}
/// Sends an delete api call for the provided [instance]
func sendDeletion(_ instance: T) async throws -> T? {
do {
return try await self._synchronize(instance, method: HTTPMethod.delete)
} catch {
self.rescheduleApiCallsIfNecessary()
Logger.error(error)
fileprivate func _prepareCalls(batch: OperationBatch<T>) {
let transactionId = Store.randomId()
for insert in batch.inserts {
self._prepareCall(instance: insert, method: .post, transactionId: transactionId)
}
return nil
for update in batch.updates {
self._prepareCall(instance: update, method: .put, transactionId: transactionId)
}
/// Initiates the process of sending the data with the server
fileprivate func _synchronize(_ instance: T, method: HTTPMethod) async throws -> T? {
if let apiCall = try self._callForInstance(instance, method: method) {
try self._prepareCall(apiCall: apiCall)
return try await self._executeApiCall(apiCall)
} else {
return nil
for delete in batch.deletes {
self._prepareCall(instance: delete, method: .delete, transactionId: transactionId)
}
}
/// Prepares and executes a GET call
fileprivate func _prepareAndSendGetCall(_ apiCall: ApiCall<T>) async throws {
self._addCallToWaitingList(apiCall)
try await self._executeGetCall(apiCall: apiCall)
}
/// Executes an API call
/// For POST requests, potentially copies additional data coming from the server during the insert
fileprivate func _executeApiCall(_ apiCall: ApiCall<T>) async throws -> T {
return try await StoreCenter.main.execute(apiCall: apiCall)
fileprivate func _executeApiCalls(_ apiCalls: [ApiCall<T>]) async throws -> [OperationResult<T>] {
// Logger.log("/// \(T.resourceName()) > Start \(apiCalls.count) calls execution...")
let results = try await self.storeCenter.execute(apiCalls: apiCalls)
for result in results {
switch result.status {
case 200..<300:
self.deleteById(result.apiCallId)
default:
break
}
}
return results
}
/// Returns the content of the API call file as a String
@ -281,7 +445,36 @@ actor ApiCallCollection<T: Storable>: SomeCallCollection {
/// Returns if the API call collection is not empty
func hasPendingCalls() -> Bool {
// print("\(T.resourceName()) calls = \(self.items.count)")
return self.items.isNotEmpty
}
/// returns the list of API calls in the collection
func apiCalls() -> [ApiCall<T>] {
return self.items
}
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)
}
}

@ -0,0 +1,11 @@
### Le Storage
LeStorage is used to store objects into json files, and it can also be used to synchronize those objects to a django server properly configured.
Here are the most important classes:
- StoredCollection: stores object of one class in a json file
- SyncedCollection: stores object of one class in a json file and synchronizes changes with the server
- ApiCallCollection: provision HTTP calls and tries to execute them again
- StoreCenter: The central class to manages all collections through Store instances

@ -7,31 +7,133 @@
import Foundation
protocol SomeCall: Storable {
public protocol SomeCall: Identifiable, Storable {
var id: String { get }
var lastAttemptDate: Date { get }
var attemptsCount: Int { get }
var method: HTTPMethod { get }
var dataId: String? { get }
var dataContent: String? { get }
}
public enum CallOption: String, Codable {
case additive // keeps the content of the current collection
}
class ApiCall<T: Storable>: ModelObject, Storable, SomeCall {
public class ApiCall<T: Storable>: ModelObject, Storable, SomeCall {
public required override init() {
self.method = .get
super.init()
}
public static func resourceName() -> String { return "apicalls_" + T.resourceName() }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
public static func storeParent() -> Bool { return false }
public var id: String = Store.randomId()
/// The transactionId serves to group calls together
var transactionId: String = Store.randomId()
/// Creation date of the call
var creationDate: Date? = Date()
/// The HTTP method of the call
public var method: HTTPMethod
/// The content of the call
var data: T?
/// The number of times the call has been executed
public var attemptsCount: Int = 0
/// The date of the last execution
public var lastAttemptDate: Date = Date()
/// The parameters to add in the URL to obtain : "?p1=v1&p2=v2"
var urlParameters: [String : String]? = nil
/// The option for the call
var option: CallOption? = nil
init(method: HTTPMethod, data: T?, transactionId: String? = nil, option: CallOption? = nil) {
self.method = method
self.data = data
if let transactionId {
self.transactionId = transactionId
}
self.option = option
}
public func copy(from other: any Storable) {
fatalError("should not happen")
}
public func copyForUpdate(from other: any Storable) {
fatalError("should not happen")
}
func formattedURLParameters() -> String? {
return self.urlParameters?.toQueryString()
}
func urlExtension() -> String {
switch self.method {
case HTTPMethod.put, HTTPMethod.delete:
return T.path(id: self.data?.stringId)
case HTTPMethod.post:
return T.path()
case HTTPMethod.get:
if let parameters = self.urlParameters?.toQueryString() {
return T.path() + parameters
} else {
return T.path()
}
}
}
public var dataId: String? {
return self.data?.stringId
}
public var dataContent: String? {
if let data = self.data {
return try? data.jsonString()
}
return nil
}
var storeId: String? { return self.urlParameters?[Services.storeIdURLParameter] }
public static func relationships() -> [Relationship] { return [] }
public static func parentRelationships() -> [Relationship] { return [] }
public static func childrenRelationships() -> [Relationship] { return [] }
}
class OldApiCall<T: Storable>: ModelObject, Storable, SomeCall {
static func resourceName() -> String { return "apicalls_" + T.resourceName() }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
static func filterByStoreIdentifier() -> Bool { return false }
static func storeParent() -> Bool { return false }
var id: String = Store.randomId()
/// The http URL of the call
// var url: String
/// The transactionId to group calls together
var transactionId: String? = Store.randomId()
/// The HTTP method of the call: post...
var method: HTTPMethod
/// Creation date of the call
var creationDate: Date? = Date()
/// The id of the underlying data
var dataId: String
/// The HTTP method of the call
var method: HTTPMethod
/// The content of the call
var body: String
var body: String?
/// The id of the underlying data stored in the body
var dataId: String?
/// The number of times the call has been executed
var attemptsCount: Int = 0
@ -39,11 +141,71 @@ class ApiCall<T: Storable>: ModelObject, Storable, SomeCall {
/// The date of the last execution
var lastAttemptDate: Date = Date()
init(method: HTTPMethod, dataId: String, body: String) {
// self.url = url
/// The parameters to add in the URL to obtain : "?p1=v1&p2=v2"
var urlParameters: [String : String]? = nil
init(method: HTTPMethod, dataId: String? = nil, body: String? = nil, transactionId: String? = nil) {
self.method = method
self.dataId = dataId
self.body = body
if let transactionId {
self.transactionId = transactionId
}
}
init(method: HTTPMethod, data: T, transactionId: String? = nil) throws {
self.method = method
self.dataId = data.stringId
self.body = try data.jsonString()
if let transactionId {
self.transactionId = transactionId
}
}
func copy(from other: any Storable) {
fatalError("should not happen")
}
public func copyForUpdate(from other: any Storable) {
fatalError("should not happen")
}
func formattedURLParameters() -> String? {
return self.urlParameters?.toQueryString()
}
func urlExtension() -> String {
switch self.method {
case HTTPMethod.put, HTTPMethod.delete:
return T.path(id: self.dataId)
case HTTPMethod.post:
return T.path()
case HTTPMethod.get:
if let parameters = self.urlParameters?.toQueryString() {
return T.path() + parameters
} else {
return T.path()
}
}
}
var dataContent: String? { return self.body }
static func relationships() -> [Relationship] { return [] }
public static func parentRelationships() -> [Relationship] { return [] }
public static func childrenRelationships() -> [Relationship] { return [] }
func toNewApiCall() -> ApiCall<T>? {
if let instance: T = try? self.body?.decode() {
let apiCall = ApiCall(method: self.method, data: instance, transactionId: self.transactionId)
apiCall.id = self.id
apiCall.creationDate = self.creationDate
apiCall.attemptsCount = self.attemptsCount
apiCall.lastAttemptDate = self.lastAttemptDate
apiCall.urlParameters = self.urlParameters
return apiCall
} else {
return nil
}
}
}

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

@ -0,0 +1,44 @@
//
// 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,11 +7,24 @@
import Foundation
class FailedAPICall: ModelObject, Storable {
class FailedAPICall: SyncedModelObject, SyncedStorable {
static func resourceName() -> String { return "failed-api-calls" }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
static func filterByStoreIdentifier() -> Bool { return false }
static func relationships() -> [Relationship] { return [] }
public static func parentRelationships() -> [Relationship] { return [] }
public static func childrenRelationships() -> [Relationship] { return [] }
static var copyServerResponse: Bool = false
static func storeParent() -> Bool { return false }
override required init() {
self.callId = ""
self.type = ""
self.apiCall = ""
self.error = ""
super.init()
}
var id: String = Store.randomId()
@ -39,6 +52,62 @@ class FailedAPICall: ModelObject, Storable {
self.apiCall = apiCall
self.error = error
self.authentication = authentication
super.init()
}
// MARK: - Codable
enum CodingKeys: String, CodingKey {
case id
case date
case callId
case type
case apiCall
case error
case authentication
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
date = try container.decode(Date.self, forKey: .date)
callId = try container.decode(String.self, forKey: .callId)
type = try container.decode(String.self, forKey: .type)
apiCall = try container.decode(String.self, forKey: .apiCall)
error = try container.decode(String.self, forKey: .error)
authentication = try container.decodeIfPresent(String.self, forKey: .authentication)
try super.init(from: decoder)
}
override func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(date, forKey: .date)
try container.encode(callId, forKey: .callId)
try container.encode(type, forKey: .type)
try container.encode(apiCall, forKey: .apiCall)
try container.encode(error, forKey: .error)
try container.encodeIfPresent(authentication, forKey: .authentication)
try super.encode(to: encoder)
}
func copy(from other: any Storable) {
guard let fac = other as? FailedAPICall else { return }
self.date = fac.date
self.callId = fac.callId
self.type = fac.type
self.apiCall = fac.apiCall
self.error = fac.error
self.authentication = fac.authentication
}
public func copyForUpdate(from other: any Storable) {
self.copy(from: other)
}
}

@ -0,0 +1,57 @@
//
// 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,20 +7,67 @@
import Foundation
class Log: ModelObject, Storable {
class Log: SyncedModelObject, SyncedStorable {
static func resourceName() -> String { return "logs" }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
static func filterByStoreIdentifier() -> Bool { return false }
static func relationships() -> [Relationship] { return [] }
public static func parentRelationships() -> [Relationship] { return [] }
public static func childrenRelationships() -> [Relationship] { return [] }
var id: String = Store.randomId()
static var copyServerResponse: Bool = false
static func storeParent() -> Bool { return false }
var date: Date = Date()
override required init() {
super.init()
}
var message: String
var id: String = Store.randomId()
var date: Date = Date()
var user: String? = nil
var message: String = ""
init(message: String) {
init(message: String, user: String?) {
self.message = message
self.user = user
super.init()
}
// MARK: - Codable
enum CodingKeys: String, CodingKey {
case id
case date
case user
case message
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
date = try container.decode(Date.self, forKey: .date)
user = try container.decodeIfPresent(String.self, forKey: .user)
message = try container.decode(String.self, forKey: .message)
try super.init(from: decoder)
}
override func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(date, forKey: .date)
try container.encodeIfPresent(user, forKey: .user)
try container.encode(message, forKey: .message)
try super.encode(to: encoder)
}
func copy(from other: any Storable) {
guard let log = other as? Log else { return }
self.date = log.date
self.message = log.message
}
public func copyForUpdate(from other: any Storable) {
fatalError("should not happen")
}
}

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

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

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

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

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

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

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

@ -0,0 +1,31 @@
//
// 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,6 +6,7 @@
//
import Foundation
import UIKit
public enum HTTPMethod: String, CaseIterable, Codable {
case get = "GET"
@ -20,115 +21,92 @@ struct ServiceCall {
var requiresToken: Bool
}
let createAccountCall: ServiceCall = ServiceCall(path: "users/", method: .post, requiresToken: false)
let requestTokenCall: ServiceCall = ServiceCall(path: "token-auth/", method: .post, requiresToken: false)
let logoutCall: ServiceCall = ServiceCall(path: "api-token-logout/", method: .post, requiresToken: true)
let getUserCall: ServiceCall = ServiceCall(path: "user-by-token/", method: .get, requiresToken: true)
let changePasswordCall: ServiceCall = ServiceCall(path: "change-password/", method: .put, requiresToken: true)
let postDeviceTokenCall: ServiceCall = ServiceCall(path: "device-token/", method: .post, requiresToken: true)
//fileprivate enum ServiceConf: String {
// case createAccount = "users/"
// case requestToken = "token-auth/"
// case logout = "api-token-logout/"
// case getUser = "user-by-token/"
// case changePassword = "change-password/"
// case postDeviceToken = "device-token/"
//
// 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
// }
// }
//
//}
let createAccountCall: ServiceCall = ServiceCall(
path: "users/", method: .post, requiresToken: false)
let requestTokenCall: ServiceCall = ServiceCall(
path: "token-auth/", method: .post, requiresToken: false)
let logoutCall: ServiceCall = ServiceCall(
path: "api-token-logout/", method: .post, requiresToken: true)
let getUserCall: ServiceCall = ServiceCall(
path: "user-by-token/", method: .get, requiresToken: true)
let changePasswordCall: ServiceCall = ServiceCall(
path: "change-password/", method: .put, requiresToken: true)
let postDeviceTokenCall: ServiceCall = ServiceCall(
path: "device-token/", method: .post, requiresToken: true)
let getUserDataAccessCallContent: ServiceCall = ServiceCall(
path: "data-access-content/", method: .get, requiresToken: true)
let userAgentsCall: ServiceCall = ServiceCall(
path: "user-supervisors/", method: .get, requiresToken: true)
/// A class used to send HTTP request to the django server
public class Services {
/// A KeychainStore object used to store the user's token
let keychainStore: KeychainStore
fileprivate let storeCenter: StoreCenter
// fileprivate var _storeIdentifier: StoreIdentifier?
/// The base API URL to send requests
fileprivate(set) var baseURL: String
public init(url: String) {
public init(storeCenter: StoreCenter, url: String) {
self.storeCenter = storeCenter
self.baseURL = url
self.keychainStore = KeychainStore(serverId: url)
// self._storeIdentifier = storeId
Logger.log("create keystore with id: \(url)")
}
/// The base API URL to send requests
fileprivate(set) var baseURL: String
fileprivate var jsonEncoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
encoder.outputFormatting = .prettyPrinted
encoder.dateEncodingStrategy = .iso8601
return encoder
}()
fileprivate var jsonDecoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601
return decoder
}()
static let storeIdURLParameter = "store_id"
// MARK: - Base
/// Runs a request on the API and returns the appropriate response
/// - Parameters:
/// - path: the path of the service in the api, ie. "create-users/"
/// - method: the HTTP method to call
/// - requiresToken: whether the token must be included in the request
public func run<U: Decodable>(path: String, method: HTTPMethod, requiresToken: Bool) async throws -> U {
return try await self._runRequest(serviceCall: ServiceCall(path: path, method: method, requiresToken: requiresToken))
}
/// Runs a request using a configuration object
/// - Parameters:
/// - serviceConf: A instance of ServiceConf
/// - 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
/// - Parameters:
/// - serviceConf: A instance of ServiceConf
/// - payload: a codable value stored in the body of the request
/// - apiCallId: an optional id referencing an ApiCall
fileprivate func _runRequest<T: Encodable, U: Decodable>(serviceCall: ServiceCall, payload: T, apiCallId: String? = nil) async throws -> U {
fileprivate func _runRequest<T: Encodable, U: Decodable>(serviceCall: ServiceCall, payload: T)
async throws -> U {
var request = try self._baseRequest(call: serviceCall)
request.httpBody = try jsonEncoder.encode(payload)
return try await _runRequest(request, apiCallId: apiCallId)
request.httpBody = try JSON.encoder.encode(payload)
return try await _runRequest(request)
}
/// 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<T: Decodable>(_ request: URLRequest, apiCallId: String? = nil) async throws -> T {
Logger.log("Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
fileprivate func _runGetApiCallRequest<T: SyncedStorable>(
_ request: URLRequest, apiCall: ApiCall<T>
) async throws -> Data {
let debugURL = request.url?.absoluteString ?? ""
// print("Run \(request.httpMethod ?? "") \(debugURL)")
let task: (Data, URLResponse) = try await URLSession.shared.data(for: request)
Logger.log("response = \(String(data: task.0, encoding: .utf8) ?? "")")
// print("\(apiCall.method.rawValue) \(String(describing: T.self)) => \(String(data: task.0, encoding: .utf8) ?? "")")
if let response = task.1 as? HTTPURLResponse {
let statusCode = response.statusCode
Logger.log("request ended with status code = \(statusCode)")
switch statusCode {
case 200..<300: // success
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)")
}
}
try await self.storeCenter.deleteApiCallById(type: T.self, id: apiCall.id)
default: // error
Logger.log("Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
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: "")
@ -136,29 +114,70 @@ public class Services {
errorMessage = message
}
if let apiCallId, let type = (T.self as? any Storable.Type) {
try await StoreCenter.main.rescheduleApiCalls(id: apiCallId, type: type)
StoreCenter.main.logFailedAPICall(apiCallId, request: request, collectionName: type.resourceName(), error: errorMessage.message)
try await self.storeCenter.rescheduleApiCalls(type: T.self)
self.storeCenter.logFailedAPICall(
apiCall.id, request: request, collectionName: T.resourceName(),
error: errorMessage.message)
throw ServiceError.responseError(response: errorMessage.error)
}
} else {
StoreCenter.main.logFailedAPICall(request: request, error: errorMessage.message)
let message: String = "Unexpected and unmanaged URL Response \(task.1)"
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)
}
} else {
let message: String = "Unexpected and unmanaged URL Response \(task.1)"
StoreCenter.main.log(message: message)
self.storeCenter.log(message: message)
Logger.w(message)
}
return try jsonDecoder.decode(T.self, from: task.0)
return try self._decode(data: task.0)
}
/// Returns if the token is required for a request
/// - Parameters:
/// - type: the type of the request resource
/// - method: the HTTP method of the request
fileprivate func _isTokenRequired<T : Storable>(type: T.Type, method: HTTPMethod) -> Bool {
fileprivate func _isTokenRequired<T: SyncedStorable>(type: T.Type, method: HTTPMethod) -> Bool {
let methods = T.tokenExemptedMethods()
if methods.contains(method) {
return false
@ -170,34 +189,48 @@ public class Services {
/// Returns a GET request for the resource
/// - Parameters:
/// - type: the type of the request resource
fileprivate func _getRequest<T: Storable>(type: T.Type, identifier: StoreIdentifier?) throws -> URLRequest {
fileprivate func _getRequest<T: SyncedStorable>(type: T.Type, identifier: String?)
throws
-> URLRequest
{
let requiresToken = self._isTokenRequired(type: T.self, method: .get)
return try self._baseRequest(servicePath: T.path(), method: .get, requiresToken: requiresToken, identifier: identifier)
return try self._baseRequest(
servicePath: T.path(), method: .get, requiresToken: requiresToken,
identifier: identifier)
}
/// 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 {
let requiresToken = self._isTokenRequired(type: T.self, method: .post)
return try self._baseRequest(servicePath: T.path(), method: .post, requiresToken: requiresToken)
}
/// Returns a PUT request for the resource
/// Returns the base URLRequest for a ServiceConf instance
/// - Parameters:
/// - type: the type of the request resource
fileprivate func _putRequest<T: Storable>(type: T.Type, id: String) throws -> URLRequest {
let requiresToken = self._isTokenRequired(type: T.self, method: .put)
return try self._baseRequest(servicePath: T.path(id: id), method: .put, requiresToken: requiresToken)
/// - conf: a ServiceConf instance
fileprivate func _baseRequest(call: ServiceCall, getArguments: [String: String]? = nil) throws -> URLRequest {
return try self._baseRequest(
servicePath: call.path, method: call.method, requiresToken: call.requiresToken, getArguments: getArguments)
}
/// 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 a POST request for the resource
// /// - Parameters:
// /// - type: the type of the request resource
// fileprivate func _postRequest<T: Storable>(type: T.Type) throws -> URLRequest {
// let requiresToken = self._isTokenRequired(type: T.self, method: .post)
// return try self._baseRequest(servicePath: T.path(), method: .post, requiresToken: requiresToken)
// }
//
// /// 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 {
// let requiresToken = self._isTokenRequired(type: T.self, method: .put)
// 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
/// - Parameters:
@ -212,66 +245,314 @@ public class Services {
/// - method: the HTTP method to execute
/// - requiresToken: An optional boolean to indicate if the token is required
/// - identifier: an optional StoreIdentifier that allows to filter GET requests with the StoreIdentifier values
fileprivate func _baseRequest(servicePath: String, method: HTTPMethod, requiresToken: Bool? = nil, identifier: StoreIdentifier? = nil) throws -> URLRequest {
public func _baseRequest(
servicePath: String, method: HTTPMethod, requiresToken: Bool? = nil,
identifier: String? = nil, getArguments: [String : String]? = nil
) throws -> URLRequest {
var urlString = baseURL + servicePath
var arguments: [String : String] = getArguments ?? [:]
if let identifier {
urlString.append(identifier.urlComponent)
arguments[Services.storeIdURLParameter] = identifier
}
urlString.append(arguments.toQueryString())
guard let url = URL(string: urlString) else {
throw ServiceError.urlCreationError(url: urlString)
}
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.addAppVersion()
if !(requiresToken == false) {
let token = try self.keychainStore.getValue()
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(
// apiCall.id, request: request, collectionName: T.resourceName(),
// 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
}
/// Returns the URLRequest for an ApiCall
/// - Parameters:
/// - apiCall: An ApiCall instance to configure the returned request
fileprivate func _syncGetRequest<T: SyncedStorable>(from apiCall: ApiCall<T>) throws -> URLRequest {
var urlString = "\(baseURL)\(T.resourceName())/" // baseURL + T.resourceName() // "data/"
if let urlParameters = apiCall.formattedURLParameters() {
urlString.append(urlParameters)
}
guard let url = URL(string: urlString) else {
throw ServiceError.urlCreationError(url: urlString)
}
var request = URLRequest(url: url)
request.httpMethod = HTTPMethod.get.rawValue
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.addAppVersion()
if self._isTokenRequired(type: T.self, method: apiCall.method), self.storeCenter.isAuthenticated {
let token = try self.storeCenter.token()
request.addValue("Token \(token)", forHTTPHeaderField: "Authorization")
}
return request
}
// /// Executes a PUT request
// public func put<T: Storable>(_ instance: T) async throws -> T {
// var postRequest = try self._putRequest(type: T.self, id: instance.stringId)
// postRequest.httpBody = try jsonEncoder.encode(instance)
// 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)
request.httpMethod = HTTPMethod.post.rawValue
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 operations = apiCalls.map { apiCall in
return Operation(apiCallId: apiCall.id,
operation: apiCall.method.rawValue,
modelName: modelName,
data: apiCall.data,
storeId: apiCall.data?.getStoreId())
}
// let posts = apiCalls.filter({ $0.method == .post })
// for post in posts {
// print("POST \(T.resourceName()): id = \(post.dataId ?? "")")
// }
let payload = SyncPayload(operations: operations,
deviceId: self.storeCenter.deviceId())
request.httpBody = try JSON.encoder.encode(payload)
return request
}
// /// Starts a request to retrieve the synchronization updates
// /// - Parameters:
// /// - since: The date from which updates are retrieved
// func synchronizeLastUpdates(since: Date?) async throws {
// let request = try self._getSyncLogRequest(since: since)
// if let data = try await self._runRequest(request) {
// await self.storeCenter.synchronizeContent(data)
// }
// }
/// Returns the URLRequest for an ApiCall
/// - Parameters:
/// - since: The date from which updates are retrieved
fileprivate func _getSyncLogRequest(since: Date?) throws -> URLRequest {
let formattedDate = ISO8601DateFormatter().string(from: since ?? Date.distantPast)
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 {
throw ServiceError.urlCreationError(url: urlString)
}
var request = URLRequest(url: url)
request.httpMethod = HTTPMethod.get.rawValue
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let token = try self.storeCenter.token()
request.addValue("Token \(token)", forHTTPHeaderField: "Authorization")
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: Storable>(identifier: StoreIdentifier? = nil) async throws -> [T] {
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
public func post<T: Storable>(_ instance: T) async throws -> T {
/// 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 jsonEncoder.encode(instance)
postRequest.httpBody = try JSON.encoder.encode(instance)
return try await self._runRequest(postRequest)
}
/// Executes a PUT request
public func put<T: Storable>(_ instance: T) async throws -> T {
/// 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 jsonEncoder.encode(instance)
postRequest.httpBody = try JSON.encoder.encode(instance)
return try await self._runRequest(postRequest)
}
/// Executes a DELETE request on the generated DRF services corresponding to T
public func rawDelete<T: Storable>(_ instance: T) async throws -> T {
let deleteRequest = try self._deleteRequest(type: T.self, id: instance.stringId)
return try await self._runRequest(deleteRequest)
}
/// Executes an ApiCall
func runApiCall<T: Storable>(_ apiCall: ApiCall<T>) async throws -> T {
let request = try self._request(from: apiCall)
print("HTTP \(request.httpMethod ?? "") : id = \(apiCall.dataId)")
return try await self._runRequest(request, apiCallId: apiCall.id)
func runGetApiCall<T: SyncedStorable>(_ apiCall: ApiCall<T>) async throws -> Data {
let request = try self._syncGetRequest(from: apiCall)
return try await self._runGetApiCallRequest(request, apiCall: apiCall)
}
/// Executes an ApiCall
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
/// - Parameters:
/// - apiCall: An ApiCall instance to configure the returned request
fileprivate func _request<T: Storable>(from apiCall: ApiCall<T>) throws -> URLRequest {
fileprivate func _request<T: SyncedStorable>(from apiCall: ApiCall<T>) throws -> URLRequest {
let url = try self._url(from: apiCall)
var request = URLRequest(url: url)
request.httpMethod = apiCall.method.rawValue
request.httpBody = apiCall.body.data(using: .utf8)
request.httpBody = try apiCall.data?.jsonData()
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.addAppVersion()
if self._isTokenRequired(type: T.self, method: apiCall.method) {
do {
let token = try self.keychainStore.getValue()
let token = try self.storeCenter.token()
request.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
} catch {
Logger.log("missing token")
@ -286,12 +567,8 @@ public class Services {
/// - apiCall: an instance of ApiCall to build to URL
fileprivate func _url<T: Storable>(from apiCall: ApiCall<T>) throws -> URL {
var stringURL: String = self.baseURL
switch apiCall.method {
case HTTPMethod.put, HTTPMethod.delete:
stringURL += T.path(id: apiCall.dataId)
default:
stringURL += T.path()
}
stringURL += apiCall.urlExtension()
if let url = URL(string: stringURL) {
return url
} else {
@ -299,6 +576,12 @@ public class Services {
}
}
// MARK: - Others
public func getUserAgents() async throws -> [ShortUser] {
return try await self._runRequest(serviceCall: userAgentsCall)
}
// MARK: - Authentication
/// Creates an account
@ -314,38 +597,28 @@ public class Services {
/// - password: the account's password
public func requestToken(username: String, password: String) async throws -> String {
var postRequest = try self._baseRequest(call: requestTokenCall)
let deviceId = StoreCenter.main.deviceId()
let credentials = Credentials(username: username, password: password, deviceId: deviceId)
postRequest.httpBody = try jsonEncoder.encode(credentials)
let deviceId = self.storeCenter.deviceId()
let deviceModel = await UIDevice.current.deviceModel()
let credentials = Credentials(username: username, password: password, deviceId: deviceId, deviceModel: deviceModel)
postRequest.httpBody = try JSON.encoder.encode(credentials)
let response: AuthResponse = try await self._runRequest(postRequest)
self._storeToken(username: username, token: response.token)
try self.storeCenter.storeToken(username: username, token: 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
/// - Parameters:
/// - username: the account's username
/// - password: the account's password
public func login<U: UserBase>(username: String, password: String) async throws -> U {
_ = try await requestToken(username: username, password: password)
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)
// StoreCenter.main.setUserUUID(uuidString: user.id)
// StoreCenter.main.setUserName(user.username)
StoreCenter.main.setUserInfo(user: user)
self.storeCenter.userDidLogIn(user: user, at: loggingDate)
return user
}
@ -354,8 +627,9 @@ public class Services {
/// - username: the account's username
/// - password: the account's password
public func logout() async throws {
let deviceId: String = StoreCenter.main.deviceId()
let _: Empty = try await self._runRequest(serviceCall: logoutCall, payload: Logout(deviceId: deviceId))
let deviceId: String = self.storeCenter.deviceId()
let _: Empty = try await self._runRequest(
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
@ -365,8 +639,16 @@ public class Services {
public func postDeviceToken(deviceToken: Data) async throws {
let tokenString = deviceToken.map { String(format: "%02x", $0) }.joined()
let token = DeviceToken(value: tokenString)
// Logger.log("Send device token = \(tokenString)")
let _: DeviceToken = try await self._runRequest(serviceCall: postDeviceTokenCall, payload: token)
// Logger.log("Send device token = \(tokenString)")
let _: Empty = 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
@ -374,9 +656,11 @@ public class Services {
/// - oldPassword: the account's old password
/// - password1: the account's new password
/// - password2: a repeat of the new password
public func changePassword(oldPassword: String, password1: String, password2: String) async throws {
public func changePassword(oldPassword: String, password1: String, password2: String)
async throws
{
guard let username = StoreCenter.main.userName() else {
guard let username = self.storeCenter.userName else {
throw ServiceError.missingUserName
}
@ -386,10 +670,12 @@ public class Services {
var new_password2: String
}
let params = ChangePasswordParams(old_password: oldPassword, new_password1: password1, new_password2: password2)
let response: Token = try await self._runRequest(serviceCall: changePasswordCall, payload: params)
let params = ChangePasswordParams(
old_password: oldPassword, new_password1: password1, new_password2: password2)
let response: Token = try await self._runRequest(
serviceCall: changePasswordCall, payload: params)
self._storeToken(username: username, token: response.token)
try self.storeCenter.storeToken(username: username, token: response.token)
}
/// The method send a request to reset the user's password
@ -397,7 +683,7 @@ public class Services {
/// - email: the email of the user
public func forgotPassword(email: String) async throws {
var postRequest = try self._baseRequest(servicePath: "dj-rest-auth/password/reset/", method: .post, requiresToken: false)
postRequest.httpBody = try jsonEncoder.encode(Email(email: email))
postRequest.httpBody = try JSON.encoder.encode(Email(email: email))
let response: Email = try await self._runRequest(postRequest)
Logger.log("response = \(response)")
}
@ -407,8 +693,8 @@ public class Services {
/// - username: the account's username
/// - password: the account's password
public func deleteAccount() async throws {
guard let userId = StoreCenter.main.userId else {
throw ServiceError.missingUserId
guard let userId = self.storeCenter.userId else {
throw StoreError.missingUserId
}
let path = "users/\(userId)/"
let deleteAccount = ServiceCall(path: path, method: .delete, requiresToken: true)
@ -417,27 +703,14 @@ public class Services {
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
/// - Parameters:
/// - data: some JSON data
fileprivate func errorMessageFromResponse(data: Data) -> ErrorMessage? {
do {
if let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
if let jsonObject = try JSONSerialization.jsonObject(with: data, options: [])
as? [String: Any]
{
if let tuple = jsonObject.first {
var error = ""
if let stringsArray = tuple.value as? [String], let first = stringsArray.first {
@ -454,10 +727,73 @@ public class Services {
return nil
}
func migrateToken(_ services: Services, userName: String) throws {
try self._storeToken(username: userName, token: services.keychainStore.getValue())
// MARK: - Convenience method for tests
/// 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 {
var operations: [Operation<T>]
var deviceId: String?
}
struct Operation<T: Encodable>: Encodable {
var apiCallId: String
var operation: String
var modelName: String
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 {
@ -476,6 +812,7 @@ struct Credentials: Codable {
var username: String
var password: String
var deviceId: String
var deviceModel: String?
}
struct Token: Codable {
var token: String
@ -504,3 +841,19 @@ public protocol UserBase: Codable {
public protocol UserPasswordBase: UserBase {
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
/// A protocol describing classes that can be stored locally in JSON and synchronized on our django server
public protocol Storable: Codable, Identifiable where ID : StringProtocol {
public protocol Storable: Codable, Identifiable, NSObjectProtocol {
/// The store containing a reference to the instance
var store: Store? { get set }
@ -17,26 +17,29 @@ public protocol Storable: Codable, Identifiable where ID : StringProtocol {
/// Also used as the name of the local file
static func resourceName() -> String
/// Returns HTTP methods that do not need to pass the token to the request
static func tokenExemptedMethods() -> [HTTPMethod]
/// This method is only used if the instance store uses an identifier
/// This method should return true if the resources need to get filtered using the store identifier when performing a GET
/// Returning false won't filter the resources when performing a GET
static func filterByStoreIdentifier() -> Bool
/// A method that deletes the local dependencies of the resource
/// Mimics the behavior the cascading delete on the django server
/// Mimics the behavior of the cascading delete on the django server
/// Typically when we delete a resource, we automatically delete items that depends on it,
/// so when we do that on the server, we also need to do it locally
func deleteDependencies() throws
func deleteDependencies(store: Store, actionOption: ActionOption)
/// A method that deletes dependencies of shared resources, but only if they are themselves shared
/// and not referenced by other objects in the store
/// This is used when cleaning up shared objects that are no longer in use
func deleteUnusedSharedDependencies(store: Store)
/// A method called to retrieve data added by the server on a POST request
/// The method will be called after a POST has succeeded,
/// and will provide a copy of what's on the server
func copyFromServerInstance(_ instance: any Storable) -> Bool
/// Copies the content of another item into the instance
/// This behavior has been made to get live updates when looking at properties in SwiftUI screens
func copy(from other: any Storable)
static var relationshipNames: [String] { get }
/// This method returns RelationShips objects of the type
static func relationships() -> [Relationship]
static func parentRelationships() -> [Relationship]
static func childrenRelationships() -> [Relationship]
/// Denotes a data that own its own store
/// Effectively used to trigger directory creation when adding an item to the collection
static func storeParent() -> Bool
}
@ -48,7 +51,16 @@ extension Storable {
}
/// Returns a string id for the instance
var stringId: String { return String(self.id) }
public var stringId: String {
switch self.id {
case let sp as any StringProtocol:
return String(sp)
case let intLitteral as any ExpressibleByIntegerLiteral:
return "\(intLitteral)"
default:
fatalError("id not convertible to string")
}
}
/// Returns the relative path of the instance for the django server
static func path(id: String? = nil) -> String {
@ -60,20 +72,15 @@ extension Storable {
return path
}
public static func storageDirectoryPath() throws -> URL {
return try FileUtils.pathForDirectoryInDocuments(directory: Store.storageDirectory)
}
static func writeToStorageDirectory(content: String, fileName: String) throws {
var fileURL = try self.storageDirectoryPath()
fileURL.append(component: fileName)
try content.write(to: fileURL, atomically: false, encoding: .utf8)
static func buildRealId(id: String) -> ID {
switch ID.self {
case is String.Type:
return id as! ID
case is Int64.Type:
return Formatter.number.number(from: id)?.int64Value as! ID
default:
fatalError("ID \(type(of: ID.self)) is neither String nor Int, can't parse \(id)")
}
static func urlForJSONFile() throws -> URL {
var storageDirectory = try self.storageDirectoryPath()
storageDirectory.append(component: self.fileName())
return storageDirectory
}
}

@ -8,61 +8,111 @@
import Foundation
import UIKit
public enum StoreError: Error {
public enum StoreError: Error, LocalizedError {
case missingService
case missingUserId
case unexpectedCollectionType(name: String)
case apiCallCollectionNotRegistered(type: String)
case missingUsername
case missingToken
case missingKeychainStore
case collectionNotRegistered(type: String)
case cannotSyncCollection(name: String)
}
public struct StoreIdentifier {
var value: String
var parameterName: String
public init(value: String, parameterName: String) {
self.value = value
self.parameterName = parameterName
case apiCallCollectionNotRegistered(type: String)
case synchronizationInactive
case storeNotRegistered(id: String)
case castIssue(type: String)
case invalidStoreLookup(from: any Storable.Type, to: any Storable.Type)
public var localizedDescription: String {
switch self {
case .missingService:
return "L'instance des services est nulle"
case .missingUsername:
return "Le nom d'utilisateur est manquant"
case .missingUserId:
return "L'identifiant utilisateur est manquant"
case .missingToken:
return "Aucun token n'est stocké"
case .missingKeychainStore:
return "Aucun magasin de trousseau n'est disponible"
case .collectionNotRegistered(let type):
return "La collection \(type) n'est pas enregistrée"
case .apiCallCollectionNotRegistered(let type):
return "La collection d'appels API n'a pas été enregistrée pour \(type)"
case .synchronizationInactive:
return "La synchronisation n'est pas active sur ce StoreCenter"
case .storeNotRegistered(let id):
return "Le magasin avec l'identifiant \(id) n'est pas enregistré"
case .castIssue(let type):
return "Problème de typage: \(type)"
case .invalidStoreLookup(let from, let to):
return "Mauvaise recherche dans le magasin de \(from) à \(to)"
}
}
public var errorDescription: String? {
switch self {
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)"
}
var urlComponent: String {
return "?\(self.parameterName)=\(self.value)"
}
}
open class Store {
final public class Store {
/// The Store singleton
public static let main = Store()
public fileprivate(set) var storeCenter: StoreCenter
/// The dictionary of registered StoredCollections
/// The dictionary of registered collections
fileprivate var _collections: [String : any SomeCollection] = [:]
/// The name of the directory to store the json files
static let storageDirectory = "storage"
/// The dictionary of all StoredCollection
fileprivate var _baseCollections: [String : any SomeCollection] = [:]
/// The store identifier, used to name the store directory, and to perform filtering requests to the server
fileprivate(set) var identifier: StoreIdentifier? = nil
/// Indicates whether the store directory has been created at the init
fileprivate var _created: Bool = false
public fileprivate(set) var identifier: String? = nil
public init() {
self._createDirectory(directory: Store.storageDirectory)
public init(storeCenter: StoreCenter) {
self.storeCenter = storeCenter
}
public required init(identifier: String, parameter: String) {
self.identifier = StoreIdentifier(value: identifier, parameterName: parameter)
let directory = "\(Store.storageDirectory)/\(identifier)"
public required init(storeCenter: StoreCenter, identifier: String) {
self.storeCenter = storeCenter
self.identifier = identifier
let directory = "\(storeCenter.directoryName)/\(identifier)"
self._createDirectory(directory: directory)
}
public static var main: Store { return StoreCenter.main.mainStore }
public func alternateStore(identifier: String) throws -> Store {
return try self.storeCenter.store(identifier: identifier)
}
/// Creates the store directory
/// - Parameters:
/// - directory: the name of the directory
fileprivate func _createDirectory(directory: String) {
self._created = FileManager.default.createDirectoryInDocuments(directoryName: directory)
FileManager.default.createDirectoryInDocuments(directoryName: directory)
}
/// A method to provide ids corresponding to the django storage
@ -72,23 +122,54 @@ open class Store {
/// Registers a collection
/// - Parameters:
/// - synchronized: indicates if the data is synchronized with the server
/// - indexed: Creates an index to quickly access the data
/// - inMemory: Indicates if the collection should only live in memory, and not write into a file
/// - 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> {
public func registerCollection<T : Storable>(indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil) -> StoredCollection<T> {
if let _ = try? self.someCollection(type: T.self) {
fatalError("collection already registered")
// return collection
}
let collection = StoredCollection<T>(synchronized: synchronized, store: self, indexed: indexed, inMemory: inMemory, sendsUpdate: sendsUpdate)
let collection = StoredCollection<T>(store: self, indexed: indexed, inMemory: inMemory, limit: limit)
self._collections[T.resourceName()] = collection
self._baseCollections[T.resourceName()] = collection
if synchronized {
StoreCenter.main.loadApiCallCollection(type: T.self)
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)
self._collections[T.resourceName()] = collection
self._baseCollections[T.resourceName()] = collection.collection
self.storeCenter.loadApiCallCollection(type: T.self)
return collection
}
if self._created, let identifier {
self._migrate(collection, identifier: identifier, type: T.self)
func asyncLoadingSynchronizedCollection<T : SyncedStorable>(inMemory: Bool = false) async -> SyncedCollection<T> {
let collection = await SyncedCollection<T>(store: self, inMemory: inMemory)
self._collections[T.resourceName()] = collection
self._baseCollections[T.resourceName()] = collection.collection
self.storeCenter.loadApiCallCollection(type: T.self)
return collection
}
func asyncLoadingStoredCollection<T : Storable>(inMemory: Bool = false) async -> StoredCollection<T> {
let collection = await StoredCollection<T>(store: self, inMemory: inMemory)
self._collections[T.resourceName()] = collection
self._baseCollections[T.resourceName()] = collection
return collection
}
@ -97,13 +178,14 @@ open class Store {
/// - synchronized: indicates if the data is synchronized with the server
/// - inMemory: Indicates if the collection should only live in memory, and not write into a file
/// - sendsUpdate: Indicates if updates of items should be sent to the server
public func registerObject<T : Storable>(synchronized: Bool, inMemory: Bool = false, sendsUpdate: Bool = true) -> StoredSingleton<T> {
public func registerObject<T : Storable>(synchronized: Bool, inMemory: Bool = false, shouldLoadDataFromServer: Bool = true) -> StoredSingleton<T> {
let storedObject = StoredSingleton<T>(synchronized: synchronized, store: self, inMemory: inMemory, sendsUpdate: sendsUpdate)
let storedObject = StoredSingleton<T>(store: self, inMemory: inMemory, shouldLoadDataFromServer: shouldLoadDataFromServer)
self._collections[T.resourceName()] = storedObject
self._collections[T.resourceName()] = storedObject
if synchronized {
StoreCenter.main.loadApiCallCollection(type: T.self)
self.storeCenter.loadApiCallCollection(type: T.self)
}
return storedObject
@ -114,44 +196,74 @@ open class Store {
/// Looks for an instance by id
/// - Parameters:
/// - id: the id of the data
public func findById<T: Storable>(_ id: String) -> T? {
guard let collection = self._collections[T.resourceName()] as? StoredCollection<T> else {
public func findById<T: Storable>(_ id: T.ID) -> T? {
guard let collection = self._baseCollections[T.resourceName()] as? StoredCollection<T> else {
Logger.w("Collection \(T.resourceName()) not registered")
return nil
}
return collection.findById(id)
}
/// Filters a collection by predicate
/// - Parameters:
/// - isIncluded: a predicate to returns if a data should be filtered in
public func filter<T: Storable>(isIncluded: (T) throws -> (Bool)) rethrows -> [T] {
do {
return try self.collection().filter(isIncluded)
} catch {
return []
/// Returns a collection by type
func syncedCollection<T: SyncedStorable>() throws -> SyncedCollection<T> {
if let collection = self._collections[T.resourceName()] as? SyncedCollection<T> {
return collection
}
throw StoreError.collectionNotRegistered(type: T.resourceName())
}
/// Returns a collection by type
func syncedCollection<T: SyncedStorable>(type: T.Type) throws -> SyncedCollection<T> {
return try self.syncedCollection()
}
/// Returns a collection by type
func collection<T: Storable>() throws -> StoredCollection<T> {
if let collection = self._collections[T.resourceName()] as? StoredCollection<T> {
func someCollection<T: Storable>(type: T.Type) throws -> any SomeCollection {
if let collection = self._collections[T.resourceName()] {
return collection
}
throw StoreError.collectionNotRegistered(type: T.resourceName())
}
func registerOrGetSyncedCollection<T: SyncedStorable>(_ type: T.Type) -> SyncedCollection<T> {
do {
return try self.syncedCollection()
} catch {
return self.registerSynchronizedCollection(indexed: true, inMemory: false, noLoad: true)
}
}
/// Loads all collection with the data from the server
public func loadCollectionsFromServer() {
for collection in self._collections.values {
if collection.synchronized {
public func loadCollectionsFromServer(clear: Bool) {
for collection in self._syncedCollections() {
Task {
do {
try await collection.loadDataFromServerIfAllowed(clear: clear)
} catch {
Logger.error(error)
}
}
}
}
/// Loads all synchronized collection with server data if they don't already have a local file
public func loadCollectionsFromServerIfNoFile() {
for collection in self._syncedCollections() {
Task {
try? await collection.loadDataFromServerIfAllowed()
do {
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
public func reset() {
for collection in self._collections.values {
@ -159,17 +271,166 @@ open class Store {
}
}
/// Returns the names of all collections
public func collectionNames() -> [String] {
return self._collections.values.map { $0.resourceName }
// MARK: - Synchronization
fileprivate func _requestWrite<T: SyncedStorable>(type: T.Type) {
self._baseCollections[T.resourceName()]?.requestWriteIfNecessary()
}
@MainActor
func synchronizationAddOrUpdate<T: SyncedStorable>(_ instances: [T], shared: SharingStatus?) {
for item in instances {
if !self.storeCenter.hasAlreadyBeenDeleted(item) {
self.addOrUpdateIfNewer(item, shared: shared)
}
}
self._requestWrite(type: T.self)
}
/// Calls addOrUpdateIfNewer from the collection corresponding to the instance
@MainActor
func addOrUpdateIfNewer<T: SyncedStorable>(_ instance: T, shared: SharingStatus?) {
let collection: SyncedCollection<T> = self.registerOrGetSyncedCollection(T.self)
collection.addOrUpdateIfNewer(instance, shared: shared)
}
@MainActor
func synchronizationDelete<T: SyncedStorable>(_ identifiers: [ObjectIdentifier], type: T.Type) {
for identifier in identifiers {
do {
try self.deleteNoSyncNoCascadeNoWrite(type: type, id: identifier.modelId)
} catch {
Logger.error(error)
}
self.storeCenter.cleanupDataLog(dataId: identifier.modelId)
}
self._requestWrite(type: T.self)
}
@MainActor
func synchronizationRevoke<T: SyncedStorable>(_ identifiers: [ObjectIdentifier], type: T.Type) {
for identifier in identifiers {
do {
if let instance = self._instance(id: identifier.modelId, type: type) {
if instance.sharing != nil && !self.storeCenter.isReferenced(instance: instance) {
try self.deleteNoSyncNoCascadeNoWrite(type: type, id: identifier.modelId)
}
}
} catch {
Logger.error(error)
}
}
self._requestWrite(type: T.self)
}
fileprivate func _instance<T: SyncedStorable>(id: String, type: T.Type) -> T? {
let realId: T.ID = T.buildRealId(id: id)
return self.findById(realId)
}
/// Calls deleteById from the collection corresponding to the instance
func deleteNoSyncNoCascadeNoWrite<T: SyncedStorable>(type: T.Type, id: String) throws {
let collection: SyncedCollection<T> = try self.syncedCollection()
collection.deleteByStringId(id, actionOption: .noCascadeNoWrite)
}
func isReferenced<T: Storable, S: Storable>(collectionType: S.Type, type: T.Type, id: String) -> Bool {
if let collection = self._baseCollections[S.resourceName()] {
return collection.hasParentReferences(type: type, id: id)
} else {
return false
}
}
public func deleteUnusedGrantedIfNecessary<T: SyncedStorable>(_ instance: T, originStoreId: String?) {
if !self.storeCenter.isReferenced(instance: instance) {
do {
let collection: SyncedCollection<T> = try self.syncedCollection()
collection.deleteUnusedGranted(instance: instance)
} catch {
Logger.error(error)
}
}
}
public func deleteUnusedSharedDependencies<T: SyncedStorable>(type: T.Type, _ handler: (T) throws -> Bool) {
do {
let collection: SyncedCollection<T> = try self.syncedCollection()
let items = try collection.items.filter(handler)
self.deleteUnusedSharedDependencies(items)
} catch {
Logger.error(error)
}
}
public func deleteUnusedSharedDependencies<T: SyncedStorable>(type: T.Type) {
do {
let collection: SyncedCollection<T> = try self.syncedCollection()
self.deleteUnusedSharedDependencies(collection.items)
} catch {
Logger.error(error)
}
}
/// Deletes dependencies of shared objects that are not used elsewhere in the system
/// Similar to _deleteDependencies but only for unused shared objects
public func deleteUnusedSharedDependencies<T: SyncedStorable>(_ items: [T]) {
do {
for item in items {
guard item.sharing != nil else { continue }
if !self.storeCenter.isReferenced(instance: item) {
// Only delete if the shared item has no references
item.deleteUnusedSharedDependencies(store: self)
let collection: SyncedCollection<T> = try self.syncedCollection()
collection.deleteUnusedGranted(instance: item)
}
}
} catch {
Logger.error(error)
}
}
public func deleteAllDependencies<T: Storable>(type: T.Type, actionOption: ActionOption) {
do {
let collection = try self.someCollection(type: type)
collection.deleteAllItemsAndDependencies(actionOption: actionOption)
} catch {
Logger.error(error)
}
}
public func deleteDependencies<T>(type: T.Type, actionOption: ActionOption, _ isIncluded: (T) -> Bool) where T: SyncedStorable {
do {
let collection = try self.someCollection(type: type)
if let syncCollection = collection as? SyncedCollection<T> {
syncCollection.deleteDependencies(actionOption: actionOption, isIncluded)
}
} catch {
Logger.error(error)
}
}
public func deleteDependencies<T>(type: T.Type, actionOption: ActionOption, _ isIncluded: (T) -> Bool) where T: Storable {
do {
let collection = try self.someCollection(type: type)
if let syncCollection = collection as? StoredCollection<T> {
syncCollection.deleteDependencies(actionOption: actionOption, isIncluded)
}
} catch {
Logger.error(error)
}
}
// MARK: - Write
/// Returns the directory URL of the store
fileprivate func _directoryPath() throws -> URL {
var url = try FileUtils.pathForDirectoryInDocuments(directory: Store.storageDirectory)
if let identifier = self.identifier?.value {
var url = try FileUtils.pathForDirectoryInDocuments(directory: storeCenter.directoryName)
if let identifier {
url.append(component: identifier)
}
return url
@ -183,14 +444,22 @@ open class Store {
var fileURL = try self._directoryPath()
fileURL.append(component: fileName)
try content.write(to: fileURL, atomically: false, encoding: .utf8)
// Logger.log("write into \(fileURL)...")
}
/// Returns the URL matching a Storable type
/// - Parameters:
/// - type: a Storable type
func fileURL<T: Storable>(type: T.Type) throws -> URL {
return try self.fileURL(fileName: T.fileName())
}
/// Returns the URL matching a Storable type
/// - Parameters:
/// - type: a Storable type
func fileURL(fileName: String) throws -> URL {
let fileURL = try self._directoryPath()
return fileURL.appending(component: T.fileName())
return fileURL.appending(component: fileName)
}
/// Removes a file matching a Storable type
@ -208,91 +477,27 @@ open class Store {
}
/// Retrieves all the items on the server
public func getItems<T: Storable>() async throws -> [T] {
if T.filterByStoreIdentifier() {
return try await StoreCenter.main.getItems(identifier: self.identifier)
public func getItems<T: SyncedStorable>() async throws -> [T] {
if let identifier = self.identifier {
return try await self.storeCenter.getItems(identifier: identifier)
} else {
return try await StoreCenter.main.getItems()
return try await self.storeCenter.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)
}
/// 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 {
func loadCollectionItems<T: SyncedStorable>(_ items: [T], clear: Bool) async {
do {
try await collection.loadCollectionsFromServerIfNoFile()
let collection: SyncedCollection<T> = try self.syncedCollection()
await collection.loadItems(items, clear: clear)
} catch {
Logger.error(error)
}
}
}
}
}
/// Returns whether all collections have loaded locally
public func collectionsAllLoaded() -> Bool {
return self._collections.values.allSatisfy { $0.hasLoaded }
}
fileprivate var _validIds: [String] = []
fileprivate func _migrate<T : Storable>(_ collection: StoredCollection<T>, identifier: StoreIdentifier, type: T.Type) {
self._validIds.append(identifier.value)
let oldCollection: StoredCollection<T> = StoredCollection<T>(synchronized: false, store: Store.main, asynchronousIO: false)
let filtered: [T] = oldCollection.items.filter { item in
var propertyValue: String? = item.stringForPropertyName(identifier.parameterName)
if propertyValue == nil {
let values = T.relationshipNames.map { item.stringForPropertyName($0) }
propertyValue = values.compactMap { $0 }.first
}
return self._validIds.first(where: { $0 == propertyValue }) != nil
}
if filtered.count > 0 {
self._validIds.append(contentsOf: filtered.map { $0.stringId })
try? collection.addOrUpdateNoSync(contentOfs: filtered)
Logger.log("Migrated \(filtered.count) \(T.resourceName())")
}
}
}
fileprivate extension Storable {
func stringForPropertyName(_ propertyName: String) -> String? {
let mirror = Mirror(reflecting: self)
for child in mirror.children {
if let label = child.label, label == "_\(propertyName)" {
return child.value as? String
}
}
return nil
public func fileCollectionsAllLoaded() -> Bool {
let fileCollections = self._collections.values.filter { $0.inMemory == false }
return fileCollections.allSatisfy { $0.hasLoaded }
}
}

File diff suppressed because it is too large Load Diff

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

@ -6,471 +6,608 @@
//
import Foundation
import Combine
enum StoredCollectionError: Error {
case unmanagedHTTPMethod(method: String)
case missingApiCallCollection
case missingInstance
}
public protocol SomeCollection<Item>: Identifiable {
protocol CollectionHolder {
associatedtype Item
associatedtype Item: Storable
var hasLoaded: Bool { get }
var inMemory: Bool { get }
var type: any Storable.Type { get }
var items: [Item] { get }
func reset()
}
func hasParentReferences<S: Storable>(type: S.Type, id: String) -> Bool
protocol SomeCollection: CollectionHolder, Identifiable {
var resourceName: String { get }
var synchronized: Bool { get }
var hasLoaded: Bool { get }
var items: [Item] { get }
func allItems() -> [any Storable]
func deleteAllItemsAndDependencies(actionOption: ActionOption)
func deleteDependencies(actionOption: ActionOption, _ isIncluded: (Item) -> Bool)
func loadDataFromServerIfAllowed() async throws
func loadCollectionsFromServerIfNoFile() async throws
func findById(_ id: Item.ID) -> Item?
func requestWriteIfNecessary()
}
protocol CollectionDelegate<Item> {
associatedtype Item: Storable
func loadingForMemoryCollection() async
func itemMerged(_ pendingOperation: PendingOperation<Item>)
}
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")
enum CollectionMethod {
case insert
case update
case delete
}
public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollection, CollectionHolder {
public struct ActionResult<T> {
var instance: T
var method: CollectionMethod
var pending: Bool
}
/// If true, will synchronize the data with the provided server located at the Store's synchronizationApiURL
let synchronized: Bool
public struct ActionOption: Codable {
var synchronize: Bool
var cascade: Bool
var write: Bool
/// Doesn't write the collection in a file
fileprivate var _inMemory: Bool = false
static let standard: ActionOption = ActionOption(synchronize: false, cascade: false, write: true)
static let noCascadeNoWrite: ActionOption = ActionOption(synchronize: false, cascade: false, write: false)
static let cascade: ActionOption = ActionOption(synchronize: false, cascade: true, write: true)
static let syncedCascade: ActionOption = ActionOption(synchronize: true, cascade: true, write: true)
}
public class StoredCollection<T: Storable>: SomeCollection {
/// Indicates if the synchronized collection sends update to the API
fileprivate var _sendsUpdate: Bool = true
public typealias Item = T
/// Doesn't write the collection in a file
fileprivate(set) public var inMemory: Bool = false
/// The list of stored items
@Published public fileprivate(set) var items: [T] = []
/// The reference to the Store
fileprivate var _store: Store
fileprivate(set) var store: Store
/// Provides fast access for instances if the collection has been instanced with [indexed] = true
fileprivate var _indexes: [String : T]? = nil
fileprivate var _indexes: [T.ID: T]? = nil
/// A PendingOperationManager instance that manages operations while the collection is not loaded
fileprivate(set) var pendingOperationManager: PendingOperationManager<T>? = nil
fileprivate var _writingTimer: Timer? = nil
/// Indicates whether the collection has changed, thus requiring a write operation
fileprivate var _hasChanged: Bool = false {
fileprivate var _triggerWrite: Bool = false {
didSet {
if self._hasChanged == true {
if self._triggerWrite == true {
self._scheduleWrite()
DispatchQueue.main.async {
NotificationCenter.default.post(name: NSNotification.Name.CollectionDidChange, object: self)
self._triggerWrite = false
}
self._hasChanged = false
DispatchQueue.main.async {
NotificationCenter.default.post(
name: NSNotification.Name.CollectionDidChange, object: self)
}
}
}
/// Denotes a collection that loads and writes asynchronously
fileprivate var asynchronousIO: Bool = true
/// Indicates if the collection has loaded locally, with or without a file
fileprivate(set) public var hasLoaded: Bool = false
init(synchronized: Bool, store: Store, indexed: Bool = false, asynchronousIO: Bool = true, inMemory: Bool = false, sendsUpdate: Bool = true) {
self.synchronized = synchronized
self.asynchronousIO = asynchronousIO
/// Sets a max number of items inside the collection
fileprivate(set) var limit: Int? = nil
init(store: Store, inMemory: Bool = false) async {
self.store = store
if self.inMemory == false {
await self.loadFromFile()
}
}
init(store: Store, indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil, noLoad: Bool = false) {
if indexed {
self._indexes = [:]
}
self._inMemory = inMemory
self._sendsUpdate = sendsUpdate
self._store = store
self.inMemory = inMemory
self.store = store
self.limit = limit
self._load()
if noLoad {
self.hasLoaded = true
} else {
Task {
await self.load()
}
}
fileprivate init() {
self.synchronized = false
self._store = Store.main
}
public static func placeholder() -> StoredCollection<T> {
return StoredCollection<T>()
init(store: Store) {
self.store = store
}
var resourceName: String {
var storeCenter: StoreCenter { return self.store.storeCenter }
/// Returns the name of the managed resource
public var resourceName: String {
return T.resourceName()
}
// MARK: - Loading
public var storeId: String? {
return self.store.identifier
}
/// Migrates if necessary and asynchronously decodes the json file
fileprivate func _load() {
// MARK: - Loading
do {
if self._inMemory {
Task {
try await self.loadDataFromServerIfAllowed()
/// Sets the collection as changed to trigger a write
public func requestWriteIfNecessary() {
if self.inMemory == false {
self._triggerWrite = true
}
}
/// Migrates if necessary and asynchronously decodes the json file
func load() async {
if !self.inMemory {
await self.loadFromFile()
} else {
try self._loadFromFile()
await MainActor.run {
self.setAsLoaded()
}
}
}
/// Starts the JSON file decoding asynchronously
func loadFromFile() async {
do {
try await self._decodeJSONFile()
} catch {
Logger.error(error)
await MainActor.run {
self.setAsLoaded()
}
do {
let fileURL = try self.store.fileURL(type: T.self)
let jsonString: String = try FileUtils.readFile(fileURL: fileURL)
if !jsonString.isEmpty {
StoreCenter.main.log(message: "Could not decode: \(jsonString)")
}
} catch {
/// Starts the JSON file decoding synchronously or asynchronously
fileprivate func _loadFromFile() throws {
if self.asynchronousIO {
Task(priority: .high) {
try self._decodeJSONFile()
}
} else {
try self._decodeJSONFile()
}
}
}
/// Decodes the json file into the items array
fileprivate func _decodeJSONFile() throws {
fileprivate func _decodeJSONFile() async 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()) {
let jsonString: String = try FileUtils.readFile(fileURL: fileURL)
let decoded: [T] = try jsonString.decodeArray() ?? []
for var item in decoded {
item.store = self._store
}
if self.asynchronousIO {
DispatchQueue.main.async {
self._setItems(decoded)
self._setAsLoaded()
self.hasLoaded = true // avoid pending management
self.setItems(decoded)
}
} else {
self._setItems(decoded)
self._setAsLoaded()
}
} else {
self._setAsLoaded()
await MainActor.run {
self.setAsLoaded()
}
}
/// Sets the collection as loaded
/// Send a CollectionDidLoad event
fileprivate func _setAsLoaded() {
@MainActor
func setAsLoaded() {
self.hasLoaded = true
DispatchQueue.main.async {
NotificationCenter.default.post(name: NSNotification.Name.CollectionDidLoad, object: self)
}
self._mergePendingOperations()
NotificationCenter.default.post(
name: NSNotification.Name.CollectionDidLoad, object: self)
}
/// Sets a collection of items and indexes them
fileprivate func _setItems(_ items: [T]) {
self.items = items
self._updateIndexIfNecessary()
func setItems(_ items: [T]) {
self.clear()
for item in items {
self._addItem(instance: item)
}
}
@MainActor
func loadAndWrite(_ items: [T], clear: Bool = false) {
if clear {
self.setItems(items)
self.setAsLoaded()
} else {
self.setAsLoaded()
self.addOrUpdate(contentOfs: items)
}
self.requestWriteIfNecessary()
}
/// Updates the whole index with the items array
fileprivate func _updateIndexIfNecessary() {
if let _ = self._indexes {
self._indexes = self.items.dictionary { $0.stringId }
if self._indexes != nil {
self._indexes = self.items.dictionary { $0.id }
}
}
/// Retrieves the data from the server and loads it into the items array
public func loadDataFromServerIfAllowed() async throws {
guard self.synchronized, !(self is StoredSingleton<T>) else {
throw StoreError.cannotSyncCollection(name: self.resourceName)
// MARK: - Basic operations
/// Adds or updates the provided instance inside the collection
/// Adds it if its id is not found, and otherwise updates it
@discardableResult public func addOrUpdate(instance: T) -> ActionResult<T> {
defer {
self.requestWriteIfNecessary()
}
do {
let items: [T] = try await self._store.getItems()
if items.count > 0 {
DispatchQueue.main.async {
self._addOrUpdate(contentOfs: items, shouldSync: false)
return self._rawAddOrUpdate(instance: instance)
}
/// Adds or update a sequence of elements
public func addOrUpdate(contentOfs sequence: any Sequence<T>, _ handler: ((ActionResult<T>) -> ())? = nil) {
defer {
self.requestWriteIfNecessary()
}
} catch {
Logger.error(error)
for instance in sequence {
let result = self._rawAddOrUpdate(instance: instance)
handler?(result)
}
self._setAsLoaded()
}
/// 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()
fileprivate func _rawAddOrUpdate(instance: T) -> ActionResult<T> {
if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
let updated = self._updateItem(instance, index: index, actionOption: .standard)
return ActionResult(instance: instance, method: .update, pending: !updated)
} else {
let added = self._addItem(instance: instance)
return ActionResult(instance: instance, method: .insert, pending: !added)
}
}
// MARK: - Basic operations
/// A method the treat the collection as a single instance holder
func setSingletonNoSync(instance: T) {
defer {
self.requestWriteIfNecessary()
}
self.clear()
self._addItem(instance: instance)
}
/// Adds or updates the provided instance inside the collection
/// Adds it if its id is not found, and otherwise updates it
public func addOrUpdate(instance: T) throws {
/// Deletes the instance in the collection and sets the collection as changed to trigger a write
public func delete(instance: T) {
self.delete(instance: instance, actionOption: .cascade)
}
/// Deletes the instance in the collection and sets the collection as changed to trigger a write
public func delete(instance: T, actionOption: ActionOption) {
defer {
self._hasChanged = true
self._triggerWrite = true
}
self.deleteItem(instance, actionOption: actionOption)
}
/// Deletes all items of the sequence by id and sets the collection as changed to trigger a write
public func delete(contentOfs sequence: any RandomAccessCollection<T>, _ handler: ((ActionResult<T>) -> ())? = nil) {
self.delete(contentOfs: sequence, actionOption: .cascade, handler: handler)
}
var item = instance
item.store = self._store
func delete(contentOfs sequence: any RandomAccessCollection<T>, actionOption: ActionOption, handler: ((ActionResult<T>) -> ())? = nil) {
// update
if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
self.items[index] = instance
self._sendUpdateIfNecessary(instance)
} else { // insert
self.items.append(instance)
self._sendInsertionIfNecessary(instance)
defer {
self._triggerWrite = true
}
self._indexes?[instance.stringId] = instance
for instance in sequence {
let deleted = self.deleteItem(instance, actionOption: actionOption)
handler?(ActionResult(instance: instance, method: .delete, pending: !deleted))
}
}
/// Sends a POST request for the instance, and changes the collection to perform a write
public func writeChangeAndInsertOnServer(instance: T) {
defer {
self._hasChanged = true
/// This method sets the storeId for the given instance if the collection belongs to a store with an id
fileprivate func _affectStoreIdIfNecessary(instance: T) {
if let storeId = self.store.identifier {
if var altStorable = instance as? SideStorable {
altStorable.storeId = storeId
} else {
fatalError("instance does not implement SideStorable, thus sync cannot work")
}
}
self._sendInsertionIfNecessary(instance)
}
/// A method the treat the collection as a single instance holder
func setSingletonNoSync(instance: T) {
defer {
self._hasChanged = true
func add(instance: T, actionOption: ActionOption) {
self._addItem(instance: instance, actionOption: actionOption)
}
self.items.removeAll()
/// Adds an instance to the collection
@discardableResult fileprivate func _addItem(instance: T, actionOption: ActionOption = .standard) -> Bool {
if !self.hasLoaded {
self.addPendingOperation(method: .add, instance: instance, actionOption: actionOption)
return false
}
self.invalidateCache()
self._affectStoreIdIfNecessary(instance: instance)
self.items.append(instance)
instance.store = self.store
self._indexes?[instance.id] = instance
self._applyLimitIfPresent()
if T.storeParent() {
_ = self.storeCenter.requestStore(identifier: instance.stringId) // make directory
}
/// Deletes the instance in the collection by id
public func delete(instance: T) throws {
return true
}
defer {
self._hasChanged = true
func update(_ instance: T, index: Int, actionOption: ActionOption) {
self._updateItem(instance, index: index, actionOption: actionOption)
// self.requestWrite()
}
try instance.deleteDependencies()
self.items.removeAll { $0.id == instance.id }
self._indexes?.removeValue(forKey: instance.stringId)
/// Updates an instance to the collection by index
@discardableResult fileprivate func _updateItem(_ instance: T, index: Int, actionOption: ActionOption) -> Bool {
self._sendDeletionIfNecessary(instance)
if !self.hasLoaded {
self.addPendingOperation(method: .update, instance: instance, actionOption: actionOption)
return false
}
self.invalidateCache()
/// Deletes all items of the sequence by id
public func delete(contentOfs sequence: any Sequence<T>) throws {
let item = self.items[index]
if item !== instance {
self.items[index].copy(from: instance)
}
defer {
self._hasChanged = true
instance.store = self.store
self._indexes?[instance.id] = instance
return true
}
for instance in sequence {
try instance.deleteDependencies()
self.items.removeAll { $0.id == instance.id }
self._indexes?.removeValue(forKey: instance.stringId)
self._sendDeletionIfNecessary(instance)
/// Deletes an instance from the collection
@discardableResult fileprivate func deleteItem(_ instance: T, actionOption: ActionOption = .cascade) -> Bool {
if !self.hasLoaded {
self.addPendingOperation(method: .delete, instance: instance, actionOption: actionOption)
return false
}
if actionOption.cascade {
instance.deleteDependencies(store: self.store, actionOption: actionOption)
}
/// Adds or update a sequence of elements
public func addOrUpdate(contentOfs sequence: any Sequence<T>) throws {
self._addOrUpdate(contentOfs: sequence)
self.localDeleteOnly(instance: instance)
if T.storeParent() {
self.storeCenter.destroyStore(identifier: instance.stringId)
}
/// Adds or update a sequence of elements without synchronizing it
func addOrUpdateNoSync(contentOfs sequence: any Sequence<T>) throws {
self._addOrUpdate(contentOfs: sequence, shouldSync: false)
return true
}
/// Inserts or updates all items in the sequence
fileprivate func _addOrUpdate(contentOfs sequence: any Sequence<T>, shouldSync: Bool = true) {
defer {
self._hasChanged = true
/// Deletes an instance from the collection
@discardableResult func deleteUnusedShared(_ instance: T, actionOption: ActionOption) -> Bool {
if !self.hasLoaded {
self.addPendingOperation(method: .deleteUnusedShared, instance: instance, actionOption: actionOption)
return false
}
for var instance in sequence {
if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
self.items[index] = instance
if shouldSync {
self._sendUpdateIfNecessary(instance)
// For shared objects, we need to check for dependencies that are also shared
// but not used elsewhere before deleting them
instance.deleteUnusedSharedDependencies(store: self.store)
self.localDeleteOnly(instance: instance)
return true
}
} else { // insert
self.items.append(instance)
if shouldSync {
self._sendInsertionIfNecessary(instance)
func localDeleteOnly(instance: T) {
self.invalidateCache()
self.items.removeAll { $0.id == instance.id }
self._indexes?.removeValue(forKey: instance.id)
}
/// If the collection has more instance that its limit, remove the surplus
fileprivate func _applyLimitIfPresent() {
if let limit {
self.items = self.items.suffix(limit)
}
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]
public func findById(_ id: String) -> T? {
public func findById(_ id: T.ID) -> T? {
if let index = self._indexes, let instance = index[id] {
return instance
}
return self.items.first(where: { $0.id == id })
}
/// 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
/// Deletes a list of items
public func deleteDependencies(_ items: any Sequence<T>) {
defer {
self._hasChanged = true
self.requestWriteIfNecessary()
}
for item in items {
self.invalidateCache()
let itemsArray = Array(items) // fix error if items is self.items
for item in itemsArray {
if let index = self.items.firstIndex(where: { $0.id == item.id }) {
self.items.remove(at: index)
}
Task {
do {
try await StoreCenter.main.deleteApiCallByDataId(type: T.self, id: item.stringId)
} catch {
Logger.error(error)
}
}
public func deleteAllItemsAndDependencies(actionOption: ActionOption) {
self._delete(contentOfs: self.items, actionOption: actionOption)
}
public func deleteDependencies(actionOption: ActionOption, _ isIncluded: (T) -> Bool) {
let items = self.items.filter(isIncluded)
self._delete(contentOfs: items, actionOption: actionOption)
}
/// Proceeds to delete all instance of the collection, properly cleaning up dependencies and sending API calls
public func deleteAll() throws {
try self.delete(contentOfs: self.items)
fileprivate func _delete(contentOfs sequence: any RandomAccessCollection<T>, actionOption: ActionOption) {
for instance in sequence {
self.deleteItem(instance, actionOption: actionOption)
}
// MARK: - Migrations
}
/// Makes POST ApiCall for all items in the collection
public func insertAllIntoCurrentService() {
for item in self.items {
self._sendInsertionIfNecessary(item)
// MARK: - Pending operations
func addPendingOperation(method: StorageMethod, instance: T, actionOption: ActionOption) {
if self.pendingOperationManager == nil {
self.pendingOperationManager = PendingOperationManager<T>(store: self.store, inMemory: self.inMemory)
}
self._addPendingOperationIfPossible(method: method, instance: instance, actionOption: actionOption)
}
fileprivate func _addPendingOperationIfPossible(method: StorageMethod, instance: T, actionOption: ActionOption) {
self.pendingOperationManager?.addPendingOperation(method: method, instance: instance, actionOption: actionOption)
}
/// Makes POST ApiCall for the provided item
public func insertIntoCurrentService(item: T) {
self._sendInsertionIfNecessary(item)
fileprivate func _mergePendingOperations() {
guard let manager = self.pendingOperationManager, manager.items.isNotEmpty else { return }
Logger.log(">>> Merge pending \(manager.typeName): \(manager.items.count)")
for item in manager.items {
let data = item.data
switch item.method {
case .add, .update:
self.addOrUpdate(instance: data)
case .delete:
self.deleteItem(data, actionOption: item.actionOption)
case .deleteUnusedShared:
self.deleteUnusedShared(data, actionOption: item.actionOption)
}
// MARK: - SomeCall
}
manager.reset()
/// Returns the collection items as [any Storable]
func allItems() -> [any Storable] {
return self.items
self.pendingOperationManager = nil
}
// MARK: - File access
/// Schedules a write operation
fileprivate func _scheduleWrite() {
guard !self._inMemory else { return }
if self.asynchronousIO {
DispatchQueue(label: "lestorage.queue.write", qos: .utility).asyncAndWait { // sync to make sure we don't have writes performed at the same time
self._write()
self._cleanTimer()
DispatchQueue.main.async {
self._writingTimer = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(self._write), userInfo: nil, repeats: false)
}
} else {
self._write()
}
fileprivate func _cleanTimer() {
self._writingTimer?.invalidate()
self._writingTimer = nil
}
/// Writes all the items as a json array inside a file
fileprivate func _write() {
// Logger.log("Start write to \(T.fileName())...")
@objc fileprivate func _write() {
DispatchQueue(label: "lestorage.queue.write", qos: .utility).async {
do {
let jsonString: String = try self.items.jsonString()
try self._store.write(content: jsonString, fileName: T.fileName())
try self.store.write(content: jsonString, fileName: T.fileName())
} catch {
Logger.error(error) // TODO how to notify the main project
Logger.error(error)
self.storeCenter.log(
message: "write failed for \(T.resourceName()): \(error.localizedDescription)")
}
// Logger.log("End write")
}
self._cleanTimer()
}
/// Simply clears the items of the collection
func clear() {
public func clear() {
self.invalidateCache()
self.items.removeAll()
}
/// Removes the items of the collection and deletes the corresponding file
public func reset() {
self.items.removeAll()
self._store.removeFile(type: T.self)
self.clear()
self.store.removeFile(type: T.self)
}
// MARK: - Reschedule calls
public var type: any Storable.Type { return T.self }
/// Sends an insert api call for the provided
/// Calls copyFromServerInstance on the instance with the result of the HTTP call
/// - Parameters:
/// - instance: the object to POST
fileprivate func _sendInsertionIfNecessary(_ instance: T) {
guard self.synchronized else {
return
}
Task {
do {
if let result = try await self._store.sendInsertion(instance) {
DispatchQueue.main.async {
self._hasChanged = instance.copyFromServerInstance(result)
}
// MARK: - Reference count
/// Counts the references to an object - given its type and id - inside the collection
public func hasParentReferences<S: Storable>(type: S.Type, id: String) -> Bool {
let relationships = T.parentRelationships().filter { $0.type == type }
guard relationships.count > 0 else { return false }
for item in self.items {
for relationship in relationships {
if item[keyPath: relationship.keyPath] as? String == id {
return true
}
} catch {
Logger.error(error)
}
}
return false
}
/// Sends an update api call for the provided [instance]
/// - Parameters:
/// - instance: the object to PUT
fileprivate func _sendUpdateIfNecessary(_ instance: T) {
guard self.synchronized, self._sendsUpdate else {
return
}
Task {
do {
try await self._store.sendUpdate(instance)
} catch {
Logger.error(error)
}
// MARK: - for Synced Collection
@MainActor
func updateLocalInstance(_ serverInstance: T) {
if let localInstance = self.findById(serverInstance.id) {
localInstance.copy(from: serverInstance)
self.requestWriteIfNecessary()
}
}
/// Sends an delete api call for the provided [instance]
/// - Parameters:
/// - instance: the object to DELETE
fileprivate func _sendDeletionIfNecessary(_ instance: T) {
guard self.synchronized else {
return
}
Task {
do {
try await self._store.sendDeletion(instance)
} catch {
Logger.error(error)
// MARK: - Cached queries
fileprivate var _cacheVersion = 0
fileprivate var _queryCache: [AnyHashable: (version: Int, result: Any)] = [:]
// Generic query method with caching
public func cached<Result>(
key: AnyHashable,
compute: ([T]) -> Result
) -> Result {
if let cached = self._queryCache[key],
cached.version == self._cacheVersion,
let result = cached.result as? Result {
return result
}
let result = compute(items)
self._queryCache[key] = (self._cacheVersion, result)
return result
}
private func invalidateCache() {
self._cacheVersion += 1
}
// MARK: - RandomAccessCollection
}
extension StoredCollection: RandomAccessCollection {
public static func placeholder() -> StoredCollection<T> {
return StoredCollection<T>(store: Store(storeCenter: StoreCenter.main))
}
public var startIndex: Int { return self.items.startIndex }
@ -480,13 +617,13 @@ public class StoredCollection<T: Storable>: RandomAccessCollection, SomeCollecti
return self.items.index(after: i)
}
open subscript(index: Int) -> T {
public subscript(index: Int) -> T {
get {
return self.items[index]
}
set(newValue) {
self.items[index] = newValue
self._hasChanged = true
self._triggerWrite = true
}
}

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

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

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

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

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

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

@ -0,0 +1,26 @@
//
// 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,14 +20,63 @@ public class ErrorUtils {
}
public enum ServiceError: Error {
public enum ServiceError: Error, LocalizedError {
case urlCreationError(url: String)
case cantConvertToUUID(id: String)
case missingUserName
case missingUserId
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 {
public enum UUIDError: Error, LocalizedError {
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,18 +9,23 @@ import Foundation
extension FileManager {
func createDirectoryInDocuments(directoryName: String) -> Bool {
@discardableResult func createDirectoryInDocuments(directoryName: String) -> Bool {
let documentsDirectory = self.urls(for: .documentDirectory, in: .userDomainMask).first!
let directoryURL = documentsDirectory.appendingPathComponent(directoryName)
if !self.fileExists(atPath: directoryURL.path) {
do {
try self.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil)
try self.createDirectory(at: directoryURL,
withIntermediateDirectories: true,
attributes: nil)
Logger.log("directory created : \(directoryURL)")
return true
} catch {
Logger.error(error)
return false
}
return true
} else {
Logger.log("directory exists : \(directoryURL)")
return false
}
}

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

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

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

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

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

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

@ -0,0 +1,24 @@
//
// 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"
}
}

@ -0,0 +1,39 @@
//
// 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://"
}
}
}

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

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

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

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

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

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

Loading…
Cancel
Save