Compare commits

...

195 Commits

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
Laurent ecc4791342 Fix crash 1 year ago
Laurent e134581896 Fix memory collection not being set as loaded 1 year ago
Laurent d94cf11a9f Fix user creation issue 1 year ago
Laurent a8bd538857 Adds a service to delete accounts 1 year ago
Laurent f5d3972eba cleanup 1 year ago
Laurent 3044bc23aa Remove logs 1 year ago
Laurent 16ef0d73e8 Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/LeStorage 1 year ago
Laurent 0a926b76dd Fix logout service 1 year ago
Razmig Sarkissian 9e96415e73 rename debug options 1 year ago
Laurent dc8f72885f Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/LeStorage 1 year ago
Laurent 79601f9e90 Adds service to send APNs device token 1 year ago
Razmig Sarkissian e1e04ee357 version mini 1 year ago
Laurent 6981ebeb23 More documentation 1 year ago
Laurent 7a9f560ccf cleanup and documentation 1 year ago
Laurent a978bbcbfa Stores a deviceId inside the keychain to avoid changes 1 year ago
Laurent 9d3935b563 Adds login/logout methods to avoid multi authentication 1 year ago
Laurent 234bb42ea7 Data migration capabilities + bug fix for singleton 1 year ago
Laurent fe78bbbe43 Fix potential item change in background thread 1 year ago
Laurent 298dc6357f Fix empty collection not being loaded 1 year ago
Laurent bb7d00df97 Adds Log collection 1 year ago
Laurent 63692c572b Fixes regression with data copy after inserts 1 year ago
Laurent db0602dcf7 Adds load state 1 year ago
Laurent f94b3d604b Refactor function name 1 year ago
Laurent a2283fce6d Fix unsync-ed collection trying to load from server + doc 1 year ago
  1. 188
      LeStorage.xcodeproj/project.pbxproj
  2. 4
      LeStorage.xcodeproj/xcshareddata/xcschemes/LeStorage.xcscheme
  3. 55
      LeStorage.xcodeproj/xcshareddata/xcschemes/LeStorageTests.xcscheme
  4. 439
      LeStorage/ApiCallCollection.swift
  5. 11
      LeStorage/CLAUDE.md
  6. 188
      LeStorage/Codables/ApiCall.swift
  7. 83
      LeStorage/Codables/DataAccess.swift
  8. 44
      LeStorage/Codables/DataLog.swift
  9. 87
      LeStorage/Codables/FailedAPICall.swift
  10. 57
      LeStorage/Codables/GetSyncData.swift
  11. 73
      LeStorage/Codables/Log.swift
  12. 34
      LeStorage/Codables/PendingOperation.swift
  13. 16
      LeStorage/Codables/Settings.swift
  14. 83
      LeStorage/Codables/SyncData.swift
  15. 78
      LeStorage/ModelObject.swift
  16. 60
      LeStorage/NetworkMonitor.swift
  17. 18
      LeStorage/Notification+Name.swift
  18. 66
      LeStorage/PendingOperationManager.swift
  19. 31
      LeStorage/Relationship.swift
  20. 809
      LeStorage/Services.swift
  21. 71
      LeStorage/Storable.swift
  22. 497
      LeStorage/Store.swift
  23. 1171
      LeStorage/StoreCenter.swift
  24. 64
      LeStorage/StoreLibrary.swift
  25. 814
      LeStorage/StoredCollection.swift
  26. 37
      LeStorage/StoredSingleton.swift
  27. 478
      LeStorage/SyncedCollection.swift
  28. 57
      LeStorage/SyncedStorable.swift
  29. 50
      LeStorage/Utils/ClassLoader.swift
  30. 59
      LeStorage/Utils/Codable+Extensions.swift
  31. 4
      LeStorage/Utils/Collection+Extension.swift
  32. 33
      LeStorage/Utils/Date+Extensions.swift
  33. 26
      LeStorage/Utils/Dictionary+Extensions.swift
  34. 54
      LeStorage/Utils/Errors.swift
  35. 11
      LeStorage/Utils/FileManager+Extensions.swift
  36. 9
      LeStorage/Utils/FileUtils.swift
  37. 12
      LeStorage/Utils/Formatter.swift
  38. 42
      LeStorage/Utils/KeychainStore.swift
  39. 8
      LeStorage/Utils/Logger.swift
  40. 44
      LeStorage/Utils/MockKeychainStore.swift
  41. 17
      LeStorage/Utils/String+Extensions.swift
  42. 24
      LeStorage/Utils/UIDevice+Extensions.swift
  43. 39
      LeStorage/Utils/URLManager.swift
  44. 151
      LeStorage/WebSocketManager.swift
  45. 133
      LeStorageTests/ApiCallTests.swift
  46. 87
      LeStorageTests/CollectionsTests.swift
  47. 110
      LeStorageTests/IdentifiableTests.swift
  48. 141
      LeStorageTests/StoredCollectionTests.swift
  49. 14
      README.md

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

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1530" LastUpgradeVersion = "1630"
version = "1.7"> version = "1.7">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
@ -35,7 +35,7 @@
parallelizable = "YES"> parallelizable = "YES">
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "C425D43D2B6D24E1002A7B48" BlueprintIdentifier = "C4C33F6A2C9B06B7006316DE"
BuildableName = "LeStorageTests.xctest" BuildableName = "LeStorageTests.xctest"
BlueprintName = "LeStorageTests" BlueprintName = "LeStorageTests"
ReferencedContainer = "container:LeStorage.xcodeproj"> 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 // LeStorage
// //
// Created by Laurent Morvillier on 17/06/2024. // Created by Laurent Morvillier on 17/06/2024.
@ -7,35 +7,51 @@
import Foundation import Foundation
protocol SomeCallCollection { protocol SomeCallCollection {
func findCallById(_ id: String) async -> (any SomeCall)? func findCallById(_ id: String) async -> (any SomeCall)?
func deleteById(_ id: String) async func deleteById(_ id: String) async
func hasPendingCalls() async -> Bool func hasPendingCalls() async -> Bool
func contentOfFile() async -> String? func contentOfFile() async -> String?
func reset() async func reset() async
func resumeApiCalls() async
func type() async -> any Storable.Type
func resourceName() async -> String
} }
enum ApiCallError: Error, LocalizedError {
case encodingError(id: String, type: String)
var errorDescription: String? {
switch self {
case .encodingError(let id, let type):
return "Can't encode instance \(type) with id: \(id)"
}
}
}
/// ApiCallCollection is an object communicating with a server to synchronize data managed locally /// ApiCallCollection is an object communicating with a server to synchronize data managed locally
/// The Api calls are serialized and stored in a JSON file /// The Api calls are serialized and stored in a JSON file
/// Failing Api calls are stored forever and will be executed again later /// Failing Api calls are stored forever and will be executed again later
actor ApiCallCollection<T: Storable>: SomeCallCollection { actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
fileprivate var storeCenter: StoreCenter
/// The list of api calls /// The list of api calls
fileprivate(set) var items: [ApiCall<T>] = [] fileprivate(set) var items: [ApiCall<T>] = []
/// The number of time an execution loop has been called /// The number of time an execution loop has been called
fileprivate var _attemptLoops: Int = 0 fileprivate var _attemptLoops: Int = 0
/// Indicates if the collection is currently retrying ApiCalls /// Indicates if the collection is currently retrying ApiCalls
fileprivate var _isRetryingCalls: Bool = false fileprivate var _isExecutingCalls: Bool = false
fileprivate var _schedulingTask: Task<(), Never>? = nil
fileprivate var _executionTask: Task<Void, any Error>? = nil
/// Indicates whether the collection content has changed /// Indicates whether the collection content has changed
/// Initiates a write when true /// Initiates a write when true
fileprivate var _hasChanged: Bool = false { fileprivate var _hasChanged: Bool = false {
@ -46,28 +62,35 @@ actor ApiCallCollection<T: Storable>: SomeCallCollection {
} }
} }
} }
init(storeCenter: StoreCenter) {
self.storeCenter = storeCenter
}
/// Starts the JSON file decoding synchronously or asynchronously /// Starts the JSON file decoding synchronously or asynchronously
/// Reschedule Api calls if not empty /// Reschedule Api calls if not empty
func loadFromFile() throws { func loadFromFile() throws {
try self._decodeJSONFile() try self._decodeJSONFile()
self.rescheduleApiCallsIfNecessary()
} }
/// Returns the file URL of the collection /// Returns the file URL of the collection
fileprivate func _urlForJSONFile() throws -> URL { fileprivate func _urlForJSONFile() throws -> URL {
return try ApiCall<T>.urlForJSONFile() return try self.storeCenter.jsonFileURL(for: ApiCall<T>.self)
} }
/// Decodes the json file into the items array /// Decodes the json file into the items array
fileprivate func _decodeJSONFile() throws { fileprivate func _decodeJSONFile() throws {
let fileURL = try self._urlForJSONFile() let fileURL = try self._urlForJSONFile()
if FileManager.default.fileExists(atPath: fileURL.path()) { if FileManager.default.fileExists(atPath: fileURL.path()) {
let jsonString: String = try FileUtils.readFile(fileURL: fileURL) let jsonString: String = try FileUtils.readFile(fileURL: fileURL)
let decoded: [ApiCall<T>] = try jsonString.decodeArray() ?? [] do {
// Logger.log("loaded \(fileURL.lastPathComponent) with \(decoded.count) items") let decoded: [ApiCall<T>] = try jsonString.decodeArray() ?? []
self.items = decoded self.items = decoded
} catch {
let decoded: [OldApiCall<T>] = try jsonString.decodeArray() ?? []
self.items = decoded.compactMap { $0.toNewApiCall() }
}
} }
} }
@ -75,17 +98,15 @@ actor ApiCallCollection<T: Storable>: SomeCallCollection {
fileprivate func _write() { fileprivate func _write() {
let fileName = ApiCall<T>.fileName() let fileName = ApiCall<T>.fileName()
DispatchQueue(label: "lestorage.queue.write", qos: .utility).asyncAndWait { DispatchQueue(label: "lestorage.queue.write", qos: .utility).asyncAndWait {
Logger.log("Start write to \(fileName)...")
do { do {
let jsonString: String = try self.items.jsonString() let jsonString: String = try self.items.jsonString()
try T.writeToStorageDirectory(content: jsonString, fileName: fileName) try self.storeCenter.write(content: jsonString, fileName: fileName)
} catch { } catch {
Logger.error(error) Logger.error(error)
} }
Logger.log("End write")
} }
} }
/// Adds or update an API call instance /// Adds or update an API call instance
func addOrUpdate(_ instance: ApiCall<T>) { func addOrUpdate(_ instance: ApiCall<T>) {
if let index = self.items.firstIndex(where: { $0.id == instance.id }) { if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
@ -95,26 +116,27 @@ actor ApiCallCollection<T: Storable>: SomeCallCollection {
} }
self._hasChanged = true self._hasChanged = true
} }
/// Deletes an API call by [id] /// Deletes an API call by [id]
func deleteById(_ id: String) { func deleteById(_ id: String) {
self.items.removeAll(where: { $0.id == id }) self.items.removeAll(where: { $0.id == id })
// Logger.log("\(T.resourceName()) > Delete by id, count after deletion = \(self.items.count)")
self._hasChanged = true self._hasChanged = true
} }
/// Deletes a call by a data id /// Deletes a call by a data id
func deleteByDataId(_ dataId: String) { func deleteByDataId(_ dataId: String) {
if let apiCallIndex = self.items.firstIndex(where: { $0.dataId == dataId }) { if let apiCallIndex = self.items.firstIndex(where: { $0.data?.stringId == dataId }) {
self.items.remove(at: apiCallIndex) self.items.remove(at: apiCallIndex)
self._hasChanged = true self._hasChanged = true
} }
} }
/// Returns the Api call associated with the provided id /// Returns the Api call associated with the provided id
func findById(_ id: String) -> ApiCall<T>? { func findById(_ id: String) -> ApiCall<T>? {
return self.items.first(where: { $0.id == id }) return self.items.first(where: { $0.id == id })
} }
/// Returns the Api call associated with the provided id /// Returns the Api call associated with the provided id
func findCallById(_ id: String) async -> (any SomeCall)? { func findCallById(_ id: String) async -> (any SomeCall)? {
return self.findById(id) return self.findById(id)
@ -122,9 +144,11 @@ actor ApiCallCollection<T: Storable>: SomeCallCollection {
/// Removes all objects in memory and deletes the JSON file /// Removes all objects in memory and deletes the JSON file
func reset() { func reset() {
self._executionTask?.cancel() self._isExecutingCalls = false
self._schedulingTask?.cancel()
self.items.removeAll() self.items.removeAll()
self._hasChanged = true
do { do {
let url: URL = try self._urlForJSONFile() let url: URL = try self._urlForJSONFile()
if FileManager.default.fileExists(atPath: url.path()) { if FileManager.default.fileExists(atPath: url.path()) {
@ -135,151 +159,281 @@ actor ApiCallCollection<T: Storable>: SomeCallCollection {
} }
} }
/// Reschedule the execution of API calls func resumeApiCalls() {
fileprivate func _rescheduleApiCalls() { 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
self._executionTask = Task {
let delay = pow(2, self._attemptLoops)
let seconds = NSDecimalNumber(decimal: delay).intValue
Logger.log("wait for \(seconds) sec")
try await Task.sleep(until: .now + .seconds(seconds))
let apiCallsCopy = self.items
for apiCall in apiCallsCopy {
apiCall.attemptsCount += 1
apiCall.lastAttemptDate = Date()
do {
try await self._executeApiCall(apiCall)
} catch {
Logger.error(error)
}
}
self._hasChanged = true /// Reschedule API calls without waiting
func rescheduleImmediately() {
self._attemptLoops = -1
self.rescheduleApiCallsIfNecessary()
}
if self.items.isEmpty { /// Reschedule API calls if necessary
self._isRetryingCalls = false func rescheduleApiCallsIfNecessary() {
} else { if self.items.isNotEmpty && !self._isExecutingCalls {
self._rescheduleApiCalls() self._schedulingTask = Task {
await self._waitAndExecuteApiCalls()
} }
} }
} }
// MARK: - Synchronization /// Reschedule the execution of API calls
fileprivate func _waitAndExecuteApiCalls() async {
guard !self._isExecutingCalls, self.storeCenter.forceNoSynchronization == false else { return }
guard self.items.isNotEmpty else { return }
self._isExecutingCalls = true
self._attemptLoops += 1
await self._wait()
await self._batchExecution()
// Logger.log("\(T.resourceName()) > EXECUTE CALLS: \(self.items.count)")
// let batches = Dictionary(grouping: self.items, by: { $0.transactionId })
//
// for batch in batches.values {
// do {
// if batch.count == 1, let apiCall = batch.first, apiCall.method == .get {
// try await self._executeGetCall(apiCall: apiCall)
// } else {
// let results = try await self._executeApiCalls(batch)
// if T.copyServerResponse {
// let instances = results.compactMap { $0.data }
// StoreCenter.main.updateLocalInstances(instances)
// }
// }
// } catch {
// Logger.error(error)
// }
// }
// Logger.log("\(T.resourceName()) > EXECUTE CALLS ENDED !")
self._isExecutingCalls = false
if self.items.isNotEmpty {
await self._waitAndExecuteApiCalls()
}
// Logger.log("\(T.resourceName()) > isRescheduling = \(self._isRescheduling)")
}
/// Returns an APICall instance for the Storable [instance] and an HTTP [method] fileprivate func _batchExecution() async {
/// The method updates existing calls or creates a new one let batches = Dictionary(grouping: self.items, by: { $0.transactionId })
fileprivate func _callForInstance(_ instance: T, method: HTTPMethod) throws -> ApiCall<T>? {
for batch in batches.values {
if let existingCall = self.items.first(where: { $0.dataId == instance.id }) { do {
switch method { if batch.count == 1, let apiCall = batch.first, apiCall.method == .get {
case .delete: try await self._executeGetCall(apiCall: apiCall)
self.deleteById(existingCall.id) // delete the existing call as we don't need it
if existingCall.method == HTTPMethod.post {
return nil // if the post has not been done, we can just stop here
} else { } else {
return try self._createCall(instance, method: method) // otherwise it's a put and we want to send the delete let results: [OperationResult<T>] = try await self._executeApiCalls(batch)
if T.copyServerResponse {
let instances: [T] = results.compactMap { $0.data }
self.storeCenter.updateLocalInstances(instances)
}
} }
default: // here we should only trying to PUT, so we update the existing POST/PUT with the instance new values } catch {
existingCall.body = try instance.jsonString() Logger.error(error)
return existingCall
} }
}
}
@discardableResult func _executeGetCall(apiCall: ApiCall<T>) async throws -> Data {
let data = try await self.storeCenter.executeGet(apiCall: apiCall)
// Logger.log("GET received = \(T.resourceName())")
if T.self == GetSyncData.self {
let syncData = try SyncData(data: data, storeCenter: self.storeCenter)
await self.storeCenter.synchronizeContent(syncData)
} else { } else {
return try self._createCall(instance, method: method) 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 makes some clean up when necessary:
/// - When deleting, we delete other calls as they are unecessary
/// - When updating, we delete other PUT as we don't want them to be executed in random orders
fileprivate func _prepareCall(instance: T, method: HTTPMethod, transactionId: String? = nil) {
// cleanup if necessary
switch method {
case .delete: // we don't want anything else than a DELETE in the queue
let existingCalls = self.items.filter { $0.data?.stringId == instance.stringId }
self._deleteCalls(existingCalls)
case .put: // we don't want mixed PUT calls so we delete the others
let existingPuts = self.items.filter { $0.data?.stringId == instance.stringId && $0.method == .put }
self._deleteCalls(existingPuts)
default:
break
}
let call: ApiCall<T> = self._createCall(method, instance: instance, transactionId: transactionId)
self._addCallToWaitingList(call)
}
/// deletes an array of ApiCall by id
fileprivate func _deleteCalls(_ calls: [ApiCall<T>]) {
for call in calls {
self.deleteById(call.id)
}
}
/// we want to avoid sending the same GET twice
fileprivate func _createGetCallIfNonExistent(_ parameters: [String : String]?, clear: Bool) -> ApiCall<T>? {
if let _ = self.items.first(where: { $0.method == .get && $0.urlParameters == parameters }) {
return nil
}
let option: CallOption? = !clear ? .additive : nil
let call = self._createCall(.get, instance: nil, option: option)
call.urlParameters = parameters
return call
}
/// Creates an API call for the Storable [instance] and an HTTP [method] /// Creates an API call for the Storable [instance] and an HTTP [method]
fileprivate func _createCall(_ instance: T, method: HTTPMethod) throws -> ApiCall<T> { fileprivate func _createCall(_ method: HTTPMethod, instance: T?, transactionId: String? = nil, option: CallOption? = nil) -> ApiCall<T> {
let jsonString = try instance.jsonString() if let instance {
return ApiCall(method: method, dataId: String(instance.id), body: jsonString) 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 /// 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.lastAttemptDate = Date()
apiCall.attemptsCount += 1 apiCall.attemptsCount += 1
self.addOrUpdate(apiCall) self.addOrUpdate(apiCall)
} }
/// Sends a GET request with an URLParameterConvertible [instance]
func sendGetRequest(instance: URLParameterConvertible) async throws {
let parameters = instance.queryParameters(storeCenter: self.storeCenter)
try await self._sendGetRequest(parameters: parameters)
}
/// Reschedule API calls if necessary /// Sends a GET request with an optional [storeId]
func rescheduleApiCallsIfNecessary() { func sendGetRequest(storeId: String?, clear: Bool = true) async throws {
if !self._isRetryingCalls { var parameters: [String : String]? = nil
self._rescheduleApiCalls() if let storeId {
parameters = [Services.storeIdURLParameter : storeId]
} }
try await self._sendGetRequest(parameters: parameters, clear: clear)
} }
/// Sends an insert api call for the provided [instance] /// Sends an insert api call for the provided [instance]
func sendInsertion(_ instance: T) { fileprivate func _sendGetRequest(parameters: [String : String]?, clear: Bool = true) async throws {
Task {
if let getCall = self._createGetCallIfNonExistent(parameters, clear: clear) {
do { do {
try await self._synchronize(instance, method: HTTPMethod.post) try await self._prepareAndSendGetCall(getCall)
} catch { } catch {
self.rescheduleApiCallsIfNecessary() self.rescheduleApiCallsIfNecessary()
Logger.error(error) Logger.error(error)
} }
} else {
self.rescheduleImmediately()
} }
} }
/// Sends an update api call for the provided [instance] /// Creates and execute the ApiCalls corresponding to the [batch]
func sendUpdate(_ instance: T) { func executeBatch(_ batch: OperationBatch<T>) {
Task { self._prepareCalls(batch: batch)
do { self.rescheduleImmediately()
try await self._synchronize(instance, method: HTTPMethod.put)
} catch {
self.rescheduleApiCallsIfNecessary()
Logger.error(error)
}
}
} }
/// Sends an delete api call for the provided [instance] func singleBatchExecution(_ batch: OperationBatch<T>) async {
func sendDeletion(_ instance: T) { self._prepareCalls(batch: batch)
Task { await self._batchExecution()
do {
try await self._synchronize(instance, method: HTTPMethod.delete)
} catch {
self.rescheduleApiCallsIfNecessary()
Logger.error(error)
}
}
} }
/// Initiates the process of sending the data with the server func executeSingleGet(instance: T) async throws -> Data where T : URLParameterConvertible {
fileprivate func _synchronize(_ instance: T, method: HTTPMethod) async throws { let call = self._createCall(.get, instance: instance, option: .none)
if let apiCall = try self._callForInstance(instance, method: method) { call.urlParameters = instance.queryParameters(storeCenter: self.storeCenter)
try self._prepareCall(apiCall: apiCall) self._addCallToWaitingList(call)
try await self._executeApiCall(apiCall) return try await self._executeGetCall(apiCall: call)
}
fileprivate func _prepareCalls(batch: OperationBatch<T>) {
let transactionId = Store.randomId()
for insert in batch.inserts {
self._prepareCall(instance: insert, method: .post, transactionId: transactionId)
}
for update in batch.updates {
self._prepareCall(instance: update, method: .put, transactionId: transactionId)
}
for delete in batch.deletes {
self._prepareCall(instance: delete, method: .delete, transactionId: transactionId)
} }
} }
/// Prepares and executes a GET call
fileprivate func _prepareAndSendGetCall(_ apiCall: ApiCall<T>) async throws {
self._addCallToWaitingList(apiCall)
try await self._executeGetCall(apiCall: apiCall)
}
/// Executes an API call /// Executes an API call
/// For POST requests, potentially copies additional data coming from the server during the insert /// For POST requests, potentially copies additional data coming from the server during the insert
fileprivate func _executeApiCall(_ apiCall: ApiCall<T>) async throws { fileprivate func _executeApiCalls(_ apiCalls: [ApiCall<T>]) async throws -> [OperationResult<T>] {
let result = try await StoreCenter.main.execute(apiCall: apiCall)
switch apiCall.method { // Logger.log("/// \(T.resourceName()) > Start \(apiCalls.count) calls execution...")
case .post:
if let instance = self.findById(result.stringId) { let results = try await self.storeCenter.execute(apiCalls: apiCalls)
self._hasChanged = instance.copyFromServerInstance(result) for result in results {
switch result.status {
case 200..<300:
self.deleteById(result.apiCallId)
default:
break
} }
default:
break
} }
// Logger.log("") return results
} }
/// Returns the content of the API call file as a String /// Returns the content of the API call file as a String
func contentOfFile() -> String? { func contentOfFile() -> String? {
guard let fileURL = try? self._urlForJSONFile() else { return nil } guard let fileURL = try? self._urlForJSONFile() else { return nil }
@ -288,10 +442,39 @@ actor ApiCallCollection<T: Storable>: SomeCallCollection {
} }
return nil return nil
} }
/// Returns if the API call collection is not empty /// Returns if the API call collection is not empty
func hasPendingCalls() -> Bool { func hasPendingCalls() -> Bool {
// print("\(T.resourceName()) calls = \(self.items.count)")
return self.items.isNotEmpty return self.items.isNotEmpty
} }
/// returns the list of API calls in the collection
func apiCalls() -> [ApiCall<T>] {
return self.items
}
func type() async -> any Storable.Type { return T.self }
func resourceName() async -> String { return T.resourceName() }
// MARK: - Testing
func sendInsertion(_ instance: T) async throws {
let batch = OperationBatch<T>()
batch.addInsert(instance)
self.executeBatch(batch)
}
func sendUpdate(_ instance: T) async throws {
let batch = OperationBatch<T>()
batch.addUpdate(instance)
self.executeBatch(batch)
}
func sendDeletion(_ instance: T) async throws {
let batch = OperationBatch<T>()
batch.addDelete(instance)
self.executeBatch(batch)
}
} }

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

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

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

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

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

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

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

@ -8,20 +8,86 @@
import Foundation import Foundation
/// A class used as the root class for Storable objects /// A class used as the root class for Storable objects
open class ModelObject { /// Provides default implementations of the Storable protocol
open class ModelObject: NSObject {
public var store: Store? = nil public var store: Store? = nil
public init() { } public override init() { }
open func deleteDependencies() throws { open func deleteDependencies(store: Store, actionOption: ActionOption) {
} }
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 {
open func copyFromServerInstance(_ instance: any Storable) -> Bool { public var storeId: String? = nil
return false
public override init() { }
// Coding Keys to map properties during encoding/decoding
enum CodingKeys: String, CodingKey {
case storeId
} }
static var relationshipNames: [String] = [] // 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
}

File diff suppressed because it is too large Load Diff

@ -8,8 +8,8 @@
import Foundation import Foundation
/// A protocol describing classes that can be stored locally in JSON and synchronized on our django server /// A protocol describing classes that can be stored locally in JSON and synchronized on our django server
public protocol Storable: Codable, Identifiable where ID : StringProtocol { public protocol Storable: Codable, Identifiable, NSObjectProtocol {
/// The store containing a reference to the instance /// The store containing a reference to the instance
var store: Store? { get set } var store: Store? { get set }
@ -17,26 +17,29 @@ public protocol Storable: Codable, Identifiable where ID : StringProtocol {
/// Also used as the name of the local file /// Also used as the name of the local file
static func resourceName() -> String static func resourceName() -> String
/// Returns HTTP methods that do not need to pass the token to the request
static func tokenExemptedMethods() -> [HTTPMethod]
/// This method is only used if the instance store uses an identifier
/// This method should return true if the resources need to get filtered using the store identifier when performing a GET
/// Returning false won't filter the resources when performing a GET
static func filterByStoreIdentifier() -> Bool
/// A method that deletes the local dependencies of the resource /// A method that deletes the local dependencies of the resource
/// Mimics the behavior 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, /// Typically when we delete a resource, we automatically delete items that depends on it,
/// so when we do that on the server, we also need to do it locally /// so when we do that on the server, we also need to do it locally
func deleteDependencies() throws func deleteDependencies(store: Store, actionOption: ActionOption)
/// A method called to retrieve data added by the server on a POST request /// A method that deletes dependencies of shared resources, but only if they are themselves shared
/// The method will be called after a POST has succeeded, /// and not referenced by other objects in the store
/// and will provide a copy of what's on the server /// This is used when cleaning up shared objects that are no longer in use
func copyFromServerInstance(_ instance: any Storable) -> Bool func deleteUnusedSharedDependencies(store: Store)
static var relationshipNames: [String] { get } /// Copies the content of another item into the instance
/// This behavior has been made to get live updates when looking at properties in SwiftUI screens
func copy(from other: any Storable)
/// This method returns RelationShips objects of the type
static func relationships() -> [Relationship]
static func parentRelationships() -> [Relationship]
static func childrenRelationships() -> [Relationship]
/// Denotes a data that own its own store
/// Effectively used to trigger directory creation when adding an item to the collection
static func storeParent() -> Bool
} }
@ -48,7 +51,16 @@ extension Storable {
} }
/// Returns a string id for the instance /// 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 /// Returns the relative path of the instance for the django server
static func path(id: String? = nil) -> String { static func path(id: String? = nil) -> String {
@ -60,20 +72,15 @@ extension Storable {
return path return path
} }
public static func storageDirectoryPath() throws -> URL { static func buildRealId(id: String) -> ID {
return try FileUtils.pathForDirectoryInDocuments(directory: Store.storageDirectory) switch ID.self {
} case is String.Type:
return id as! ID
static func writeToStorageDirectory(content: String, fileName: String) throws { case is Int64.Type:
var fileURL = try self.storageDirectoryPath() return Formatter.number.number(from: id)?.int64Value as! ID
fileURL.append(component: fileName) default:
try content.write(to: fileURL, atomically: false, encoding: .utf8) 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,66 +8,111 @@
import Foundation import Foundation
import UIKit import UIKit
//public enum ResetOption { public enum StoreError: Error, LocalizedError {
// case all
// case synchronizedOnly
//}
public enum StoreError: Error {
case missingService case missingService
case missingUserId case missingUserId
case unexpectedCollectionType(name: String) case missingUsername
case apiCallCollectionNotRegistered(type: String) case missingToken
case missingKeychainStore
case collectionNotRegistered(type: String) case collectionNotRegistered(type: String)
case cannotSyncCollection(name: String) case apiCallCollectionNotRegistered(type: String)
} case synchronizationInactive
case storeNotRegistered(id: String)
public struct StoreIdentifier { case castIssue(type: String)
var value: String case invalidStoreLookup(from: any Storable.Type, to: any Storable.Type)
var parameterName: String
public var localizedDescription: String {
public init(value: String, parameterName: String) { switch self {
self.value = value case .missingService:
self.parameterName = parameterName 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)"
}
} }
var urlComponent: String { public var errorDescription: String? {
return "?\(self.parameterName)=\(self.value)" 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)"
}
} }
} }
open class Store { final public class Store {
/// The Store singleton public fileprivate(set) var storeCenter: StoreCenter
public static let main = Store()
/// The dictionary of registered StoredCollections /// The dictionary of registered collections
fileprivate var _collections: [String : any SomeCollection] = [:] fileprivate var _collections: [String : any SomeCollection] = [:]
/// The name of the directory to store the json files /// The dictionary of all StoredCollection
static let storageDirectory = "storage" 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 /// The store identifier, used to name the store directory, and to perform filtering requests to the server
fileprivate var _created: Bool = false public fileprivate(set) var identifier: String? = nil
public init() { public init(storeCenter: StoreCenter) {
self._createDirectory(directory: Store.storageDirectory) self.storeCenter = storeCenter
} }
public required init(identifier: String, parameter: String) { public required init(storeCenter: StoreCenter, identifier: String) {
self.identifier = StoreIdentifier(value: identifier, parameterName: parameter) self.storeCenter = storeCenter
let directory = "\(Store.storageDirectory)/\(identifier)" self.identifier = identifier
let directory = "\(storeCenter.directoryName)/\(identifier)"
self._createDirectory(directory: directory) 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 /// Creates the store directory
/// - Parameters: /// - Parameters:
/// - directory: the name of the directory /// - directory: the name of the directory
fileprivate func _createDirectory(directory: String) { fileprivate func _createDirectory(directory: String) {
self._created = FileManager.default.createDirectoryInDocuments(directoryName: directory) FileManager.default.createDirectoryInDocuments(directoryName: directory)
} }
/// A method to provide ids corresponding to the django storage /// A method to provide ids corresponding to the django storage
@ -77,36 +122,72 @@ open class Store {
/// Registers a collection /// Registers a collection
/// - Parameters: /// - Parameters:
/// - synchronized: indicates if the data is synchronized with the server
/// - indexed: Creates an index to quickly access the data /// - indexed: Creates an index to quickly access the data
/// - inMemory: Indicates if the collection should only live in memory, and not write into a file /// - inMemory: Indicates if the collection should only live in memory, and not write into a file
/// - sendsUpdate: Indicates if updates of items should be sent to the server public func registerCollection<T : Storable>(indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil) -> StoredCollection<T> {
public func registerCollection<T : Storable>(synchronized: Bool, indexed: Bool = false, inMemory: Bool = false, sendsUpdate: Bool = true) -> StoredCollection<T> {
// register collection if let _ = try? self.someCollection(type: T.self) {
let collection = StoredCollection<T>(synchronized: synchronized, store: self, indexed: indexed, inMemory: inMemory, sendsUpdate: sendsUpdate) fatalError("collection already registered")
// return collection
}
let collection = StoredCollection<T>(store: self, indexed: indexed, inMemory: inMemory, limit: limit)
self._collections[T.resourceName()] = collection self._collections[T.resourceName()] = collection
self._baseCollections[T.resourceName()] = collection
if synchronized { return collection
StoreCenter.main.loadApiCallCollection(type: T.self) }
}
/// Registers a synchronized collection
/// - Parameters:
/// - indexed: Creates an index to quickly access the data
/// - inMemory: Indicates if the collection should only live in memory, and not write into a file
public func registerSynchronizedCollection<T : SyncedStorable>(indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil, noLoad: Bool = false) -> SyncedCollection<T> {
if self._created, let identifier { if let collection: SyncedCollection<T> = try? self.syncedCollection() {
self._migrate(collection, identifier: identifier, type: T.self) 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
}
func asyncLoadingSynchronizedCollection<T : SyncedStorable>(inMemory: Bool = false) async -> SyncedCollection<T> {
let collection = await SyncedCollection<T>(store: self, inMemory: inMemory)
self._collections[T.resourceName()] = collection
self._baseCollections[T.resourceName()] = collection.collection
self.storeCenter.loadApiCallCollection(type: T.self)
return collection return collection
} }
func asyncLoadingStoredCollection<T : Storable>(inMemory: Bool = false) async -> StoredCollection<T> {
let collection = await StoredCollection<T>(store: self, inMemory: inMemory)
self._collections[T.resourceName()] = collection
self._baseCollections[T.resourceName()] = collection
return collection
}
/// Registers a singleton object /// Registers a singleton object
/// - Parameters: /// - Parameters:
/// - synchronized: indicates if the data is synchronized with the server /// - synchronized: indicates if the data is synchronized with the server
/// - inMemory: Indicates if the collection should only live in memory, and not write into a file /// - inMemory: Indicates if the collection should only live in memory, and not write into a file
/// - sendsUpdate: Indicates if updates of items should be sent to the server /// - sendsUpdate: Indicates if updates of items should be sent to the server
public func registerObject<T : Storable>(synchronized: Bool, inMemory: Bool = false, 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 self._collections[T.resourceName()] = storedObject
if synchronized {
self.storeCenter.loadApiCallCollection(type: T.self)
}
return storedObject return storedObject
} }
@ -115,60 +196,241 @@ open class Store {
/// Looks for an instance by id /// Looks for an instance by id
/// - Parameters: /// - Parameters:
/// - id: the id of the data /// - id: the id of the data
public func findById<T: Storable>(_ id: String) -> T? { public func findById<T: Storable>(_ id: T.ID) -> T? {
guard let collection = self._collections[T.resourceName()] as? StoredCollection<T> else { guard let collection = self._baseCollections[T.resourceName()] as? StoredCollection<T> else {
Logger.w("Collection \(T.resourceName()) not registered") Logger.w("Collection \(T.resourceName()) not registered")
return nil return nil
} }
return collection.findById(id) return collection.findById(id)
} }
/// Filters a collection by predicate /// Returns a collection by type
/// - Parameters: func syncedCollection<T: SyncedStorable>() throws -> SyncedCollection<T> {
/// - isIncluded: a predicate to returns if a data should be filtered in if let collection = self._collections[T.resourceName()] as? SyncedCollection<T> {
public func filter<T: Storable>(isIncluded: (T) throws -> (Bool)) rethrows -> [T] { return collection
do {
return try self.collection().filter(isIncluded)
} catch {
return []
} }
throw StoreError.collectionNotRegistered(type: T.resourceName())
}
/// Returns a collection by type
func syncedCollection<T: SyncedStorable>(type: T.Type) throws -> SyncedCollection<T> {
return try self.syncedCollection()
} }
/// Returns a collection by type /// Returns a collection by type
func collection<T: Storable>() throws -> StoredCollection<T> { func someCollection<T: Storable>(type: T.Type) throws -> any SomeCollection {
if let collection = self._collections[T.resourceName()] as? StoredCollection<T> { if let collection = self._collections[T.resourceName()] {
return collection return collection
} }
throw StoreError.collectionNotRegistered(type: T.resourceName()) throw StoreError.collectionNotRegistered(type: T.resourceName())
} }
func registerOrGetSyncedCollection<T: SyncedStorable>(_ type: T.Type) -> SyncedCollection<T> {
do {
return try self.syncedCollection()
} catch {
return self.registerSynchronizedCollection(indexed: true, inMemory: false, noLoad: true)
}
}
/// Loads all collection with the data from the server /// Loads all collection with the data from the server
public func loadCollectionFromServer() { public func loadCollectionsFromServer(clear: Bool) {
for collection in self._collections.values { 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 { 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 /// Resets all registered collection
public func reset() { public func reset() {
for collection in self._collections.values { for collection in self._collections.values {
collection.reset() collection.reset()
} }
} }
// MARK: - Synchronization
fileprivate func _requestWrite<T: SyncedStorable>(type: T.Type) {
self._baseCollections[T.resourceName()]?.requestWriteIfNecessary()
}
@MainActor
func synchronizationAddOrUpdate<T: SyncedStorable>(_ instances: [T], shared: SharingStatus?) {
for item in instances {
if !self.storeCenter.hasAlreadyBeenDeleted(item) {
self.addOrUpdateIfNewer(item, shared: shared)
}
}
self._requestWrite(type: T.self)
}
/// Calls addOrUpdateIfNewer from the collection corresponding to the instance
@MainActor
func addOrUpdateIfNewer<T: SyncedStorable>(_ instance: T, shared: SharingStatus?) {
let collection: SyncedCollection<T> = self.registerOrGetSyncedCollection(T.self)
collection.addOrUpdateIfNewer(instance, shared: shared)
}
@MainActor
func synchronizationDelete<T: SyncedStorable>(_ identifiers: [ObjectIdentifier], type: T.Type) {
for identifier in identifiers {
do {
try self.deleteNoSyncNoCascadeNoWrite(type: type, id: identifier.modelId)
} catch {
Logger.error(error)
}
self.storeCenter.cleanupDataLog(dataId: identifier.modelId)
}
self._requestWrite(type: T.self)
}
@MainActor
func synchronizationRevoke<T: SyncedStorable>(_ identifiers: [ObjectIdentifier], type: T.Type) {
for identifier in identifiers {
do {
if let instance = self._instance(id: identifier.modelId, type: type) {
if instance.sharing != nil && !self.storeCenter.isReferenced(instance: instance) {
try self.deleteNoSyncNoCascadeNoWrite(type: type, id: identifier.modelId)
}
}
} catch {
Logger.error(error)
}
}
self._requestWrite(type: T.self)
/// Returns the names of all collections }
public func collectionNames() -> [String] {
return self._collections.values.map { $0.resourceName } fileprivate func _instance<T: SyncedStorable>(id: String, type: T.Type) -> T? {
let realId: T.ID = T.buildRealId(id: id)
return self.findById(realId)
}
/// Calls deleteById from the collection corresponding to the instance
func deleteNoSyncNoCascadeNoWrite<T: SyncedStorable>(type: T.Type, id: String) throws {
let collection: SyncedCollection<T> = try self.syncedCollection()
collection.deleteByStringId(id, actionOption: .noCascadeNoWrite)
}
func isReferenced<T: Storable, S: Storable>(collectionType: S.Type, type: T.Type, id: String) -> Bool {
if let collection = self._baseCollections[S.resourceName()] {
return collection.hasParentReferences(type: type, id: id)
} else {
return false
}
}
public func deleteUnusedGrantedIfNecessary<T: SyncedStorable>(_ instance: T, originStoreId: String?) {
if !self.storeCenter.isReferenced(instance: instance) {
do {
let collection: SyncedCollection<T> = try self.syncedCollection()
collection.deleteUnusedGranted(instance: instance)
} catch {
Logger.error(error)
}
}
}
public func deleteUnusedSharedDependencies<T: SyncedStorable>(type: T.Type, _ handler: (T) throws -> Bool) {
do {
let collection: SyncedCollection<T> = try self.syncedCollection()
let items = try collection.items.filter(handler)
self.deleteUnusedSharedDependencies(items)
} catch {
Logger.error(error)
}
}
public func deleteUnusedSharedDependencies<T: SyncedStorable>(type: T.Type) {
do {
let collection: SyncedCollection<T> = try self.syncedCollection()
self.deleteUnusedSharedDependencies(collection.items)
} catch {
Logger.error(error)
}
}
/// Deletes dependencies of shared objects that are not used elsewhere in the system
/// Similar to _deleteDependencies but only for unused shared objects
public func deleteUnusedSharedDependencies<T: SyncedStorable>(_ items: [T]) {
do {
for item in items {
guard item.sharing != nil else { continue }
if !self.storeCenter.isReferenced(instance: item) {
// Only delete if the shared item has no references
item.deleteUnusedSharedDependencies(store: self)
let collection: SyncedCollection<T> = try self.syncedCollection()
collection.deleteUnusedGranted(instance: item)
}
}
} catch {
Logger.error(error)
}
}
public func deleteAllDependencies<T: Storable>(type: T.Type, actionOption: ActionOption) {
do {
let collection = try self.someCollection(type: type)
collection.deleteAllItemsAndDependencies(actionOption: actionOption)
} catch {
Logger.error(error)
}
}
public func deleteDependencies<T>(type: T.Type, actionOption: ActionOption, _ isIncluded: (T) -> Bool) where T: SyncedStorable {
do {
let collection = try self.someCollection(type: type)
if let syncCollection = collection as? SyncedCollection<T> {
syncCollection.deleteDependencies(actionOption: actionOption, isIncluded)
}
} catch {
Logger.error(error)
}
}
public func deleteDependencies<T>(type: T.Type, actionOption: ActionOption, _ isIncluded: (T) -> Bool) where T: Storable {
do {
let collection = try self.someCollection(type: type)
if let syncCollection = collection as? StoredCollection<T> {
syncCollection.deleteDependencies(actionOption: actionOption, isIncluded)
}
} catch {
Logger.error(error)
}
} }
// MARK: - Write // MARK: - Write
/// Returns the directory URL of the store /// Returns the directory URL of the store
fileprivate func _directoryPath() throws -> URL { fileprivate func _directoryPath() throws -> URL {
var url = try FileUtils.pathForDirectoryInDocuments(directory: Store.storageDirectory) var url = try FileUtils.pathForDirectoryInDocuments(directory: storeCenter.directoryName)
if let identifier = self.identifier?.value { if let identifier {
url.append(component: identifier) url.append(component: identifier)
} }
return url return url
@ -182,14 +444,22 @@ open class Store {
var fileURL = try self._directoryPath() var fileURL = try self._directoryPath()
fileURL.append(component: fileName) fileURL.append(component: fileName)
try content.write(to: fileURL, atomically: false, encoding: .utf8) try content.write(to: fileURL, atomically: false, encoding: .utf8)
// Logger.log("write into \(fileURL)...")
} }
/// Returns the URL matching a Storable type /// Returns the URL matching a Storable type
/// - Parameters: /// - Parameters:
/// - type: a Storable type /// - type: a Storable type
func fileURL<T: Storable>(type: T.Type) throws -> URL { func fileURL<T: Storable>(type: T.Type) throws -> URL {
return try self.fileURL(fileName: T.fileName())
}
/// Returns the URL matching a Storable type
/// - Parameters:
/// - type: a Storable type
func fileURL(fileName: String) throws -> URL {
let fileURL = try self._directoryPath() let fileURL = try self._directoryPath()
return fileURL.appending(component: T.fileName()) return fileURL.appending(component: fileName)
} }
/// Removes a file matching a Storable type /// Removes a file matching a Storable type
@ -207,84 +477,27 @@ open class Store {
} }
/// Retrieves all the items on the server /// Retrieves all the items on the server
public func getItems<T: Storable>() async throws -> [T] { public func getItems<T: SyncedStorable>() async throws -> [T] {
if T.filterByStoreIdentifier() { if let identifier = self.identifier {
return try await StoreCenter.main.getItems(identifier: self.identifier) return try await self.storeCenter.getItems(identifier: identifier)
} else { } 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 {
try await StoreCenter.main.sendInsertion(instance)
}
/// Requests an update to the StoreCenter
/// - Parameters:
/// - instance: an object to update
func sendUpdate<T: Storable>(_ instance: T) async throws {
try await StoreCenter.main.sendUpdate(instance)
}
/// Requests a deletion to the StoreCenter
/// - Parameters:
/// - instance: an object to delete
func sendDeletion<T: Storable>(_ instance: T) async throws {
try await StoreCenter.main.sendDeletion(instance)
}
public func loadCollectionsFromServerIfNoFile() { func loadCollectionItems<T: SyncedStorable>(_ items: [T], clear: Bool) async {
for collection in self._collections.values { do {
// Logger.log("Load \(name)") let collection: SyncedCollection<T> = try self.syncedCollection()
Task { await collection.loadItems(items, clear: clear)
do { } catch {
try await collection.loadCollectionsFromServerIfNoFile() Logger.error(error)
} catch {
Logger.error(error)
}
}
}
}
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())")
} }
} }
} /// Returns whether all collections have loaded locally
public func fileCollectionsAllLoaded() -> Bool {
fileprivate extension Storable { let fileCollections = self._collections.values.filter { $0.inMemory == false }
return fileCollections.allSatisfy { $0.hasLoaded }
func stringForPropertyName(_ propertyName: String) -> String? {
let mirror = Mirror(reflecting: self)
for child in mirror.children {
if let label = child.label, label == "_\(propertyName)" {
return child.value as? String
}
}
return nil
} }
} }

File diff suppressed because it is too large Load Diff

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

@ -8,28 +8,53 @@
import Foundation import Foundation
/// A class extending the capabilities of StoredCollection but supposedly manages only one item /// A class extending the capabilities of StoredCollection but supposedly manages only one item
public class StoredSingleton<T: 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 /// Sets the singleton to the collection without synchronizing it
public func setItemNoSync(_ instance: T) { public func setItemNoSync(_ instance: T) {
self.setSingletonNoSync(instance: instance) self.collection.setSingletonNoSync(instance: instance)
} }
/// updates the existing singleton /// updates the existing singleton
public func update() throws { public func update() {
if let item = self.item() { if let item = self.item() {
try self.addOrUpdate(instance: item) self.addOrUpdate(instance: item)
} }
} }
/// Returns the singleton /// Returns the singleton
public func item() -> T? { 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 // 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") 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 import Foundation
fileprivate var jsonEncoder: JSONEncoder = { public class JSON {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase public static var encoder: JSONEncoder = {
#if DEBUG let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted encoder.keyEncodingStrategy = .convertToSnakeCase
#endif #if DEBUG
encoder.dateEncodingStrategy = .iso8601 encoder.outputFormatting = .prettyPrinted
return encoder #endif
}() encoder.dateEncodingStrategy = .custom { date, encoder in
let dateString = Date.iso8601FractionalFormatter.string(from: date)
var container = encoder.singleValueContainer()
try container.encode(dateString)
} // need dates with thousandth precision
return encoder
}()
fileprivate var jsonDecoder: JSONDecoder = { public static var decoder: JSONDecoder = {
let decoder = JSONDecoder() let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601 decoder.dateDecodingStrategy = .custom { decoder in
return decoder 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 { extension Encodable {
@ -32,11 +53,11 @@ extension Encodable {
} }
public func jsonData() throws -> Data { public func jsonData() throws -> Data {
return try jsonEncoder.encode(self) return try JSON.encoder.encode(self)
} }
public func prettyJSONString() throws -> String { public func prettyJSONString() throws -> String {
let data = try jsonEncoder.encode(self) let data = try JSON.encoder.encode(self)
return String(data: data, encoding: .utf8) ?? "" return String(data: data, encoding: .utf8) ?? ""
} }
@ -57,11 +78,11 @@ extension String {
extension Data { extension Data {
public func decode<T : Decodable>() throws -> T { 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] { 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 { 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,13 +20,63 @@ public class ErrorUtils {
} }
public enum ServiceError: Error { public enum ServiceError: Error, LocalizedError {
case urlCreationError(url: String) case urlCreationError(url: String)
case cantConvertToUUID(id: String) case cantConvertToUUID(id: String)
case missingUserName case missingUserName
case responseError(response: String) case responseError(response: String)
case cantDecodeData(resource: String, method: String, content: String?)
public var errorDescription: String? {
switch self {
case .urlCreationError(let url):
return "Can't create URL from \(url)"
case .cantConvertToUUID(let id):
return "Cant convert \(id) to UUID"
case .missingUserName:
return "There is no userName defined in the Settings"
case .responseError(let response):
return "The server returned an error: \(response)"
case .cantDecodeData(let resource, let method, let content):
return "cannot decode data from \(resource), method: \(method): \(content ?? "")"
}
}
} }
public enum UUIDError: Error { public enum UUIDError: Error, LocalizedError {
case cantConvertString(string: String) case cantConvertString(string: String)
public var errorDescription: String? {
switch self {
case .cantConvertString(let string):
return "cant convert string to UUID: \(string)"
}
}
}
public enum LeStorageError: Error, LocalizedError {
case cantFindClassFromName(name: String)
case cantCastTypeToSyncedStorable(name: String)
case cantAccessCFBundleName
case cantCreateDataAccessBecauseNotInMainStore
case cantCreateDataAccessBecauseUserIdIsNil
case dataAccessCollectionNotDefined
public var errorDescription: String? {
switch self {
case .cantFindClassFromName(let string):
return "can't find class for class name: \(string)"
case .cantCastTypeToSyncedStorable(let string):
return "can't cast type \(string) to SyncedStorable"
case .cantAccessCFBundleName:
return "can't access CFBundleName for some reason"
case .cantCreateDataAccessBecauseNotInMainStore:
return "Can't create data access because the data is not in the main Store"
case .cantCreateDataAccessBecauseUserIdIsNil:
return "Can't create data access because the there is no logged user"
case .dataAccessCollectionNotDefined:
return "Can't create data access because the collection is not defined"
}
}
} }

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

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

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

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

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

Loading…
Cancel
Save