Compare commits

..

54 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
  1. 32
      LeStorage.xcodeproj/project.pbxproj
  2. 2
      LeStorage.xcodeproj/xcshareddata/xcschemes/LeStorage.xcscheme
  3. 2
      LeStorage.xcodeproj/xcshareddata/xcschemes/LeStorageTests.xcscheme
  4. 33
      LeStorage/ApiCallCollection.swift
  5. 494
      LeStorage/BaseCollection.swift
  6. 11
      LeStorage/CLAUDE.md
  7. 13
      LeStorage/Codables/ApiCall.swift
  8. 12
      LeStorage/Codables/DataAccess.swift
  9. 7
      LeStorage/Codables/DataLog.swift
  10. 7
      LeStorage/Codables/FailedAPICall.swift
  11. 22
      LeStorage/Codables/GetSyncData.swift
  12. 19
      LeStorage/Codables/Log.swift
  13. 10
      LeStorage/Codables/PendingOperation.swift
  14. 83
      LeStorage/Codables/SyncData.swift
  15. 17
      LeStorage/ModelObject.swift
  16. 16
      LeStorage/PendingOperationManager.swift
  17. 13
      LeStorage/Relationship.swift
  18. 69
      LeStorage/Services.swift
  19. 13
      LeStorage/Storable.swift
  20. 261
      LeStorage/Store.swift
  21. 631
      LeStorage/StoreCenter.swift
  22. 64
      LeStorage/StoreLibrary.swift
  23. 630
      LeStorage/StoredCollection.swift
  24. 17
      LeStorage/StoredSingleton.swift
  25. 487
      LeStorage/SyncedCollection.swift
  26. 25
      LeStorage/SyncedStorable.swift
  27. 17
      LeStorage/Utils/String+Extensions.swift
  28. 22
      LeStorage/WebSocketManager.swift
  29. 3
      LeStorageTests/ApiCallTests.swift
  30. 40
      LeStorageTests/CollectionsTests.swift
  31. 6
      LeStorageTests/IdentifiableTests.swift
  32. 32
      LeStorageTests/StoredCollectionTests.swift

@ -20,9 +20,12 @@
C471F2582DB10649006317F4 /* MockKeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C471F2572DB10649006317F4 /* MockKeychainStore.swift */; }; C471F2582DB10649006317F4 /* MockKeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C471F2572DB10649006317F4 /* MockKeychainStore.swift */; };
C48638B32D9BC6A8007E3E06 /* PendingOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C48638B22D9BC6A8007E3E06 /* PendingOperation.swift */; }; C48638B32D9BC6A8007E3E06 /* PendingOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C48638B22D9BC6A8007E3E06 /* PendingOperation.swift */; };
C488C8802CCBDC210082001F /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C488C87F2CCBDC210082001F /* NetworkMonitor.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 /* BaseCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D4E2B6D280200ADC637 /* BaseCollection.swift */; }; C4A47D4F2B6D280200ADC637 /* StoredCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */; };
C4A47D512B6D2C4E00ADC637 /* Codable+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */; }; C4A47D512B6D2C4E00ADC637 /* Codable+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */; };
C4A47D532B6D2C5F00ADC637 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D522B6D2C5F00ADC637 /* Logger.swift */; }; C4A47D532B6D2C5F00ADC637 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D522B6D2C5F00ADC637 /* Logger.swift */; };
C4A47D552B6D2DBF00ADC637 /* FileUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D542B6D2DBF00ADC637 /* FileUtils.swift */; }; C4A47D552B6D2DBF00ADC637 /* FileUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D542B6D2DBF00ADC637 /* FileUtils.swift */; };
@ -46,6 +49,7 @@
C4D4779D2CB923720077713D /* DataLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D4779C2CB923720077713D /* DataLog.swift */; }; C4D4779D2CB923720077713D /* DataLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D4779C2CB923720077713D /* DataLog.swift */; };
C4D4779F2CB92FD80077713D /* SyncedStorable.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D4779E2CB92FD80077713D /* SyncedStorable.swift */; }; C4D4779F2CB92FD80077713D /* SyncedStorable.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D4779E2CB92FD80077713D /* SyncedStorable.swift */; };
C4D477A12CB9586A0077713D /* SyncedCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D477A02CB9586A0077713D /* SyncedCollection.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 */; }; C4FAE69A2CEB84B300790446 /* WebSocketManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FAE6992CEB84B300790446 /* WebSocketManager.swift */; };
C4FAE69C2CEB8E9500790446 /* URLManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FAE69B2CEB8E9500790446 /* URLManager.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 */; };
@ -77,9 +81,12 @@
C471F2572DB10649006317F4 /* MockKeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockKeychainStore.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>"; }; 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>"; }; 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 /* BaseCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseCollection.swift; sourceTree = "<group>"; }; C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredCollection.swift; sourceTree = "<group>"; };
C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Codable+Extensions.swift"; sourceTree = "<group>"; }; C4A47D502B6D2C4E00ADC637 /* Codable+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Codable+Extensions.swift"; sourceTree = "<group>"; };
C4A47D522B6D2C5F00ADC637 /* Logger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; }; C4A47D522B6D2C5F00ADC637 /* Logger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
C4A47D542B6D2DBF00ADC637 /* FileUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = "<group>"; }; C4A47D542B6D2DBF00ADC637 /* FileUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = "<group>"; };
@ -103,6 +110,7 @@
C4D4779C2CB923720077713D /* DataLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLog.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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; };
@ -153,6 +161,7 @@
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 */,
@ -165,9 +174,10 @@
C4AC9CE92CF754CC00CC13DF /* Relationship.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 /* BaseCollection.swift */, C4A47D4E2B6D280200ADC637 /* StoredCollection.swift */,
C4D477A02CB9586A0077713D /* SyncedCollection.swift */, C4D477A02CB9586A0077713D /* SyncedCollection.swift */,
C456EFE12BE52379007388E2 /* StoredSingleton.swift */, C456EFE12BE52379007388E2 /* StoredSingleton.swift */,
C4D4779E2CB92FD80077713D /* SyncedStorable.swift */, C4D4779E2CB92FD80077713D /* SyncedStorable.swift */,
@ -196,6 +206,7 @@
C4A47D522B6D2C5F00ADC637 /* Logger.swift */, C4A47D522B6D2C5F00ADC637 /* Logger.swift */,
C4B96E1C2D8C53D700C2955F /* UIDevice+Extensions.swift */, C4B96E1C2D8C53D700C2955F /* UIDevice+Extensions.swift */,
C4FAE69B2CEB8E9500790446 /* URLManager.swift */, C4FAE69B2CEB8E9500790446 /* URLManager.swift */,
C49779FB2DDB5D89005CD239 /* String+Extensions.swift */,
); );
path = Utils; path = Utils;
sourceTree = "<group>"; sourceTree = "<group>";
@ -219,6 +230,7 @@
C400D7222CC2AF560092237C /* GetSyncData.swift */, C400D7222CC2AF560092237C /* GetSyncData.swift */,
C4AC9CE42CEFB12100CC13DF /* DataAccess.swift */, C4AC9CE42CEFB12100CC13DF /* DataAccess.swift */,
C48638B22D9BC6A8007E3E06 /* PendingOperation.swift */, C48638B22D9BC6A8007E3E06 /* PendingOperation.swift */,
C49774DE2DC4B3D7005CD239 /* SyncData.swift */,
); );
path = Codables; path = Codables;
sourceTree = "<group>"; sourceTree = "<group>";
@ -286,7 +298,7 @@
attributes = { attributes = {
BuildIndependentTargetsInParallel = 1; BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1600; LastSwiftUpdateCheck = 1600;
LastUpgradeCheck = 1600; LastUpgradeCheck = 1630;
TargetAttributes = { TargetAttributes = {
C425D4332B6D24E1002A7B48 = { C425D4332B6D24E1002A7B48 = {
CreatedOnToolsVersion = 15.2; CreatedOnToolsVersion = 15.2;
@ -320,6 +332,7 @@
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;
@ -360,9 +373,12 @@
C4D477972CB66EEA0077713D /* Date+Extensions.swift in Sources */, C4D477972CB66EEA0077713D /* Date+Extensions.swift in Sources */,
C488C8802CCBDC210082001F /* NetworkMonitor.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 */, C400D7232CC2AF560092237C /* GetSyncData.swift in Sources */,
C4A47D4F2B6D280200ADC637 /* BaseCollection.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 */, C462E0DC2D37B61100F3E6E4 /* Notification+Name.swift in Sources */,
C4A47D812B7665AD00ADC637 /* Migration.swift in Sources */, C4A47D812B7665AD00ADC637 /* Migration.swift in Sources */,
@ -435,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;
@ -501,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;
@ -532,7 +550,6 @@
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";
@ -566,7 +583,6 @@
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";
@ -597,7 +613,6 @@
buildSettings = { buildSettings = {
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; IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@ -614,7 +629,6 @@
buildSettings = { buildSettings = {
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; IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1600" LastUpgradeVersion = "1630"
version = "1.7"> version = "1.7">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1600" LastUpgradeVersion = "1630"
version = "1.7"> version = "1.7">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"

@ -188,7 +188,6 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
/// Reschedule the execution of API calls /// Reschedule the execution of API calls
fileprivate func _waitAndExecuteApiCalls() async { fileprivate func _waitAndExecuteApiCalls() async {
// Logger.log("\(T.resourceName()) > RESCHED")
guard !self._isExecutingCalls, self.storeCenter.forceNoSynchronization == false else { return } guard !self._isExecutingCalls, self.storeCenter.forceNoSynchronization == false else { return }
guard self.items.isNotEmpty else { return } guard self.items.isNotEmpty else { return }
@ -236,9 +235,9 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
if batch.count == 1, let apiCall = batch.first, apiCall.method == .get { if batch.count == 1, let apiCall = batch.first, apiCall.method == .get {
try await self._executeGetCall(apiCall: apiCall) try await self._executeGetCall(apiCall: apiCall)
} else { } else {
let results = try await self._executeApiCalls(batch) let results: [OperationResult<T>] = try await self._executeApiCalls(batch)
if T.copyServerResponse { if T.copyServerResponse {
let instances = results.compactMap { $0.data } let instances: [T] = results.compactMap { $0.data }
self.storeCenter.updateLocalInstances(instances) self.storeCenter.updateLocalInstances(instances)
} }
} }
@ -248,13 +247,27 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
} }
} }
fileprivate func _executeGetCall(apiCall: ApiCall<T>) async throws { @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 { if T.self == GetSyncData.self {
let _: Empty = try await self.storeCenter.executeGet(apiCall: apiCall) let syncData = try SyncData(data: data, storeCenter: self.storeCenter)
await self.storeCenter.synchronizeContent(syncData)
} else { } else {
let results: [T] = try await self.storeCenter.executeGet(apiCall: apiCall) let results: [T] = try self._decode(data: data)
await self.storeCenter.itemsRetrieved(results, storeId: apiCall.storeId, clear: apiCall.option != .additive) 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 /// Wait for an exponentionnaly long time depending on the number of attemps
@ -376,11 +389,11 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
await self._batchExecution() await self._batchExecution()
} }
func executeSingleGet(instance: T) async where T : URLParameterConvertible { func executeSingleGet(instance: T) async throws -> Data where T : URLParameterConvertible {
let call = self._createCall(.get, instance: instance, option: .none) let call = self._createCall(.get, instance: instance, option: .none)
call.urlParameters = instance.queryParameters(storeCenter: self.storeCenter) call.urlParameters = instance.queryParameters(storeCenter: self.storeCenter)
self._addCallToWaitingList(call) self._addCallToWaitingList(call)
await self._batchExecution() return try await self._executeGetCall(apiCall: call)
} }
fileprivate func _prepareCalls(batch: OperationBatch<T>) { fileprivate func _prepareCalls(batch: OperationBatch<T>) {
@ -405,6 +418,9 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
/// Executes an API call /// Executes an API call
/// For POST requests, potentially copies additional data coming from the server during the insert /// For POST requests, potentially copies additional data coming from the server during the insert
fileprivate func _executeApiCalls(_ apiCalls: [ApiCall<T>]) async throws -> [OperationResult<T>] { fileprivate func _executeApiCalls(_ apiCalls: [ApiCall<T>]) async throws -> [OperationResult<T>] {
// Logger.log("/// \(T.resourceName()) > Start \(apiCalls.count) calls execution...")
let results = try await self.storeCenter.execute(apiCalls: apiCalls) let results = try await self.storeCenter.execute(apiCalls: apiCalls)
for result in results { for result in results {
switch result.status { switch result.status {
@ -429,6 +445,7 @@ actor ApiCallCollection<T: SyncedStorable>: SomeCallCollection {
/// Returns if the API call collection is not empty /// Returns if the API call collection is not empty
func hasPendingCalls() -> Bool { func hasPendingCalls() -> Bool {
// print("\(T.resourceName()) calls = \(self.items.count)")
return self.items.isNotEmpty return self.items.isNotEmpty
} }

@ -1,494 +0,0 @@
//
// BaseCollection.swift
// LeStorage
//
// Created by Laurent Morvillier on 02/02/2024.
//
import Foundation
public protocol CollectionHolder {
associatedtype Item: Storable
var items: [Item] { get }
func reset()
}
public protocol SomeCollection: CollectionHolder, Identifiable {
var resourceName: String { get }
var hasLoaded: Bool { get }
var inMemory: Bool { get }
var type: any Storable.Type { get }
func referenceCount<S: Storable>(type: S.Type, id: String) -> Int
func findById(_ id: Item.ID) -> Item?
}
public class BaseCollection<T: Storable>: SomeCollection, CollectionHolder {
/// Doesn't write the collection in a file
fileprivate(set) public var inMemory: Bool = false
/// The list of stored items
@Published public fileprivate(set) var items: [T] = []
/// The reference to the Store
fileprivate(set) var store: Store
/// Provides fast access for instances if the collection has been instanced with [indexed] = true
fileprivate var _indexes: [T.ID: T]? = nil
/// A PendingOperationManager instance that manages operations while the collection is not loaded
fileprivate(set) var pendingOperationManager: PendingOperationManager<T>? = nil
/// Indicates whether the collection has changed, thus requiring a write operation
fileprivate var _hasChanged: Bool = false {
didSet {
if self._hasChanged == true {
self._scheduleWrite()
DispatchQueue.main.async {
NotificationCenter.default.post(
name: NSNotification.Name.CollectionDidChange, object: self)
}
self._hasChanged = false
}
}
}
/// Indicates if the collection has loaded locally, with or without a file
fileprivate(set) public var hasLoaded: Bool = false
/// Sets a max number of items inside the collection
fileprivate(set) var limit: Int? = nil
init(store: Store, indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil, synchronousLoading: Bool = false) {
if indexed {
self._indexes = [:]
}
self.inMemory = inMemory
self.store = store
self.limit = limit
if synchronousLoading {
Task {
await self.loadFromFile()
}
} else {
Task(priority: .high) {
await self.load()
}
}
}
init(store: Store) {
self.store = store
}
var storeCenter: StoreCenter { return self.store.storeCenter }
/// Returns the name of the managed resource
public var resourceName: String {
return T.resourceName()
}
public var storeId: String? {
return self.store.identifier
}
// MARK: - Loading
/// Sets the collection as changed to trigger a write
func setChanged() {
self._hasChanged = true
}
/// Migrates if necessary and asynchronously decodes the json file
func load() async {
if !self.inMemory {
await self.loadFromFile()
} else {
await MainActor.run {
self.setAsLoaded()
}
}
}
/// Starts the JSON file decoding synchronously or asynchronously
func loadFromFile() async {
do {
try await self._decodeJSONFile()
} catch {
Logger.error(error)
}
}
/// Decodes the json file into the items array
fileprivate func _decodeJSONFile() async throws {
let fileURL = try self.store.fileURL(type: T.self)
if FileManager.default.fileExists(atPath: fileURL.path()) {
let jsonString: String = try FileUtils.readFile(fileURL: fileURL)
let decoded: [T] = try jsonString.decodeArray() ?? []
self.setItems(decoded)
}
await MainActor.run {
self.setAsLoaded()
}
}
/// Sets the collection as loaded
/// Send a CollectionDidLoad event
@MainActor
func setAsLoaded() {
self.hasLoaded = true
self._mergePendingOperations()
NotificationCenter.default.post(
name: NSNotification.Name.CollectionDidLoad, object: self)
}
/// Sets a collection of items and indexes them
func setItems(_ items: [T]) {
for item in items {
item.store = self.store
}
self.items = items
self._updateIndexIfNecessary()
}
/// Updates the whole index with the items array
fileprivate func _updateIndexIfNecessary() {
if self._indexes != nil {
self._indexes = self.items.dictionary { $0.id }
}
}
// MARK: - Basic operations
/// Adds or updates the provided instance inside the collection
/// Adds it if its id is not found, and otherwise updates it
public func addOrUpdate(instance: T) {
self.addOrUpdateItem(instance: instance)
}
/// Adds or update an instance inside the collection and writes
func addOrUpdateItem(instance: T) {
defer {
self._hasChanged = true
}
if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
self.updateItem(instance, index: index)
} else {
self.addItem(instance: instance)
}
}
/// A method the treat the collection as a single instance holder
func setSingletonNoSync(instance: T) {
defer {
self._hasChanged = true
}
self.items.removeAll()
self.addItem(instance: instance)
}
/// Deletes an item by its id
public func deleteById(_ id: T.ID) {
if let instance = self.findById(id) {
self.delete(instance: instance)
}
}
/// Deletes the instance in the collection and sets the collection as changed to trigger a write
public func delete(instance: T) {
defer {
self._hasChanged = true
}
self.deleteItem(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>) {
defer {
self._hasChanged = true
}
for instance in sequence {
self.deleteItem(instance)
}
}
/// Adds or update a sequence of elements
public func addOrUpdate(contentOfs sequence: any Sequence<T>) {
self.addSequence(sequence)
// self._addOrUpdate(contentOfs: sequence)
}
/// Adds a sequence of objects inside the collection and performs a write
func addSequence(_ sequence: any Sequence<T>, checkLoaded: Bool = true) {
defer {
self._hasChanged = true
}
for instance in sequence {
if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
self.updateItem(instance, index: index, checkLoaded: checkLoaded)
} else { // insert
self.addItem(instance: instance, checkLoaded: checkLoaded)
}
}
}
/// 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")
}
}
}
/// Adds an instance to the collection
@discardableResult func addItem(instance: T, checkLoaded: Bool = true, shouldBeSynchronized: Bool = false) -> Bool {
if checkLoaded && !self.hasLoaded {
self._addPendingOperation(method: .addOrUpdate, instance: instance, shouldBeSynchronized: shouldBeSynchronized)
return false
}
self._affectStoreIdIfNecessary(instance: instance)
self.items.append(instance)
instance.store = self.store
self._indexes?[instance.id] = instance
self._applyLimitIfPresent()
return true
}
/// Updates an instance to the collection by index
@discardableResult func updateItem(_ instance: T, index: Int, checkLoaded: Bool = true, shouldBeSynchronized: Bool = false) -> Bool {
if checkLoaded && !self.hasLoaded {
self._addPendingOperation(method: .addOrUpdate, instance: instance, shouldBeSynchronized: shouldBeSynchronized)
return false
}
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 func deleteItem(_ instance: T, shouldBeSynchronized: Bool = false) -> Bool {
if !self.hasLoaded {
self._addPendingOperation(method: .addOrUpdate, instance: instance, shouldBeSynchronized: shouldBeSynchronized)
return false
}
instance.deleteDependencies(shouldBeSynchronized: shouldBeSynchronized)
self.items.removeAll { $0.id == instance.id }
self._indexes?.removeValue(forKey: instance.id)
return true
}
/// If the collection has more instance that its limit, remove the surplus
fileprivate func _applyLimitIfPresent() {
if let limit {
self.items = self.items.suffix(limit)
}
}
/// Returns the instance corresponding to the provided [id]
public func findById(_ id: T.ID) -> T? {
if let index = self._indexes, let instance = index[id] {
return instance
}
return self.items.first(where: { $0.id == id })
}
/// Proceeds to "hard" delete the items without synchronizing them
/// Also removes related API calls
public func deleteDependencies(_ items: any Sequence<T>) {
defer {
self._hasChanged = true
}
let itemsArray = Array(items) // fix error if items is self.items
for item in itemsArray {
if let index = self.items.firstIndex(where: { $0.id == item.id }) {
self.items.remove(at: index)
}
}
}
/// Proceeds to delete all instance of the collection, properly cleaning up dependencies and sending API calls
public func deleteAll() throws {
self.delete(contentOfs: self.items)
}
// MARK: - Pending operations
fileprivate func _addPendingOperation(method: StorageMethod, instance: T, shouldBeSynchronized: Bool) {
if self.pendingOperationManager == nil {
self.pendingOperationManager = PendingOperationManager<T>(store: self.store, inMemory: self.inMemory)
}
self.addPendingOperation(method: method, instance: instance, shouldBeSynchronized: false)
}
func addPendingOperation(method: StorageMethod, instance: T, shouldBeSynchronized: Bool) {
self.pendingOperationManager?.addPendingOperation(method: method, instance: instance, shouldBeSynchronized: shouldBeSynchronized)
}
fileprivate func _mergePendingOperations() {
guard let manager = self.pendingOperationManager, manager.items.isNotEmpty else { return }
Logger.log(">>> Merge pending: \(manager.items.count)")
for item in manager.items {
let data = item.data
switch (item.method, item.shouldBeSynchronized) {
case (.addOrUpdate, true):
self.addOrUpdate(instance: data)
case (.addOrUpdate, false):
self.addOrUpdateItem(instance: data)
case (.delete, true):
self.delete(instance: data)
case (.delete, false):
self.deleteItem(data)
}
}
// let methodGroups = manager.items.group { $0.method }
// for (method, group) in methodGroups {
// let dataArray = group.map { $0.data }
// switch method {
// case .addOrUpdate:
// self.addOrUpdate(contentOfs: dataArray)
// case .delete:
// self.delete(contentOfs: dataArray)
// }
// }
self.pendingOperationManager = nil
}
// MARK: - File access
/// Schedules a write operation
fileprivate func _scheduleWrite() {
guard !self.inMemory else { return }
DispatchQueue(label: "lestorage.queue.write", qos: .utility).asyncAndWait { // sync to make sure we don't have writes performed at the same time
self._write()
}
}
/// Writes all the items as a json array inside a file
fileprivate func _write() {
do {
let jsonString: String = try self.items.jsonString()
try self.store.write(content: jsonString, fileName: T.fileName())
} catch {
Logger.error(error)
self.storeCenter.log(
message: "write failed for \(T.resourceName()): \(error.localizedDescription)")
}
}
/// Simply clears the items of the collection
public func clear() {
self.items.removeAll()
}
/// Removes the items of the collection and deletes the corresponding file
public func reset() {
self.items.removeAll()
self.store.removeFile(type: T.self)
setChanged()
}
public var type: any Storable.Type { return T.self }
// MARK: - Reference count
/// Counts the references to an object - given its type and id - inside the collection
public func referenceCount<S: Storable>(type: S.Type, id: String) -> Int {
let relationships = T.relationships().filter { $0.type == type }
guard relationships.count > 0 else { return 0 }
return self.items.reduce(0) { count, item in
count
+ relationships.filter { relationship in
(item[keyPath: relationship.keyPath] as? String) == id
}.count
}
}
}
public class StoredCollection<T: Storable>: BaseCollection<T>, RandomAccessCollection {
/// Returns a dummy StoredCollection instance
public static func placeholder() -> StoredCollection<T> {
return StoredCollection<T>(store: Store(storeCenter: StoreCenter.main))
}
// MARK: - RandomAccessCollection
public var startIndex: Int { return self.items.startIndex }
public var endIndex: Int { return self.items.endIndex }
public func index(after i: Int) -> Int {
return self.items.index(after: i)
}
open subscript(index: Int) -> T {
get {
return self.items[index]
}
set(newValue) {
self.items[index] = newValue
self._hasChanged = true
}
}
}
extension SyncedCollection: RandomAccessCollection {
public var startIndex: Int { return self.items.startIndex }
public var endIndex: Int { return self.items.endIndex }
public func index(after i: Int) -> Int {
return self.items.index(after: i)
}
public subscript(index: Int) -> T {
get {
return self.items[index]
}
set(newValue) {
self.items[index] = newValue
self._hasChanged = true
}
}
}

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

@ -30,6 +30,7 @@ public class ApiCall<T: Storable>: ModelObject, Storable, SomeCall {
public static func resourceName() -> String { return "apicalls_" + T.resourceName() } public static func resourceName() -> String { return "apicalls_" + T.resourceName() }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] } static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
public static func storeParent() -> Bool { return false }
public var id: String = Store.randomId() public var id: String = Store.randomId()
@ -69,6 +70,9 @@ public class ApiCall<T: Storable>: ModelObject, Storable, SomeCall {
public func copy(from other: any Storable) { public func copy(from other: any Storable) {
fatalError("should not happen") fatalError("should not happen")
} }
public func copyForUpdate(from other: any Storable) {
fatalError("should not happen")
}
func formattedURLParameters() -> String? { func formattedURLParameters() -> String? {
return self.urlParameters?.toQueryString() return self.urlParameters?.toQueryString()
@ -103,12 +107,16 @@ public class ApiCall<T: Storable>: ModelObject, Storable, SomeCall {
var storeId: String? { return self.urlParameters?[Services.storeIdURLParameter] } var storeId: String? { return self.urlParameters?[Services.storeIdURLParameter] }
public static func relationships() -> [Relationship] { return [] } public static func relationships() -> [Relationship] { return [] }
public static func parentRelationships() -> [Relationship] { return [] }
public static func childrenRelationships() -> [Relationship] { return [] }
} }
class OldApiCall<T: Storable>: ModelObject, Storable, SomeCall { class OldApiCall<T: Storable>: ModelObject, Storable, SomeCall {
static func resourceName() -> String { return "apicalls_" + T.resourceName() } static func resourceName() -> String { return "apicalls_" + T.resourceName() }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] } static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
static func storeParent() -> Bool { return false }
var id: String = Store.randomId() var id: String = Store.randomId()
@ -157,6 +165,9 @@ class OldApiCall<T: Storable>: ModelObject, Storable, SomeCall {
func copy(from other: any Storable) { func copy(from other: any Storable) {
fatalError("should not happen") fatalError("should not happen")
} }
public func copyForUpdate(from other: any Storable) {
fatalError("should not happen")
}
func formattedURLParameters() -> String? { func formattedURLParameters() -> String? {
return self.urlParameters?.toQueryString() return self.urlParameters?.toQueryString()
@ -180,6 +191,8 @@ class OldApiCall<T: Storable>: ModelObject, Storable, SomeCall {
var dataContent: String? { return self.body } var dataContent: String? { return self.body }
static func relationships() -> [Relationship] { return [] } static func relationships() -> [Relationship] { return [] }
public static func parentRelationships() -> [Relationship] { return [] }
public static func childrenRelationships() -> [Relationship] { return [] }
func toNewApiCall() -> ApiCall<T>? { func toNewApiCall() -> ApiCall<T>? {
if let instance: T = try? self.body?.decode() { if let instance: T = try? self.body?.decode() {

@ -12,7 +12,11 @@ class DataAccess: SyncedModelObject, SyncedStorable {
static func tokenExemptedMethods() -> [HTTPMethod] { return [] } static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
static func resourceName() -> String { return "data-access" } static func resourceName() -> String { return "data-access" }
static func relationships() -> [Relationship] { return [] } static func relationships() -> [Relationship] { return [] }
public static func parentRelationships() -> [Relationship] { return [] }
public static func childrenRelationships() -> [Relationship] { return [] }
static var copyServerResponse: Bool = false static var copyServerResponse: Bool = false
static func storeParent() -> Bool { return false }
override required init() { override required init() {
super.init() super.init()
@ -24,12 +28,13 @@ class DataAccess: SyncedModelObject, SyncedStorable {
var modelId: String = "" var modelId: String = ""
var grantedAt: Date = Date() var grantedAt: Date = Date()
init(owner: String, sharedWith: [String], modelName: String, modelId: String) { init(owner: String, sharedWith: [String], modelName: String, modelId: String, storeId: String?) {
self.sharedWith = sharedWith self.sharedWith = sharedWith
self.modelName = modelName self.modelName = modelName
self.modelId = modelId self.modelId = modelId
super.init() super.init()
self.relatedUser = owner self.relatedUser = owner
self.storeId = storeId
} }
// Codable implementation // Codable implementation
@ -67,7 +72,12 @@ class DataAccess: SyncedModelObject, SyncedStorable {
self.sharedWith = dataAccess.sharedWith self.sharedWith = dataAccess.sharedWith
self.modelName = dataAccess.modelName self.modelName = dataAccess.modelName
self.modelId = dataAccess.modelId self.modelId = dataAccess.modelId
self.storeId = dataAccess.storeId
self.grantedAt = dataAccess.grantedAt self.grantedAt = dataAccess.grantedAt
} }
public func copyForUpdate(from other: any Storable) {
self.copy(from: other)
}
} }

@ -12,6 +12,10 @@ class DataLog: ModelObject, Storable {
static func resourceName() -> String { return "data-logs" } static func resourceName() -> String { return "data-logs" }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] } static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
static func relationships() -> [Relationship] { 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() var id: String = Store.randomId()
@ -33,5 +37,8 @@ class DataLog: ModelObject, Storable {
func copy(from other: any Storable) { func copy(from other: any Storable) {
fatalError("should not happen") fatalError("should not happen")
} }
public func copyForUpdate(from other: any Storable) {
fatalError("should not happen")
}
} }

@ -12,7 +12,11 @@ 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 relationships() -> [Relationship] { return [] } static func relationships() -> [Relationship] { return [] }
public static func parentRelationships() -> [Relationship] { return [] }
public static func childrenRelationships() -> [Relationship] { return [] }
static var copyServerResponse: Bool = false static var copyServerResponse: Bool = false
static func storeParent() -> Bool { return false }
override required init() { override required init() {
self.callId = "" self.callId = ""
@ -102,5 +106,8 @@ class FailedAPICall: SyncedModelObject, SyncedStorable {
self.error = fac.error self.error = fac.error
self.authentication = fac.authentication self.authentication = fac.authentication
} }
public func copyForUpdate(from other: any Storable) {
self.copy(from: other)
}
} }

@ -9,19 +9,26 @@ import Foundation
class GetSyncData: SyncedModelObject, SyncedStorable, URLParameterConvertible { class GetSyncData: SyncedModelObject, SyncedStorable, URLParameterConvertible {
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
static var copyServerResponse: Bool = false
static func storeParent() -> Bool { return false }
var date: String = "" var date: String = ""
enum CodingKeys: String, CodingKey {
case date
}
override required init() { override required init() {
super.init() super.init()
} }
required public init(from decoder: Decoder) throws { required public init(from decoder: Decoder) throws {
fatalError("init(from:) has not been implemented") let container = try decoder.container(keyedBy: CodingKeys.self)
date = try container.decode(String.self, forKey: .date)
try super.init(from: decoder)
} }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
static var copyServerResponse: Bool = false
static func resourceName() -> String { static func resourceName() -> String {
return "sync-data" return "sync-data"
} }
@ -30,7 +37,9 @@ class GetSyncData: SyncedModelObject, SyncedStorable, URLParameterConvertible {
guard let getSyncData = other as? GetSyncData else { return } guard let getSyncData = other as? GetSyncData else { return }
self.date = getSyncData.date self.date = getSyncData.date
} }
public func copyForUpdate(from other: any Storable) {
fatalError("should not happen")
}
func queryParameters(storeCenter: StoreCenter) -> [String : String] { func queryParameters(storeCenter: StoreCenter) -> [String : String] {
return ["last_update" : self._formattedLastUpdate, return ["last_update" : self._formattedLastUpdate,
"device_id" : storeCenter.deviceId()] "device_id" : storeCenter.deviceId()]
@ -42,4 +51,7 @@ class GetSyncData: SyncedModelObject, SyncedStorable, URLParameterConvertible {
} }
static func relationships() -> [Relationship] { return [] } static func relationships() -> [Relationship] { return [] }
public static func parentRelationships() -> [Relationship] { return [] }
public static func childrenRelationships() -> [Relationship] { return [] }
} }

@ -12,27 +12,33 @@ class Log: SyncedModelObject, SyncedStorable {
static func resourceName() -> String { return "logs" } static func resourceName() -> String { return "logs" }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] } static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
static func relationships() -> [Relationship] { return [] } static func relationships() -> [Relationship] { return [] }
public static func parentRelationships() -> [Relationship] { return [] }
public static func childrenRelationships() -> [Relationship] { return [] }
static var copyServerResponse: Bool = false static var copyServerResponse: Bool = false
static func storeParent() -> Bool { return false }
override required init() { override required init() {
super.init() super.init()
} }
var id: String = Store.randomId() var id: String = Store.randomId()
var date: Date = Date() var date: Date = Date()
var user: String? = nil
var message: String = "" var message: String = ""
init(message: String) { init(message: String, user: String?) {
self.message = message self.message = message
self.user = user
super.init() super.init()
} }
// MARK: - Codable // MARK: - Codable
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id case id
case date case date
case user
case message case message
} }
@ -40,6 +46,7 @@ class Log: SyncedModelObject, SyncedStorable {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id) id = try container.decode(String.self, forKey: .id)
date = try container.decode(Date.self, forKey: .date) date = try container.decode(Date.self, forKey: .date)
user = try container.decodeIfPresent(String.self, forKey: .user)
message = try container.decode(String.self, forKey: .message) message = try container.decode(String.self, forKey: .message)
try super.init(from: decoder) try super.init(from: decoder)
} }
@ -48,15 +55,19 @@ class Log: SyncedModelObject, SyncedStorable {
var container = encoder.container(keyedBy: CodingKeys.self) var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id) try container.encode(id, forKey: .id)
try container.encode(date, forKey: .date) try container.encode(date, forKey: .date)
try container.encodeIfPresent(user, forKey: .user)
try container.encode(message, forKey: .message) try container.encode(message, forKey: .message)
try super.encode(to: encoder) try super.encode(to: encoder)
} }
func copy(from other: any Storable) { func copy(from other: any Storable) {
guard let log = other as? Log else { return } guard let log = other as? Log else { return }
self.date = log.date self.date = log.date
self.message = log.message self.message = log.message
} }
public func copyForUpdate(from other: any Storable) {
fatalError("should not happen")
}
} }

@ -8,8 +8,10 @@
import Foundation import Foundation
enum StorageMethod: String, Codable { enum StorageMethod: String, Codable {
case addOrUpdate case add
case update
case delete case delete
case deleteUnusedShared
} }
class PendingOperation<T : Storable>: Codable, Equatable { class PendingOperation<T : Storable>: Codable, Equatable {
@ -17,12 +19,12 @@ class PendingOperation<T : Storable>: Codable, Equatable {
var id: String = Store.randomId() var id: String = Store.randomId()
var method: StorageMethod var method: StorageMethod
var data: T var data: T
var shouldBeSynchronized: Bool var actionOption: ActionOption
init(method: StorageMethod, data: T, shouldBeSynchronized: Bool) { init(method: StorageMethod, data: T, actionOption: ActionOption) {
self.method = method self.method = method
self.data = data self.data = data
self.shouldBeSynchronized = shouldBeSynchronized self.actionOption = actionOption
} }
static func == (lhs: PendingOperation, rhs: PendingOperation) -> Bool { static func == (lhs: PendingOperation, rhs: PendingOperation) -> Bool {

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

@ -15,10 +15,15 @@ open class ModelObject: NSObject {
public override init() { } public override init() { }
open func deleteDependencies(shouldBeSynchronized: Bool) { 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] = [] static var relationshipNames: [String] = []
} }
@ -51,7 +56,7 @@ open class SyncedModelObject: BaseModelObject {
public var relatedUser: String? = nil public var relatedUser: String? = nil
public var lastUpdate: Date = Date() public var lastUpdate: Date = Date()
public var shared: Bool? public var sharing: SharingStatus?
public override init() { public override init() {
super.init() super.init()
@ -60,7 +65,7 @@ open class SyncedModelObject: BaseModelObject {
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case relatedUser case relatedUser
case lastUpdate case lastUpdate
case shared = "_shared" case sharing = "_sharing"
} }
// Required initializer for Decodable // Required initializer for Decodable
@ -68,7 +73,7 @@ open class SyncedModelObject: BaseModelObject {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
self.relatedUser = try container.decodeIfPresent(String.self, forKey: .relatedUser) self.relatedUser = try container.decodeIfPresent(String.self, forKey: .relatedUser)
self.lastUpdate = try container.decodeIfPresent(Date.self, forKey: .lastUpdate) ?? Date() self.lastUpdate = try container.decodeIfPresent(Date.self, forKey: .lastUpdate) ?? Date()
self.shared = try container.decodeIfPresent(Bool.self, forKey: .shared) self.sharing = try container.decodeIfPresent(SharingStatus.self, forKey: .sharing)
try super.init(from: decoder) try super.init(from: decoder)
} }
@ -78,8 +83,8 @@ open class SyncedModelObject: BaseModelObject {
var container = encoder.container(keyedBy: CodingKeys.self) var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(relatedUser, forKey: .relatedUser) try container.encode(relatedUser, forKey: .relatedUser)
try container.encode(lastUpdate, forKey: .lastUpdate) try container.encode(lastUpdate, forKey: .lastUpdate)
if self.shared == true { if self.sharing != nil {
try container.encodeIfPresent(shared, forKey: .shared) try container.encodeIfPresent(sharing, forKey: .sharing)
} }
try super.encode(to: encoder) try super.encode(to: encoder)

@ -11,11 +11,13 @@ class PendingOperationManager<T: Storable> {
fileprivate(set) var items: [PendingOperation<T>] = [] fileprivate(set) var items: [PendingOperation<T>] = []
fileprivate var _fileName: String = "pending_\(T.resourceName())" fileprivate var _fileName: String
fileprivate var _inMemory: Bool = false fileprivate var _inMemory: Bool = false
init(store: Store, inMemory: Bool) { init(store: Store, inMemory: Bool) {
self._fileName = "\(store.storeCenter.directoryName)/pending_\(T.resourceName()).json"
self._inMemory = inMemory self._inMemory = inMemory
if !inMemory { if !inMemory {
do { do {
@ -32,10 +34,12 @@ class PendingOperationManager<T: Storable> {
} }
} }
func addPendingOperation(method: StorageMethod, instance: T, shouldBeSynchronized: Bool) { var typeName: String { return String(describing: T.self) }
func addPendingOperation(method: StorageMethod, instance: T, actionOption: ActionOption) {
Logger.log("addPendingOperation: \(method), \(instance)") Logger.log("addPendingOperation: \(method), \(instance)")
let operation = PendingOperation<T>(method: method, data: instance, shouldBeSynchronized: shouldBeSynchronized) let operation = PendingOperation<T>(method: method, data: instance, actionOption: actionOption)
self.items.append(operation) self.items.append(operation)
self._writeIfNecessary() self._writeIfNecessary()
@ -48,15 +52,15 @@ class PendingOperationManager<T: Storable> {
fileprivate func _writeIfNecessary() { fileprivate func _writeIfNecessary() {
guard !self._inMemory else { return } guard !self._inMemory else { return }
Task(priority: .background) {
do { do {
let jsonString: String = try self.items.jsonString() let jsonString: String = try self.items.jsonString()
Task(priority: .background) {
let _ = try FileUtils.writeToDocumentDirectory(content: jsonString, fileName: self._fileName) let _ = try FileUtils.writeToDocumentDirectory(content: jsonString, fileName: self._fileName)
}
} catch { } catch {
Logger.error(error) Logger.error(error)
} }
}
} }
} }

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

@ -33,10 +33,10 @@ let changePasswordCall: ServiceCall = ServiceCall(
path: "change-password/", method: .put, requiresToken: true) path: "change-password/", method: .put, requiresToken: true)
let postDeviceTokenCall: ServiceCall = ServiceCall( let postDeviceTokenCall: ServiceCall = ServiceCall(
path: "device-token/", method: .post, requiresToken: true) path: "device-token/", method: .post, requiresToken: true)
let getUserDataAccessCall: ServiceCall = ServiceCall( let getUserDataAccessCallContent: ServiceCall = ServiceCall(
path: "data-access-content/", method: .get, requiresToken: true) path: "data-access-content/", method: .get, requiresToken: true)
let userNamesCall: ServiceCall = ServiceCall( let userAgentsCall: ServiceCall = ServiceCall(
path: "user-names/", method: .get, requiresToken: true) path: "user-supervisors/", method: .get, requiresToken: true)
/// A class used to send HTTP request to the django server /// A class used to send HTTP request to the django server
public class Services { public class Services {
@ -55,6 +55,15 @@ public class Services {
// MARK: - Base // MARK: - Base
/// Runs a request on the API and returns the appropriate response
/// - Parameters:
/// - path: the path of the service in the api, ie. "create-users/"
/// - method: the HTTP method to call
/// - requiresToken: whether the token must be included in the request
public func run<U: Decodable>(path: String, method: HTTPMethod, requiresToken: Bool) async throws -> U {
return try await self._runRequest(serviceCall: ServiceCall(path: path, method: method, requiresToken: requiresToken))
}
/// Runs a request using a configuration object /// Runs a request using a configuration object
/// - Parameters: /// - Parameters:
/// - serviceConf: A instance of ServiceConf /// - serviceConf: A instance of ServiceConf
@ -81,9 +90,9 @@ public class Services {
/// - Parameters: /// - Parameters:
/// - request: the URLRequest to run /// - request: the URLRequest to run
/// - apiCallId: the id of the ApiCall to delete in case of success, or to schedule for a rerun in case of failure /// - apiCallId: the id of the ApiCall to delete in case of success, or to schedule for a rerun in case of failure
fileprivate func _runGetApiCallRequest<T: SyncedStorable, V: Decodable>( fileprivate func _runGetApiCallRequest<T: SyncedStorable>(
_ request: URLRequest, apiCall: ApiCall<T> _ request: URLRequest, apiCall: ApiCall<T>
) async throws -> V { ) async throws -> Data {
let debugURL = request.url?.absoluteString ?? "" let debugURL = request.url?.absoluteString ?? ""
// print("Run \(request.httpMethod ?? "") \(debugURL)") // print("Run \(request.httpMethod ?? "") \(debugURL)")
let task: (Data, URLResponse) = try await URLSession.shared.data(for: request) let task: (Data, URLResponse) = try await URLSession.shared.data(for: request)
@ -91,16 +100,11 @@ public class Services {
if let response = task.1 as? HTTPURLResponse { if let response = task.1 as? HTTPURLResponse {
let statusCode = response.statusCode let statusCode = response.statusCode
print("\(debugURL) ended, status code = \(statusCode)")
switch statusCode { switch statusCode {
case 200..<300: // success case 200..<300: // success
try await self.storeCenter.deleteApiCallById(type: T.self, id: apiCall.id) try await self.storeCenter.deleteApiCallById(type: T.self, id: apiCall.id)
if T.self == GetSyncData.self {
await self.storeCenter.synchronizeContent(task.0)
}
default: // error default: // error
print("\(debugURL) ended, status code = \(statusCode)")
Logger.log( Logger.log(
"Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")") "Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
let errorString: String = String(data: task.0, encoding: .utf8) ?? "" let errorString: String = String(data: task.0, encoding: .utf8) ?? ""
@ -123,7 +127,7 @@ public class Services {
Logger.w(message) Logger.w(message)
} }
return try self._decode(data: task.0) return task.0 //try self._decode(data: task.0)
} }
@ -147,11 +151,11 @@ public class Services {
if let response = task.1 as? HTTPURLResponse { if let response = task.1 as? HTTPURLResponse {
let statusCode = response.statusCode let statusCode = response.statusCode
print("\(debugURL) ended, status code = \(statusCode)")
switch statusCode { switch statusCode {
case 200..<300: // success case 200..<300: // success
break break
default: // error default: // error
print("\(debugURL) ended, status code = \(statusCode)")
Logger.log( Logger.log(
"Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")") "Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
let errorString: String = String(data: task.0, encoding: .utf8) ?? "" let errorString: String = String(data: task.0, encoding: .utf8) ?? ""
@ -285,7 +289,6 @@ public class Services {
if let response = task.1 as? HTTPURLResponse { if let response = task.1 as? HTTPURLResponse {
let statusCode = response.statusCode let statusCode = response.statusCode
print("\(String(describing: T.self))> \(debugURL) ended, status code = \(statusCode)")
switch statusCode { switch statusCode {
case 200..<300: // success case 200..<300: // success
@ -300,7 +303,7 @@ public class Services {
default: default:
if let message = result.message { if let message = result.message {
let type = String(describing: T.self) let type = String(describing: T.self)
print("\(type) - \(result.apiCallId): \(result.status) > \(message)") print("*** \(type) - \(result.data?.stringId ?? ""): \(result.status) > \(message)")
} }
rescheduleApiCalls = true rescheduleApiCalls = true
break break
@ -308,6 +311,7 @@ public class Services {
} }
default: // error default: // error
print("\(String(describing: T.self))> \(debugURL) ended, status code = \(statusCode)")
Logger.log( Logger.log(
"Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")") "Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
let errorString: String = String(data: task.0, encoding: .utf8) ?? "" let errorString: String = String(data: task.0, encoding: .utf8) ?? ""
@ -413,6 +417,11 @@ public class Services {
storeId: apiCall.data?.getStoreId()) storeId: apiCall.data?.getStoreId())
} }
// let posts = apiCalls.filter({ $0.method == .post })
// for post in posts {
// print("POST \(T.resourceName()): id = \(post.dataId ?? "")")
// }
let payload = SyncPayload(operations: operations, let payload = SyncPayload(operations: operations,
deviceId: self.storeCenter.deviceId()) deviceId: self.storeCenter.deviceId())
request.httpBody = try JSON.encoder.encode(payload) request.httpBody = try JSON.encoder.encode(payload)
@ -420,15 +429,15 @@ public class Services {
return request return request
} }
/// Starts a request to retrieve the synchronization updates // /// Starts a request to retrieve the synchronization updates
/// - Parameters: // /// - Parameters:
/// - since: The date from which updates are retrieved // /// - since: The date from which updates are retrieved
func synchronizeLastUpdates(since: Date?) async throws { // func synchronizeLastUpdates(since: Date?) async throws {
let request = try self._getSyncLogRequest(since: since) // let request = try self._getSyncLogRequest(since: since)
if let data = try await self._runRequest(request) { // if let data = try await self._runRequest(request) {
await self.storeCenter.synchronizeContent(data) // await self.storeCenter.synchronizeContent(data)
} // }
} // }
/// Returns the URLRequest for an ApiCall /// Returns the URLRequest for an ApiCall
/// - Parameters: /// - Parameters:
@ -468,12 +477,12 @@ public class Services {
if let response = task.1 as? HTTPURLResponse { if let response = task.1 as? HTTPURLResponse {
let statusCode = response.statusCode let statusCode = response.statusCode
print("\(debugURL) ended, status code = \(statusCode)")
switch statusCode { switch statusCode {
case 200..<300: // success case 200..<300: // success
return task.0 return task.0
// success(task.0) // success(task.0)
default: // error default: // error
print("\(debugURL) ended, status code = \(statusCode)")
Logger.log( Logger.log(
"Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")") "Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
let errorString: String = String(data: task.0, encoding: .utf8) ?? "" let errorString: String = String(data: task.0, encoding: .utf8) ?? ""
@ -520,7 +529,7 @@ public class Services {
} }
/// Executes an ApiCall /// Executes an ApiCall
func runGetApiCall<T: SyncedStorable, V: Decodable>(_ apiCall: ApiCall<T>) async throws -> V { func runGetApiCall<T: SyncedStorable>(_ apiCall: ApiCall<T>) async throws -> Data {
let request = try self._syncGetRequest(from: apiCall) let request = try self._syncGetRequest(from: apiCall)
return try await self._runGetApiCallRequest(request, apiCall: apiCall) return try await self._runGetApiCallRequest(request, apiCall: apiCall)
} }
@ -569,8 +578,8 @@ public class Services {
// MARK: - Others // MARK: - Others
public func getUserNames() async throws -> [ShortUser] { public func getUserAgents() async throws -> [ShortUser] {
return try await self._runRequest(serviceCall: userNamesCall) return try await self._runRequest(serviceCall: userAgentsCall)
} }
// MARK: - Authentication // MARK: - Authentication
@ -635,8 +644,8 @@ public class Services {
} }
/// Returns the list of DataAccess /// Returns the list of DataAccess
func getUserDataAccess() async throws { func getUserDataAccessContent() async throws {
let request = try self._baseRequest(call: getUserDataAccessCall) let request = try self._baseRequest(call: getUserDataAccessCallContent)
if let data = try await self._runRequest(request) { if let data = try await self._runRequest(request) {
await self.storeCenter.userDataAccessRetrieved(data) await self.storeCenter.userDataAccessRetrieved(data)
} }

@ -21,7 +21,12 @@ public protocol Storable: Codable, Identifiable, NSObjectProtocol {
/// Mimics the behavior of 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(shouldBeSynchronized: Bool) func deleteDependencies(store: Store, actionOption: ActionOption)
/// A method that deletes dependencies of shared resources, but only if they are themselves shared
/// and not referenced by other objects in the store
/// This is used when cleaning up shared objects that are no longer in use
func deleteUnusedSharedDependencies(store: Store)
/// Copies the content of another item into the instance /// 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 /// This behavior has been made to get live updates when looking at properties in SwiftUI screens
@ -29,6 +34,12 @@ public protocol Storable: Codable, Identifiable, NSObjectProtocol {
/// This method returns RelationShips objects of the type /// This method returns RelationShips objects of the type
static func relationships() -> [Relationship] 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
} }

@ -15,8 +15,38 @@ public enum StoreError: Error, LocalizedError {
case missingToken case missingToken
case missingKeychainStore case missingKeychainStore
case collectionNotRegistered(type: String) case collectionNotRegistered(type: String)
case cannotSyncCollection(name: String)
case apiCallCollectionNotRegistered(type: String) case apiCallCollectionNotRegistered(type: String)
case synchronizationInactive
case storeNotRegistered(id: String)
case castIssue(type: String)
case invalidStoreLookup(from: any Storable.Type, to: any Storable.Type)
public var localizedDescription: String {
switch self {
case .missingService:
return "L'instance des services est nulle"
case .missingUsername:
return "Le nom d'utilisateur est manquant"
case .missingUserId:
return "L'identifiant utilisateur est manquant"
case .missingToken:
return "Aucun token n'est stocké"
case .missingKeychainStore:
return "Aucun magasin de trousseau n'est disponible"
case .collectionNotRegistered(let type):
return "La collection \(type) n'est pas enregistrée"
case .apiCallCollectionNotRegistered(let type):
return "La collection d'appels API n'a pas été enregistrée pour \(type)"
case .synchronizationInactive:
return "La synchronisation n'est pas active sur ce StoreCenter"
case .storeNotRegistered(let id):
return "Le magasin avec l'identifiant \(id) n'est pas enregistré"
case .castIssue(let type):
return "Problème de typage: \(type)"
case .invalidStoreLookup(let from, let to):
return "Mauvaise recherche dans le magasin de \(from) à \(to)"
}
}
public var errorDescription: String? { public var errorDescription: String? {
switch self { switch self {
@ -32,10 +62,16 @@ public enum StoreError: Error, LocalizedError {
return "There is no keychain store" return "There is no keychain store"
case .collectionNotRegistered(let type): case .collectionNotRegistered(let type):
return "The collection \(type) is not registered" return "The collection \(type) is not registered"
case .cannotSyncCollection(let name):
return "Tries to load the collection \(name) from the server while it's not authorized"
case .apiCallCollectionNotRegistered(let type): case .apiCallCollectionNotRegistered(let type):
return "The api call collection has not been registered for \(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)"
} }
} }
@ -43,16 +79,13 @@ public enum StoreError: Error, LocalizedError {
final public class Store { final public class Store {
fileprivate(set) var storeCenter: StoreCenter public fileprivate(set) var storeCenter: StoreCenter
/// The Store singleton
// public static let main = Store()
/// The dictionary of registered collections /// 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 /// The store identifier, used to name the store directory, and to perform filtering requests to the server
public fileprivate(set) var identifier: String? = nil public fileprivate(set) var identifier: String? = nil
@ -71,6 +104,10 @@ final public class Store {
public static var main: Store { return StoreCenter.main.mainStore } 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
@ -89,12 +126,14 @@ final public class Store {
/// - inMemory: Indicates if the collection should only live in memory, and not write into a file /// - inMemory: Indicates if the collection should only live in memory, and not write into a file
public func registerCollection<T : Storable>(indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil) -> StoredCollection<T> { public func registerCollection<T : Storable>(indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil) -> StoredCollection<T> {
if let collection: StoredCollection<T> = try? self.collection() as? StoredCollection<T> { if let _ = try? self.someCollection(type: T.self) {
return collection fatalError("collection already registered")
// return collection
} }
let collection = StoredCollection<T>(store: self, indexed: indexed, inMemory: inMemory, limit: limit) 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
return collection return collection
} }
@ -103,26 +142,46 @@ final public class Store {
/// - Parameters: /// - Parameters:
/// - indexed: Creates an index to quickly access the data /// - indexed: Creates an index to quickly access the data
/// - inMemory: Indicates if the collection should only live in memory, and not write into a file /// - inMemory: Indicates if the collection should only live in memory, and not write into a file
public func registerSynchronizedCollection<T : SyncedStorable>(indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil, synchronousLoading: Bool = false) -> SyncedCollection<T> { public func registerSynchronizedCollection<T : SyncedStorable>(indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil, noLoad: Bool = false) -> SyncedCollection<T> {
if let collection: SyncedCollection<T> = try? self.syncedCollection() { if let collection: SyncedCollection<T> = try? self.syncedCollection() {
return collection return collection
} }
let collection = SyncedCollection<T>(store: self, indexed: indexed, inMemory: inMemory, limit: limit, synchronousLoading: synchronousLoading) 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._collections[T.resourceName()] = collection
self._baseCollections[T.resourceName()] = collection.collection
self.storeCenter.loadApiCallCollection(type: T.self) 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>(store: self, inMemory: inMemory) 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 { if synchronized {
@ -138,24 +197,13 @@ final public class Store {
/// - Parameters: /// - Parameters:
/// - id: the id of the data /// - id: the id of the data
public func findById<T: Storable>(_ id: T.ID) -> T? { public func findById<T: Storable>(_ id: T.ID) -> T? {
guard let collection = self._collections[T.resourceName()] as? BaseCollection<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
/// - Parameters:
/// - isIncluded: a predicate to returns if a data should be filtered in
// public func filter<T: Storable>(isIncluded: (T) throws -> (Bool)) rethrows -> [T] {
// do {
// return try self.collection().filter(isIncluded)
// } catch {
// return []
// }
// }
/// Returns a collection by type /// Returns a collection by type
func syncedCollection<T: SyncedStorable>() throws -> SyncedCollection<T> { func syncedCollection<T: SyncedStorable>() throws -> SyncedCollection<T> {
if let collection = self._collections[T.resourceName()] as? SyncedCollection<T> { if let collection = self._collections[T.resourceName()] as? SyncedCollection<T> {
@ -165,8 +213,13 @@ final public class Store {
} }
/// Returns a collection by type /// Returns a collection by type
func collection<T: Storable>() throws -> BaseCollection<T> { func syncedCollection<T: SyncedStorable>(type: T.Type) throws -> SyncedCollection<T> {
if let collection = self._collections[T.resourceName()] as? BaseCollection<T> { return try self.syncedCollection()
}
/// Returns a collection by type
func someCollection<T: Storable>(type: T.Type) throws -> any SomeCollection {
if let collection = self._collections[T.resourceName()] {
return collection return collection
} }
throw StoreError.collectionNotRegistered(type: T.resourceName()) throw StoreError.collectionNotRegistered(type: T.resourceName())
@ -176,7 +229,7 @@ final public class Store {
do { do {
return try self.syncedCollection() return try self.syncedCollection()
} catch { } catch {
return self.registerSynchronizedCollection(indexed: true, inMemory: false) return self.registerSynchronizedCollection(indexed: true, inMemory: false, noLoad: true)
} }
} }
@ -220,35 +273,156 @@ final public class Store {
// MARK: - Synchronization // 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 /// Calls addOrUpdateIfNewer from the collection corresponding to the instance
func addOrUpdateIfNewer<T: SyncedStorable>(_ instance: T, shared: Bool) { @MainActor
func addOrUpdateIfNewer<T: SyncedStorable>(_ instance: T, shared: SharingStatus?) {
let collection: SyncedCollection<T> = self.registerOrGetSyncedCollection(T.self) let collection: SyncedCollection<T> = self.registerOrGetSyncedCollection(T.self)
collection.addOrUpdateIfNewer(instance, shared: shared) collection.addOrUpdateIfNewer(instance, shared: shared)
} }
/// Calls deleteById from the collection corresponding to the instance @MainActor
func deleteNoSync<T: Storable>(instance: T) { 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 { do {
let collection: BaseCollection<T> = try self.collection() if let instance = self._instance(id: identifier.modelId, type: type) {
collection.delete(instance: instance) if instance.sharing != nil && !self.storeCenter.isReferenced(instance: instance) {
try self.deleteNoSyncNoCascadeNoWrite(type: type, id: identifier.modelId)
}
}
} catch { } catch {
Logger.error(error) Logger.error(error)
} }
} }
self._requestWrite(type: T.self)
}
fileprivate func _instance<T: SyncedStorable>(id: String, type: T.Type) -> T? {
let realId: T.ID = T.buildRealId(id: id)
return self.findById(realId)
}
/// Calls deleteById from the collection corresponding to the instance /// Calls deleteById from the collection corresponding to the instance
func deleteNoSync<T: SyncedStorable>(type: T.Type, id: String) throws { func deleteNoSyncNoCascadeNoWrite<T: SyncedStorable>(type: T.Type, id: String) throws {
let collection: SyncedCollection<T> = try self.syncedCollection() let collection: SyncedCollection<T> = try self.syncedCollection()
collection.deleteByStringIdNoSync(id) collection.deleteByStringId(id, actionOption: .noCascadeNoWrite)
} }
/// Calls deleteById from the collection corresponding to the instance func isReferenced<T: Storable, S: Storable>(collectionType: S.Type, type: T.Type, id: String) -> Bool {
func referenceCount<T: SyncedStorable>(type: T.Type, id: String) -> Int { if let collection = self._baseCollections[S.resourceName()] {
var count: Int = 0 return collection.hasParentReferences(type: type, id: id)
for collection in self._collections.values { } else {
count += collection.referenceCount(type: type, id: id) 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)
} }
return count
} }
// MARK: - Write // MARK: - Write
@ -270,6 +444,7 @@ final public class Store {
var fileURL = try self._directoryPath() var fileURL = try self._directoryPath()
fileURL.append(component: fileName) fileURL.append(component: fileName)
try content.write(to: fileURL, atomically: false, encoding: .utf8) try content.write(to: fileURL, atomically: false, encoding: .utf8)
// Logger.log("write into \(fileURL)...")
} }
/// Returns the URL matching a Storable type /// Returns the URL matching a Storable type

@ -13,13 +13,13 @@ public class StoreCenter {
/// The main instance /// The main instance
public static let main: StoreCenter = StoreCenter() public static let main: StoreCenter = StoreCenter()
fileprivate lazy var _storeLibrary: StoreLibrary = { StoreLibrary(storeCenter: self) }()
/// The name of the directory to store the json files /// The name of the directory to store the json files
let directoryName: String let directoryName: String
/// A dictionary of Stores associated to their id /// Returns a default Store instance
fileprivate var _stores: [String: Store] = [:] public lazy var mainStore: Store = { Store(storeCenter: self) }()
lazy var mainStore: Store = { Store(storeCenter: self) }()
/// A KeychainStore object used to store the user's token /// A KeychainStore object used to store the user's token
var tokenKeychain: KeychainService? = nil var tokenKeychain: KeychainService? = nil
@ -44,10 +44,10 @@ public class StoreCenter {
fileprivate var _apiCallCollections: [String: any SomeCallCollection] = [:] fileprivate var _apiCallCollections: [String: any SomeCallCollection] = [:]
/// A collection of DataLog objects, used for the synchronization /// A collection of DataLog objects, used for the synchronization
lazy fileprivate var _deleteLogs: StoredCollection<DataLog> = { self.mainStore.registerCollection() }() fileprivate var _deleteLogs: StoredCollection<DataLog>? = nil
/// A synchronized collection of DataAccess /// A synchronized collection of DataAccess
fileprivate var _dataAccess: SyncedCollection<DataAccess>? = nil fileprivate(set) var dataAccessCollection: SyncedCollection<DataAccess>? = nil
/// A collection storing FailedAPICall objects /// A collection storing FailedAPICall objects
fileprivate var _failedAPICallsCollection: SyncedCollection<FailedAPICall>? = nil fileprivate var _failedAPICallsCollection: SyncedCollection<FailedAPICall>? = nil
@ -61,17 +61,30 @@ public class StoreCenter {
/// The URL manager /// The URL manager
fileprivate var _urlManager: URLManager? = nil fileprivate var _urlManager: URLManager? = nil
var classProject: String? = nil /// Gives the project name to retrieve classes from names
public var classProject: String? = nil
var useWebsockets: Bool = false
var useSynchronization: Bool = false
var synchronizesData: Bool = false
var wantsToSynchronize: Bool = false
init(directoryName: String? = nil) { init(directoryName: String? = nil) {
self.directoryName = directoryName ?? "storage" self.directoryName = directoryName ?? "storage"
self._createDirectory() self._createDirectory()
self._setupNotifications() self._setupNotifications()
self.loadApiCallCollection(type: GetSyncData.self) self.loadApiCallCollection(type: GetSyncData.self)
if let directoryName {
self._settingsStorage = MicroStorage(
fileName: "\(directoryName)/settings.json")
}
NetworkMonitor.shared.onConnectionEstablished = { NetworkMonitor.shared.onConnectionEstablished = {
self._resumeApiCalls() self._resumeApiCalls()
// self._configureWebSocket() // self._configureWebSocket()
@ -79,23 +92,34 @@ public class StoreCenter {
// Logger.log("device Id = \(self.deviceId())") // Logger.log("device Id = \(self.deviceId())")
} }
public func configureURLs(secureScheme: Bool, domain: String, webSockets: Bool = true) { public func configureURLs(secureScheme: Bool, domain: String, webSockets: Bool = true, useSynchronization: Bool = false) {
self.useWebsockets = webSockets
self.useSynchronization = useSynchronization
self._deleteLogs = self.mainStore.registerCollection()
let urlManager: URLManager = URLManager(secureScheme: secureScheme, domain: domain) let urlManager: URLManager = URLManager(secureScheme: secureScheme, domain: domain)
self._urlManager = urlManager self._urlManager = urlManager
self._services = Services(storeCenter: self, url: urlManager.api) self._services = Services(storeCenter: self, url: urlManager.api)
self.tokenKeychain = KeychainStore(serverId: urlManager.api) self.tokenKeychain = KeychainStore(serverId: urlManager.api)
self._dataAccess = self.mainStore.registerSynchronizedCollection() if self.useSynchronization {
self.dataAccessCollection = self.mainStore.registerSynchronizedCollection()
}
Logger.log("Sync URL: \(urlManager.api)") Logger.log("Sync URL: \(urlManager.api)")
if webSockets && self.userId != nil { if self.userId != nil {
self._configureWebSocket() self._configureWebSocket()
} }
} }
fileprivate func _configureWebSocket() { fileprivate func _configureWebSocket() {
guard self.useWebsockets else {
return
}
self._webSocketManager?.disconnect() self._webSocketManager?.disconnect()
self._webSocketManager = nil self._webSocketManager = nil
@ -122,6 +146,12 @@ public class StoreCenter {
public var websocketFailure: Bool { public var websocketFailure: Bool {
return self._webSocketManager?.failure ?? true return self._webSocketManager?.failure ?? true
} }
public var websocketError: Error? {
return self._webSocketManager?.error
}
public var websocketReconnectAttempts: Int {
return self._webSocketManager?.reconnectAttempts ?? 0
}
public var apiURL: String? { public var apiURL: String? {
return self._urlManager?.api return self._urlManager?.api
@ -154,39 +184,45 @@ public class StoreCenter {
@objc fileprivate func _launchSynchronization() { @objc fileprivate func _launchSynchronization() {
Task { Task {
do { await self.synchronizeLastUpdates()
try await self.synchronizeLastUpdates()
} catch {
Logger.error(error)
}
} }
} }
/// Registers a store into the list of stores // MARK: - Store management
/// - Parameters:
/// - store: A store to save func requestStore(identifier: String) -> Store {
fileprivate func _registerStore(store: Store) { return self._storeLibrary.requestStore(identifier: identifier)
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)") /// Returns the store corresponding to the provided id, and creates one if necessary, otherwise returns the main store
fileprivate func _requestStore(id: String?) -> Store {
if let storeId = id {
return self._storeLibrary.requestStore(identifier: storeId)
} else {
return self.mainStore
} }
self._stores[identifier] = store
} }
/// Returns a store using its identifier, and registers it if it does not exists fileprivate func _store(id: String?) -> Store? {
/// - Parameters: if let storeId = id {
/// - identifier: The store identifer return self._storeLibrary[storeId]
/// - parameter: The parameter name used to filter data on the server
public func store(identifier: String) -> Store {
if let store = self._stores[identifier] {
return store
} else { } else {
let store = Store(storeCenter: self, identifier: identifier) return self.mainStore
self._registerStore(store: store) }
}
public func store(identifier: String) throws -> Store {
if let store = self._storeLibrary[identifier] {
return store return store
} }
throw StoreError.storeNotRegistered(id: identifier)
}
/// Deletes the directory using its identifier
/// - Parameters:
/// - identifier: The name of the directory
public func destroyStore(identifier: String) {
self._storeLibrary.destroyStore(identifier: identifier)
} }
// MARK: - Settings // MARK: - Settings
@ -228,9 +264,9 @@ public class StoreCenter {
self.resetApiCalls() self.resetApiCalls()
self._failedAPICallsCollection?.reset() self._failedAPICallsCollection?.reset()
self._stores.removeAll() self._storeLibrary.reset()
self._dataAccess?.reset() self.dataAccessCollection?.reset()
self._deleteLogs.reset() self._deleteLogs?.reset()
self._settingsStorage.update { settings in self._settingsStorage.update { settings in
settings.username = nil settings.username = nil
@ -442,7 +478,7 @@ public class StoreCenter {
// } // }
/// Executes an API call /// Executes an API call
func executeGet<T: SyncedStorable, V: Decodable>(apiCall: ApiCall<T>) async throws -> V { func executeGet<T: SyncedStorable>(apiCall: ApiCall<T>) async throws -> Data {
return try await self.service().runGetApiCall(apiCall) return try await self.service().runGetApiCall(apiCall)
} }
@ -507,7 +543,7 @@ public class StoreCenter {
} }
func itemsRetrieved<T: SyncedStorable>(_ results: [T], storeId: String?, clear: Bool) async { func itemsRetrieved<T: SyncedStorable>(_ results: [T], storeId: String?, clear: Bool) async {
await self._store(id: storeId).loadCollectionItems(results, clear: clear) await self._requestStore(id: storeId).loadCollectionItems(results, clear: clear)
} }
/// Returns the names of all collections /// Returns the names of all collections
@ -533,37 +569,57 @@ public class StoreCenter {
self.mainStore.loadCollectionsFromServer(clear: clear) self.mainStore.loadCollectionsFromServer(clear: clear)
// request data that has been shared with the user // request data that has been shared with the user
if self.useSynchronization {
Task { Task {
do { do {
try await self.service().getUserDataAccess() try await self.service().getUserDataAccessContent()
} catch { } catch {
Logger.error(error) Logger.error(error)
} }
} }
}
} }
/// Basically asks the server for new content /// Basically asks the server for new content
public func synchronizeLastUpdates() async throws { @discardableResult public func synchronizeLastUpdates() async -> Error? {
guard self.isAuthenticated else { Logger.log("synchronizeLastUpdates: self.synchronizesData: \(self.synchronizesData) / self.isAuthenticated = \(self.isAuthenticated) / self.useSynchronization = \(self.useSynchronization)")
return guard self.isAuthenticated, self.useSynchronization else {
return nil
}
guard !self.synchronizesData else {
self.wantsToSynchronize = true
return nil
} }
Logger.log(">>> synchronizeLastUpdates started...")
self.synchronizesData = true
self.wantsToSynchronize = false
let lastSync = self._settingsStorage.item.lastSynchronization let lastSync = self._settingsStorage.item.lastSynchronization
do {
let syncGetCollection: ApiCallCollection<GetSyncData> = try self.apiCallCollection() let syncGetCollection: ApiCallCollection<GetSyncData> = try self.apiCallCollection()
if await syncGetCollection.hasPendingCalls() { if await syncGetCollection.hasPendingCalls() == false {
await syncGetCollection.rescheduleImmediately() Logger.log("*** START sync: \(lastSync)")
} else {
let getSyncData = GetSyncData() let getSyncData = GetSyncData()
getSyncData.date = lastSync getSyncData.date = lastSync
try await syncGetCollection.sendGetRequest(instance: getSyncData) try await syncGetCollection.sendGetRequest(instance: getSyncData)
} }
} catch {
self.synchronizesData = false
self.log(message: "sync failed: \(error)")
Logger.error(error)
return error
}
self.synchronizesData = false
Logger.log(">>> synchronizeLastUpdates ended.")
return nil
} }
func testSynchronizeOnceAsync() async throws { @discardableResult func testSynchronizeOnceAsync() async throws -> Data {
guard self.isAuthenticated else { guard self.isAuthenticated else {
throw StoreError.missingToken throw StoreError.missingToken
} }
@ -572,7 +628,7 @@ public class StoreCenter {
let getSyncData = GetSyncData() let getSyncData = GetSyncData()
getSyncData.date = lastSync getSyncData.date = lastSync
await syncGetCollection.executeSingleGet(instance: getSyncData) return try await syncGetCollection.executeSingleGet(instance: getSyncData)
} }
func sendGetRequest<T: SyncedStorable>(_ type: T.Type, storeId: String?, clear: Bool) async throws { func sendGetRequest<T: SyncedStorable>(_ type: T.Type, storeId: String?, clear: Bool) async throws {
@ -599,69 +655,43 @@ public class StoreCenter {
Logger.w("data unrecognized: \(string)") Logger.w("data unrecognized: \(string)")
return return
} }
try await self._parseSyncUpdates(json, shared: true)
let array = try self.decodeDictionary(json)
await self._syncAddOrUpdate(array, shared: .shared)
} catch { } catch {
Logger.error(error) Logger.error(error)
} }
} }
/// Processes the data coming from a sync request /// Processes the data coming from a sync request
@MainActor func synchronizeContent(_ data: Data) { @MainActor func synchronizeContent(_ syncData: SyncData) async {
do {
guard
let json = try JSONSerialization.jsonObject(with: data, options: [])
as? [String: Any]
else {
Logger.w("data unrecognized")
return
}
if let updates = json["updates"] as? [String: Any] {
try self._parseSyncUpdates(updates)
}
if let deletions = json["deletions"] as? [String: Any] { await self._syncAddOrUpdate(syncData.updates)
try self._parseSyncDeletions(deletions) await self._syncDelete(syncData.deletions)
} await self._syncAddOrUpdate(syncData.shared, shared: .shared)
await self._syncAddOrUpdate(syncData.grants, shared: .granted)
if let updates = json["grants"] as? [String: Any] { await self.syncRevoke(syncData.revocations, parents: syncData.revocationParents)
try self._parseSyncUpdates(updates, shared: true) // self._syncAddOrUpdate(syncData.relationshipSets)
} // await self._syncDelete(syncData.relationshipRemovals)
await self._syncAddOrUpdate(syncData.sharedRelationshipSets, shared: .granted)
if let revocations = json["revocations"] as? [String: Any] { await self._syncRevoke(syncData.sharedRelationshipRemovals)
try self._parseSyncRevocations(revocations, parents: json["revocation_parents"] as? [[String: Any]])
}
// Data access events
if let rs = json["relationship_sets"] as? [String: Any] {
try self._parseSyncUpdates(rs)
}
if let rr = json["relationship_removals"] as? [String: Any] {
try self._parseSyncDeletions(rr)
}
if let srs = json["shared_relationship_sets"] as? [String: Any] {
try self._parseSyncUpdates(srs, shared: true)
}
if let srm = json["shared_relationship_removals"] as? [String: Any] { // Logger.log("sync content: updates = \(syncData.updates.count) / deletions = \(syncData.deletions.count), grants = \(syncData.grants.count)")
self._synchronizationRevoke(items: srm)
}
if let dateString = json["date"] as? String { if let dateString = syncData.date {
Logger.log("Sets sync date = \(dateString)") Logger.log("Sets sync date = \(dateString)")
self._settingsStorage.update { settings in self._settingsStorage.update { settings in
settings.lastSynchronization = dateString settings.lastSynchronization = dateString
} }
} }
} catch { self.synchronizesData = false
self.log(message: error.localizedDescription) if self.wantsToSynchronize {
Logger.error(error) await self.synchronizeLastUpdates()
} }
// Logger.log(">>> SYNC ENDED")
NotificationCenter.default.post( NotificationCenter.default.post(
name: NSNotification.Name.LeStorageDidSynchronize, object: self) name: NSNotification.Name.LeStorageDidSynchronize, object: self)
@ -669,102 +699,104 @@ public class StoreCenter {
/// Processes data that should be inserted or updated inside the app /// Processes data that should be inserted or updated inside the app
/// - Parameters: /// - Parameters:
/// - updates: the server updates /// - updateArrays: the server updates
/// - shared: indicates if the content should be flagged as shared /// - shared: indicates if the content should be flagged as shared
@MainActor func _parseSyncUpdates(_ updates: [String: Any], shared: Bool = false) throws { @MainActor
for (className, updateData) in updates { fileprivate func _syncAddOrUpdate(_ updateArrays: [SyncedStorableArray], shared: SharingStatus? = nil) async {
guard let updateArray = updateData as? [[String: Any]] else { for updateArray in updateArrays {
Logger.w("Invalid update data for \(className)") await self._syncAddOrUpdate(updateArray, type: updateArray.type, shared: shared)
continue // for item in updateArray.items {
// let storeId: String? = item.getStoreId()
// await self.synchronizationAddOrUpdate(item, storeId: storeId, shared: shared)
// }
} }
Logger.log(">>> UPDATE \(updateArray.count) \(className)")
let type = try self.classFromName(className)
for updateItem in updateArray { }
do { @MainActor
let jsonData = try JSONSerialization.data( fileprivate func _syncAddOrUpdate<T: SyncedStorable>(_ updateArray: SyncedStorableArray, type: T.Type, shared: SharingStatus? = nil) async {
withJSONObject: updateItem, options: [])
let decodedObject = try JSON.decoder.decode(type, from: jsonData)
// Logger.log(">>> \(decodedObject.lastUpdate.timeIntervalSince1970) : \(decodedObject.id)")
let storeId: String? = decodedObject.getStoreId() let itemsByStore = updateArray.items.group { $0.getStoreId() }
self.synchronizationAddOrUpdate(decodedObject, storeId: storeId, shared: shared) for (storeId, items) in itemsByStore {
} catch { let store = self._requestStore(id: storeId)
Logger.w("Issue with json decoding: \(updateItem)") store.synchronizationAddOrUpdate(items as! [T], shared: shared)
Logger.error(error)
}
}
} }
} }
/// Processes data that should be deleted inside the app /// Processes data that should be deleted inside the app
fileprivate func _parseSyncDeletions(_ deletions: [String: Any]) throws { fileprivate func _syncDelete(_ deletionArrays: [ObjectIdentifierArray]) async {
for (className, deleteData) in deletions { for deletionArray in deletionArrays {
guard let deletedItems = deleteData as? [Any] else { await self._syncDelete(deletionArray, type: deletionArray.type)
Logger.w("Invalid update data for \(className)")
continue
}
for deleted in deletedItems {
do { // for deletedObject in deletionArray.items {
let data = try JSONSerialization.data(withJSONObject: deleted, options: []) // await self.synchronizationDelete(id: deletedObject.modelId, type: deletionArray.type, storeId: deletedObject.storeId)
let deletedObject = try JSON.decoder.decode(ObjectIdentifier.self, from: data) // }
}
self.synchronizationDelete(id: deletedObject.modelId, model: className, storeId: deletedObject.storeId)
} catch {
Logger.error(error)
} }
fileprivate func _syncDelete<T : SyncedStorable>(_ deletionArray: ObjectIdentifierArray, type: T.Type) async {
let itemsByStore = deletionArray.items.group { $0.storeId }
for (storeId, items) in itemsByStore {
if let store = self._store(id: storeId) {
await store.synchronizationDelete(items, type: T.self)
} }
} }
// for deletedObject in deletionArray.items {
//
// let itemsByStore = deletionArray.items.group { $0.storeId }
// for (storeId, items) in itemsByStore {
// let store = self._requestStore(id: storeId)
// await store.synchronizationDelete(items, type: T.self)
// }
//// await self.synchronizationDelete(id: deletedObject.modelId, type: deletionArray.type, storeId: deletedObject.storeId)
// }
} }
/// Processes data that has been revoked /// Processes data that has been revoked
fileprivate func _parseSyncRevocations(_ deletions: [String: Any], parents: [[String: Any]]?) throws { fileprivate func syncRevoke(_ revokedArrays: [ObjectIdentifierArray], parents: [[ObjectIdentifierArray]]) async {
for (className, revocationData) in deletions {
guard let revokedItems = revocationData as? [Any] else { await self._syncRevoke(revokedArrays)
Logger.w("Invalid update data for \(className)") for revokedArray in revokedArrays {
continue await self._syncDelete(revokedArray, type: revokedArray.type)
}
for revoked in revokedItems { // for revoked in revokedArray.items {
do { // await self.synchronizationDelete(id: revoked.modelId, type: revokedArray.type, storeId: revoked.storeId) // or synchronizationRevoke ?
let data = try JSONSerialization.data(withJSONObject: revoked, options: []) // }
let revokedObject = try JSON.decoder.decode(ObjectIdentifier.self, from: data)
self.synchronizationDelete(id: revokedObject.modelId, model: className, storeId: revokedObject.storeId)
} catch {
Logger.error(error)
}
}
} }
if let parents {
for level in parents { for level in parents {
self._synchronizationRevoke(items: level) await self._syncRevoke(level)
}
} }
} }
fileprivate func _synchronizationRevoke(items: [String: Any]) { fileprivate func _syncRevoke(_ revokeArrays: [ObjectIdentifierArray]) async {
for (className, parentData) in items { for revokeArray in revokeArrays {
guard let parentItems = parentData as? [Any] else { await self._syncRevoke(revokeArray: revokeArray)
Logger.w("Invalid update data for \(className): \(parentData)") // for revoked in revokeArray.items {
continue // await self.synchronizationRevoke(id: revoked.modelId, type: revokeArray.type, storeId: revoked.storeId)
} // }
for parentItem in parentItems {
do {
let data = try JSONSerialization.data(withJSONObject: parentItem, options: [])
let revokedObject = try JSON.decoder.decode(ObjectIdentifier.self, from: data)
self.synchronizationRevoke(id: revokedObject.modelId, model: className, storeId: revokedObject.storeId)
} catch {
Logger.error(error)
} }
} }
@MainActor
fileprivate func _syncRevoke(revokeArray: ObjectIdentifierArray) async {
let itemsByStore = revokeArray.items.group { $0.storeId }
for (storeId, items) in itemsByStore {
let store = self._requestStore(id: storeId)
store.synchronizationRevoke(items, type: revokeArray.type)
} }
// for revoked in revokeArray.items {
//
//
//
// }
} }
/// Returns a Type object for a class name /// Returns a Type object for a class name
@ -781,93 +813,165 @@ public class StoreCenter {
} }
/// Returns the store corresponding to the provided id, and creates one if necessary
fileprivate func _store(id: String?) -> Store {
if let storeId = id {
if let store = self._stores[storeId] {
return store
} else {
let store = Store(storeCenter: self, identifier: storeId)
self._registerStore(store: store)
return store
}
} else {
return self.mainStore
}
}
/// Returns whether a data has already been deleted by, to avoid inserting it again /// Returns whether a data has already been deleted by, to avoid inserting it again
fileprivate func _hasAlreadyBeenDeleted<T: Storable>(_ instance: T) -> Bool { func hasAlreadyBeenDeleted<T: Storable>(_ instance: T) -> Bool {
return self._deleteLogs.contains(where: { guard let deleteLogs = self._deleteLogs else {
fatalError("missing delete logs collection")
}
return deleteLogs.contains(where: {
$0.dataId == instance.stringId && $0.operation == .delete $0.dataId == instance.stringId && $0.operation == .delete
}) })
} }
/// Adds or updates an instance into the store /// Adds or updates an instance into the store
func synchronizationAddOrUpdate<T: SyncedStorable>(_ instance: T, storeId: String?, shared: Bool) { // func synchronizationAddOrUpdate<T: SyncedStorable>(_ instance: T, storeId: String?, shared: SharingStatus?) async {
let hasAlreadyBeenDeleted: Bool = self._hasAlreadyBeenDeleted(instance) // let hasAlreadyBeenDeleted: Bool = self.hasAlreadyBeenDeleted(instance)
if !hasAlreadyBeenDeleted { // if !hasAlreadyBeenDeleted {
DispatchQueue.main.async { // await self._requestStore(id: storeId).addOrUpdateIfNewer(instance, shared: shared)
self._store(id: storeId).addOrUpdateIfNewer(instance, shared: shared) // }
} // }
/// Deletes an instance with the given parameters
// @MainActor
// func synchronizationDelete<T: SyncedStorable>(id: String, type: T.Type, storeId: String?) {
// do {
// try self._store(id: storeId).deleteNoSyncNoCascade(type: type, id: id)
// } catch {
// Logger.error(error)
// }
// self.cleanupDataLog(dataId: id)
// }
/// Revokes a data that has been shared with the user
// @MainActor
// func synchronizationRevoke<T: SyncedStorable>(id: String, type: T.Type, storeId: String?) {
//
// do {
// if let instance = self._instance(id: id, type: type, storeId: storeId) {
// if instance.sharing != nil && !self.isReferenced(instance: instance) {
// try self._store(id: storeId).deleteNoSyncNoCascade(type: type, id: id)
// }
// }
// } catch {
// Logger.error(error)
// }
// }
// fileprivate func _instance<T: SyncedStorable>(id: String, type: T.Type, storeId: String?) -> T? {
// let realId: T.ID = T.buildRealId(id: id)
// return self._store(id: storeId).findById(realId)
// }
/// Returns whether an instance has been shared with the user
// fileprivate func _instanceShared<T: SyncedStorable>(id: String, type: T.Type, storeId: String?) -> Bool {
//// let realId: T.ID = T.buildRealId(id: id)
// let instance: T? = self._instance(id: id, type: type, storeId: storeId)
// return instance?.sharing != nil
// }
/// Deletes a data log by data id
func cleanupDataLog(dataId: String) {
guard let deleteLogs = self._deleteLogs else {
return
} }
let logs = deleteLogs.filter { $0.dataId == dataId }
deleteLogs.delete(contentOfs: logs)
} }
/// Deletes an instance with the given parameters /// Creates a delete log for an instance
func synchronizationDelete(id: String, model: String, storeId: String?) { func createDeleteLog<T: Storable>(_ instance: T) {
let dataLog = DataLog(dataId: instance.stringId,
modelName: String(describing: T.self),
operation: .delete)
self._deleteLogs?.addOrUpdate(instance: dataLog)
}
DispatchQueue.main.async { /// Returns the appropriate store for a relationship
do { /// - Parameters:
let type = try self.classFromName(model) /// - instance: some Storable instance
try self._store(id: storeId).deleteNoSync(type: type, id: id) /// - relationship: the relationship
} catch { func relationshipStore<T: Storable>(instance: T, relationship: Relationship) -> Store? {
Logger.error(error) switch relationship.storeLookup {
case .main: return Store.main
case .child: return self._storeLibrary[instance.stringId]
case .same: return instance.store
} }
self._cleanupDataLog(dataId: id)
} }
/// Returns if an instance has at least one valid parent relationship by checking if the id of the parent exists
/// - Parameters:
/// - instance: some Storable instance
/// - relationship: the relationship
func hasParentReferences<T: Storable, S: Storable>(instance: T, relationshipType: S.Type, relationship: Relationship) -> Bool {
if let referenceId = instance[keyPath: relationship.keyPath] as? S.ID,
let store = self.relationshipStore(instance: instance, relationship: relationship) {
let instance: S? = store.findById(referenceId)
return instance != nil
}
return false
} }
/// Revokes a data that has been shared with the user func isReferenced<T: Storable>(instance: T) -> Bool {
func synchronizationRevoke(id: String, model: String, storeId: String?) {
DispatchQueue.main.async { for relationship in T.parentRelationships() {
do { if self.hasParentReferences(instance: instance, relationshipType: relationship.type, relationship: relationship) {
let type = try self.classFromName(model) return true
if self._instanceShared(id: id, type: type) {
let count = self.mainStore.referenceCount(type: type, id: id)
if count == 0 {
try self._store(id: storeId).deleteNoSync(type: type, id: id)
} }
} }
} catch {
Logger.error(error) for relationship in T.childrenRelationships() {
if let store = self.relationshipStore(instance: instance, relationship: relationship) {
if store.isReferenced(collectionType: relationship.type, type: T.self, id: instance.stringId) {
return true
}
} else {
Logger.w("missing store for instance \(instance)")
} }
} }
return false
} }
/// Returns whether an instance has been shared with the user // MARK: - Sync data conversion
fileprivate func _instanceShared<T: SyncedStorable>(id: String, type: T.Type) -> Bool {
let realId: T.ID = T.buildRealId(id: id) func decodeObjectIdentifierDictionary(_ dictionary: [String: Any]) throws -> [ObjectIdentifierArray] {
let instance: T? = self.mainStore.findById(realId)
return instance?.shared == true var objectIdentifierArray: [ObjectIdentifierArray] = []
for (className, dataArray) in dictionary {
guard let array = dataArray as? [[String: Any]] else {
Logger.w("Invalid update data for \(className)")
continue
}
let type = try self.classFromName(className)
let decodedArray = try self._decodeArray(type: ObjectIdentifier.self, array: array)
objectIdentifierArray.append(ObjectIdentifierArray(type: type, items: decodedArray))
}
return objectIdentifierArray
} }
/// Deletes a data log by data id func decodeDictionary(_ dictionary: [String: Any]) throws -> [SyncedStorableArray] {
fileprivate func _cleanupDataLog(dataId: String) {
let logs = self._deleteLogs.filter { $0.dataId == dataId } var syncedStorableArray: [SyncedStorableArray] = []
self._deleteLogs.delete(contentOfs: logs)
for (className, dataArray) in dictionary {
guard let array = dataArray as? [[String: Any]] else {
Logger.w("Invalid update data for \(className)")
continue
} }
// Logger.log(">>> UPDATE \(array.count) \(className)")
/// Creates a delete log for an instance let type = try self.classFromName(className)
func createDeleteLog<T: Storable>(_ instance: T) { let decodedArray = try self._decodeArray(type: type, array: array)
self._addDataLog(instance, method: .delete) syncedStorableArray.append(SyncedStorableArray(type: type, items: decodedArray))
}
return syncedStorableArray
} }
/// Adds a datalog for an instance with the associated method fileprivate func _decodeArray<T: Decodable>(type: T.Type, array: [[String : Any]]) throws -> [T] {
fileprivate func _addDataLog<T: Storable>(_ instance: T, method: HTTPMethod) { let jsonData = try JSONSerialization.data(withJSONObject: array, options: [])
let dataLog = DataLog( return try JSON.decoder.decode([T].self, from: jsonData)
dataId: instance.stringId, modelName: String(describing: T.self), operation: method)
self._deleteLogs.addOrUpdate(instance: dataLog)
} }
// MARK: - Miscellanous // MARK: - Miscellanous
@ -989,15 +1093,6 @@ public class StoreCenter {
return !self._blackListedUserName.contains(where: { $0 == userName }) return !self._blackListedUserName.contains(where: { $0 == userName })
} }
/// Deletes the directory using its identifier
/// - Parameters:
/// - identifier: The name of the directory
public func destroyStore(identifier: String) {
let directory = "\(self.directoryName)/\(identifier)"
FileManager.default.deleteDirectoryInDocuments(directoryName: directory)
self._stores.removeValue(forKey: identifier)
}
// MARK: - Instant update // MARK: - Instant update
/// Updates a local object with a server instance /// Updates a local object with a server instance
@ -1012,35 +1107,37 @@ public class StoreCenter {
} }
/// Returns the collection hosting an instance /// Returns the collection hosting an instance
func collectionOfInstance<T: Storable>(_ instance: T) -> BaseCollection<T>? { func collectionOfInstance<T: Storable>(_ instance: T) -> (any SomeCollection)? {
do { do {
let collection: BaseCollection<T> = try self.mainStore.collection() if let storeId = instance.getStoreId() {
if collection.findById(instance.id) != nil { let store = try self.store(identifier: storeId)
return collection return try store.someCollection(type: T.self)
} else { } else {
return self.collectionOfInstanceInSubStores(instance) return try Store.main.someCollection(type: T.self)
} }
} catch { } catch {
return self.collectionOfInstanceInSubStores(instance) Logger.error(error)
} }
return nil
} }
/// Search inside the additional stores to find the collection hosting the instance /// Search inside the additional stores to find the collection hosting the instance
func collectionOfInstanceInSubStores<T: Storable>(_ instance: T) -> BaseCollection<T>? { // func collectionOfInstanceInSubStores<T: Storable>(_ instance: T) -> BaseCollection<T>? {
for store in self._stores.values { // for store in self._stores.values {
let collection: BaseCollection<T>? = try? store.collection() // let collection: BaseCollection<T>? = try? store.collection()
if collection?.findById(instance.id) != nil { // if collection?.findById(instance.id) != nil {
return collection // return collection
} // }
} // }
return nil // return nil
} // }
// MARK: - Data Access // MARK: - Data Access
/// Returns the list of users have access to a data given its id /// Returns the list of users have access to a data given its id
public func authorizedUsers(for modelId: String) -> [String] { public func authorizedUsers(for modelId: String) -> [String] {
guard let dataAccessCollection = self._dataAccess else { guard let dataAccessCollection = self.dataAccessCollection else {
return [] return []
} }
if let dataAccess = dataAccessCollection.first(where: { $0.modelId == modelId }) { if let dataAccess = dataAccessCollection.first(where: { $0.modelId == modelId }) {
@ -1049,10 +1146,10 @@ public class StoreCenter {
return [] return []
} }
/// Sets the the list of authorized users for an instance public func setAuthorizedUsersAsync<T: SyncedStorable>(for instance: T, users: [String]) async throws {
public func setAuthorizedUsers<T: SyncedStorable>(for instance: T, users: [String]) throws {
guard let dataAccessCollection = self._dataAccess else { guard let dataAccessCollection = self.dataAccessCollection else {
return throw StoreError.synchronizationInactive
} }
guard let userId = self.userId else { guard let userId = self.userId else {
throw LeStorageError.cantCreateDataAccessBecauseUserIdIsNil throw LeStorageError.cantCreateDataAccessBecauseUserIdIsNil
@ -1060,15 +1157,27 @@ public class StoreCenter {
if let dataAccess = dataAccessCollection.first(where: { $0.modelId == instance.stringId }) { if let dataAccess = dataAccessCollection.first(where: { $0.modelId == instance.stringId }) {
if users.isEmpty { if users.isEmpty {
dataAccessCollection.delete(instance: dataAccess) try await dataAccessCollection.deleteAsync(instance: dataAccess)
} else { } else {
dataAccess.sharedWith.removeAll() dataAccess.sharedWith.removeAll()
dataAccess.sharedWith = users dataAccess.sharedWith = users
dataAccessCollection.addOrUpdate(instance: dataAccess) try await dataAccessCollection.addOrUpdateAsync(instance: dataAccess)
} }
} else { } else {
let dataAccess = DataAccess(owner: userId, sharedWith: users, modelName: String(describing: type(of: instance)), modelId: instance.stringId) let dataAccess = DataAccess(owner: userId, sharedWith: users, modelName: String(describing: type(of: instance)), modelId: instance.stringId, storeId: instance.getStoreId())
dataAccessCollection.addOrUpdate(instance: dataAccess) try await dataAccessCollection.addOrUpdateAsync(instance: dataAccess)
}
}
/// Sets the the list of authorized users for an instance
public func setAuthorizedUsers<T: SyncedStorable>(for instance: T, users: [String]) throws {
Task {
do {
try await self.setAuthorizedUsersAsync(for: instance, users: users)
} catch {
Logger.error(error)
}
} }
} }
@ -1088,21 +1197,11 @@ public class StoreCenter {
/// Logs a message in the logs collection /// Logs a message in the logs collection
public func log(message: String) { public func log(message: String) {
DispatchQueue.main.async { DispatchQueue.main.async {
let log = Log(message: message) let log = Log(message: message, user: self.userId)
self._logsCollection().addOrUpdate(instance: log) self._logsCollection().addOrUpdate(instance: log)
} }
} }
// MARK: - Migration
/// Migrates the token from the provided service to the main Services instance
// public func migrateToken(_ services: Services) throws {
// guard let userName = self.userName() else {
// return
// }
// try self.service().migrateToken(services, userName: userName)
// }
deinit { deinit {
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
} }

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

@ -0,0 +1,630 @@
//
// StoredCollection.swift
// LeStorage
//
// Created by Laurent Morvillier on 02/02/2024.
//
import Foundation
import Combine
public protocol SomeCollection<Item>: Identifiable {
associatedtype Item: Storable
var hasLoaded: Bool { get }
var inMemory: Bool { get }
var type: any Storable.Type { get }
func reset()
func hasParentReferences<S: Storable>(type: S.Type, id: String) -> Bool
var items: [Item] { get }
func deleteAllItemsAndDependencies(actionOption: ActionOption)
func deleteDependencies(actionOption: ActionOption, _ isIncluded: (Item) -> Bool)
func findById(_ id: Item.ID) -> Item?
func requestWriteIfNecessary()
}
protocol CollectionDelegate<Item> {
associatedtype Item: Storable
func loadingForMemoryCollection() async
func itemMerged(_ pendingOperation: PendingOperation<Item>)
}
enum CollectionMethod {
case insert
case update
case delete
}
public struct ActionResult<T> {
var instance: T
var method: CollectionMethod
var pending: Bool
}
public struct ActionOption: Codable {
var synchronize: Bool
var cascade: Bool
var write: Bool
static let standard: ActionOption = ActionOption(synchronize: false, cascade: false, write: true)
static let noCascadeNoWrite: ActionOption = ActionOption(synchronize: false, cascade: false, write: false)
static let cascade: ActionOption = ActionOption(synchronize: false, cascade: true, write: true)
static let syncedCascade: ActionOption = ActionOption(synchronize: true, cascade: true, write: true)
}
public class StoredCollection<T: Storable>: SomeCollection {
public typealias Item = T
/// Doesn't write the collection in a file
fileprivate(set) public var inMemory: Bool = false
/// The list of stored items
@Published public fileprivate(set) var items: [T] = []
/// The reference to the Store
fileprivate(set) var store: Store
/// Provides fast access for instances if the collection has been instanced with [indexed] = true
fileprivate var _indexes: [T.ID: T]? = nil
/// A PendingOperationManager instance that manages operations while the collection is not loaded
fileprivate(set) var pendingOperationManager: PendingOperationManager<T>? = nil
fileprivate var _writingTimer: Timer? = nil
/// Indicates whether the collection has changed, thus requiring a write operation
fileprivate var _triggerWrite: Bool = false {
didSet {
if self._triggerWrite == true {
self._scheduleWrite()
self._triggerWrite = false
}
DispatchQueue.main.async {
NotificationCenter.default.post(
name: NSNotification.Name.CollectionDidChange, object: self)
}
}
}
/// Indicates if the collection has loaded locally, with or without a file
fileprivate(set) public var hasLoaded: Bool = false
/// Sets a max number of items inside the collection
fileprivate(set) var limit: Int? = nil
init(store: Store, inMemory: Bool = false) async {
self.store = store
if self.inMemory == false {
await self.loadFromFile()
}
}
init(store: Store, indexed: Bool = false, inMemory: Bool = false, limit: Int? = nil, noLoad: Bool = false) {
if indexed {
self._indexes = [:]
}
self.inMemory = inMemory
self.store = store
self.limit = limit
if noLoad {
self.hasLoaded = true
} else {
Task {
await self.load()
}
}
}
init(store: Store) {
self.store = store
}
var storeCenter: StoreCenter { return self.store.storeCenter }
/// Returns the name of the managed resource
public var resourceName: String {
return T.resourceName()
}
public var storeId: String? {
return self.store.identifier
}
// MARK: - Loading
/// Sets the collection as changed to trigger a write
public func requestWriteIfNecessary() {
if self.inMemory == false {
self._triggerWrite = true
}
}
/// Migrates if necessary and asynchronously decodes the json file
func load() async {
if !self.inMemory {
await self.loadFromFile()
} else {
await MainActor.run {
self.setAsLoaded()
}
}
}
/// Starts the JSON file decoding asynchronously
func loadFromFile() async {
do {
try await self._decodeJSONFile()
} catch {
Logger.error(error)
await MainActor.run {
self.setAsLoaded()
}
do {
let fileURL = try self.store.fileURL(type: T.self)
let jsonString: String = try FileUtils.readFile(fileURL: fileURL)
if !jsonString.isEmpty {
StoreCenter.main.log(message: "Could not decode: \(jsonString)")
}
} catch {
}
}
}
/// Decodes the json file into the items array
fileprivate func _decodeJSONFile() async throws {
let fileURL = try self.store.fileURL(type: T.self)
if FileManager.default.fileExists(atPath: fileURL.path()) {
let jsonString: String = try FileUtils.readFile(fileURL: fileURL)
let decoded: [T] = try jsonString.decodeArray() ?? []
self.hasLoaded = true // avoid pending management
self.setItems(decoded)
}
await MainActor.run {
self.setAsLoaded()
}
}
/// Sets the collection as loaded
/// Send a CollectionDidLoad event
@MainActor
func setAsLoaded() {
self.hasLoaded = true
self._mergePendingOperations()
NotificationCenter.default.post(
name: NSNotification.Name.CollectionDidLoad, object: self)
}
/// Sets a collection of items and indexes them
func setItems(_ items: [T]) {
self.clear()
for item in items {
self._addItem(instance: item)
}
}
@MainActor
func loadAndWrite(_ items: [T], clear: Bool = false) {
if clear {
self.setItems(items)
self.setAsLoaded()
} else {
self.setAsLoaded()
self.addOrUpdate(contentOfs: items)
}
self.requestWriteIfNecessary()
}
/// Updates the whole index with the items array
fileprivate func _updateIndexIfNecessary() {
if self._indexes != nil {
self._indexes = self.items.dictionary { $0.id }
}
}
// MARK: - Basic operations
/// Adds or updates the provided instance inside the collection
/// Adds it if its id is not found, and otherwise updates it
@discardableResult public func addOrUpdate(instance: T) -> ActionResult<T> {
defer {
self.requestWriteIfNecessary()
}
return self._rawAddOrUpdate(instance: instance)
}
/// Adds or update a sequence of elements
public func addOrUpdate(contentOfs sequence: any Sequence<T>, _ handler: ((ActionResult<T>) -> ())? = nil) {
defer {
self.requestWriteIfNecessary()
}
for instance in sequence {
let result = self._rawAddOrUpdate(instance: instance)
handler?(result)
}
}
fileprivate func _rawAddOrUpdate(instance: T) -> ActionResult<T> {
if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
let updated = self._updateItem(instance, index: index, actionOption: .standard)
return ActionResult(instance: instance, method: .update, pending: !updated)
} else {
let added = self._addItem(instance: instance)
return ActionResult(instance: instance, method: .insert, pending: !added)
}
}
/// A method the treat the collection as a single instance holder
func setSingletonNoSync(instance: T) {
defer {
self.requestWriteIfNecessary()
}
self.clear()
self._addItem(instance: instance)
}
/// Deletes the instance in the collection and sets the collection as changed to trigger a write
public func delete(instance: T) {
self.delete(instance: instance, actionOption: .cascade)
}
/// Deletes the instance in the collection and sets the collection as changed to trigger a write
public func 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)
}
func delete(contentOfs sequence: any RandomAccessCollection<T>, actionOption: ActionOption, handler: ((ActionResult<T>) -> ())? = nil) {
defer {
self._triggerWrite = true
}
for instance in sequence {
let deleted = self.deleteItem(instance, actionOption: actionOption)
handler?(ActionResult(instance: instance, method: .delete, pending: !deleted))
}
}
/// This method sets the storeId for the given instance if the collection belongs to a store with an id
fileprivate func _affectStoreIdIfNecessary(instance: T) {
if let storeId = self.store.identifier {
if var altStorable = instance as? SideStorable {
altStorable.storeId = storeId
} else {
fatalError("instance does not implement SideStorable, thus sync cannot work")
}
}
}
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 {
if !self.hasLoaded {
self.addPendingOperation(method: .add, instance: instance, actionOption: actionOption)
return false
}
self.invalidateCache()
self._affectStoreIdIfNecessary(instance: instance)
self.items.append(instance)
instance.store = self.store
self._indexes?[instance.id] = instance
self._applyLimitIfPresent()
if T.storeParent() {
_ = self.storeCenter.requestStore(identifier: instance.stringId) // make directory
}
return true
}
func update(_ instance: T, index: Int, actionOption: ActionOption) {
self._updateItem(instance, index: index, actionOption: actionOption)
// self.requestWrite()
}
/// Updates an instance to the collection by index
@discardableResult fileprivate func _updateItem(_ instance: T, index: Int, actionOption: ActionOption) -> Bool {
if !self.hasLoaded {
self.addPendingOperation(method: .update, instance: instance, actionOption: actionOption)
return false
}
self.invalidateCache()
let item = self.items[index]
if item !== instance {
self.items[index].copy(from: instance)
}
instance.store = self.store
self._indexes?[instance.id] = instance
return true
}
/// Deletes an instance from the collection
@discardableResult fileprivate func deleteItem(_ instance: T, actionOption: ActionOption = .cascade) -> Bool {
if !self.hasLoaded {
self.addPendingOperation(method: .delete, instance: instance, actionOption: actionOption)
return false
}
if actionOption.cascade {
instance.deleteDependencies(store: self.store, actionOption: actionOption)
}
self.localDeleteOnly(instance: instance)
if T.storeParent() {
self.storeCenter.destroyStore(identifier: instance.stringId)
}
return true
}
/// Deletes an instance from the collection
@discardableResult func deleteUnusedShared(_ instance: T, actionOption: ActionOption) -> Bool {
if !self.hasLoaded {
self.addPendingOperation(method: .deleteUnusedShared, instance: instance, actionOption: actionOption)
return false
}
// For shared objects, we need to check for dependencies that are also shared
// but not used elsewhere before deleting them
instance.deleteUnusedSharedDependencies(store: self.store)
self.localDeleteOnly(instance: instance)
return true
}
func localDeleteOnly(instance: T) {
self.invalidateCache()
self.items.removeAll { $0.id == instance.id }
self._indexes?.removeValue(forKey: instance.id)
}
/// If the collection has more instance that its limit, remove the surplus
fileprivate func _applyLimitIfPresent() {
if let limit {
self.items = self.items.suffix(limit)
}
}
func deleteByStringId(_ id: String, actionOption: ActionOption = .cascade) {
let realId = T.buildRealId(id: id)
if let instance = self.findById(realId) {
self.deleteItem(instance, actionOption: actionOption)
}
if actionOption.write {
self.requestWriteIfNecessary()
}
}
/// Returns the instance corresponding to the provided [id]
public func findById(_ id: T.ID) -> T? {
if let index = self._indexes, let instance = index[id] {
return instance
}
return self.items.first(where: { $0.id == id })
}
/// Deletes a list of items
public func deleteDependencies(_ items: any Sequence<T>) {
defer {
self.requestWriteIfNecessary()
}
self.invalidateCache()
let itemsArray = Array(items) // fix error if items is self.items
for item in itemsArray {
if let index = self.items.firstIndex(where: { $0.id == item.id }) {
self.items.remove(at: index)
}
}
}
public func deleteAllItemsAndDependencies(actionOption: ActionOption) {
self._delete(contentOfs: self.items, actionOption: actionOption)
}
public func deleteDependencies(actionOption: ActionOption, _ isIncluded: (T) -> Bool) {
let items = self.items.filter(isIncluded)
self._delete(contentOfs: items, actionOption: actionOption)
}
fileprivate func _delete(contentOfs sequence: any RandomAccessCollection<T>, actionOption: ActionOption) {
for instance in sequence {
self.deleteItem(instance, actionOption: actionOption)
}
}
// MARK: - Pending operations
func addPendingOperation(method: StorageMethod, instance: T, actionOption: ActionOption) {
if self.pendingOperationManager == nil {
self.pendingOperationManager = PendingOperationManager<T>(store: self.store, inMemory: self.inMemory)
}
self._addPendingOperationIfPossible(method: method, instance: instance, actionOption: actionOption)
}
fileprivate func _addPendingOperationIfPossible(method: StorageMethod, instance: T, actionOption: ActionOption) {
self.pendingOperationManager?.addPendingOperation(method: method, instance: instance, actionOption: actionOption)
}
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
/// Schedules a write operation
fileprivate func _scheduleWrite() {
self._cleanTimer()
DispatchQueue.main.async {
self._writingTimer = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(self._write), userInfo: nil, repeats: false)
}
}
fileprivate func _cleanTimer() {
self._writingTimer?.invalidate()
self._writingTimer = nil
}
/// Writes all the items as a json array inside a file
@objc fileprivate func _write() {
DispatchQueue(label: "lestorage.queue.write", qos: .utility).async {
do {
let jsonString: String = try self.items.jsonString()
try self.store.write(content: jsonString, fileName: T.fileName())
} catch {
Logger.error(error)
self.storeCenter.log(
message: "write failed for \(T.resourceName()): \(error.localizedDescription)")
}
}
self._cleanTimer()
}
/// Simply clears the items of the collection
public func clear() {
self.invalidateCache()
self.items.removeAll()
}
/// Removes the items of the collection and deletes the corresponding file
public func reset() {
self.clear()
self.store.removeFile(type: T.self)
}
public var type: any Storable.Type { return T.self }
// MARK: - Reference count
/// Counts the references to an object - given its type and id - inside the collection
public func hasParentReferences<S: Storable>(type: S.Type, id: String) -> Bool {
let relationships = T.parentRelationships().filter { $0.type == type }
guard relationships.count > 0 else { return false }
for item in self.items {
for relationship in relationships {
if item[keyPath: relationship.keyPath] as? String == id {
return true
}
}
}
return false
}
// MARK: - for Synced Collection
@MainActor
func updateLocalInstance(_ serverInstance: T) {
if let localInstance = self.findById(serverInstance.id) {
localInstance.copy(from: serverInstance)
self.requestWriteIfNecessary()
}
}
// MARK: - Cached queries
fileprivate var _cacheVersion = 0
fileprivate var _queryCache: [AnyHashable: (version: Int, result: Any)] = [:]
// Generic query method with caching
public func cached<Result>(
key: AnyHashable,
compute: ([T]) -> Result
) -> Result {
if let cached = self._queryCache[key],
cached.version == self._cacheVersion,
let result = cached.result as? Result {
return result
}
let result = compute(items)
self._queryCache[key] = (self._cacheVersion, result)
return result
}
private func invalidateCache() {
self._cacheVersion += 1
}
}
extension StoredCollection: RandomAccessCollection {
public static func placeholder() -> StoredCollection<T> {
return StoredCollection<T>(store: Store(storeCenter: StoreCenter.main))
}
public var startIndex: Int { return self.items.startIndex }
public var endIndex: Int { return self.items.endIndex }
public func index(after i: Int) -> Int {
return self.items.index(after: i)
}
public subscript(index: Int) -> T {
get {
return self.items[index]
}
set(newValue) {
self.items[index] = newValue
self._triggerWrite = true
}
}
}

@ -10,9 +10,22 @@ import Foundation
/// A class extending the capabilities of StoredCollection but supposedly manages only one item /// A class extending the capabilities of StoredCollection but supposedly manages only one item
public class StoredSingleton<T: SyncedStorable>: SyncedCollection<T> { public class StoredSingleton<T: 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
@ -24,7 +37,7 @@ public class StoredSingleton<T: SyncedStorable>: SyncedCollection<T> {
/// 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 { public func tryPutBeforeUpdating(_ instance: T) async throws {

@ -12,24 +12,34 @@ protocol SomeSyncedCollection: SomeCollection {
func loadCollectionsFromServerIfNoFile() async throws func loadCollectionsFromServerIfNoFile() async throws
} }
public class SyncedCollection<T : SyncedStorable>: BaseCollection<T>, SomeSyncedCollection { 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)
/// Returns a dummy SyncedCollection instance
public static func placeholder() -> SyncedCollection<T> {
return SyncedCollection<T>(store: Store(storeCenter: StoreCenter.main))
} }
/// Migrates if necessary and asynchronously decodes the json file init(store: Store, inMemory: Bool) async {
override func load() async { self.store = store
do { self.collection = await StoredCollection(store: store, inMemory: inMemory)
if self.inMemory {
try await self.loadDataFromServerIfAllowed()
} else {
await self.loadFromFile()
} }
} catch {
Logger.error(error) 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 /// Loads the collection using the server data only if the collection file doesn't exists
@ -40,15 +50,8 @@ public class SyncedCollection<T : SyncedStorable>: BaseCollection<T>, SomeSynced
} }
} }
// func loadDataFromServerIfAllowed() async throws {
// try await self.loadDataFromServerIfAllowed(clear: false)
// }
/// Retrieves the data from the server and loads it into the items array /// Retrieves the data from the server and loads it into the items array
public func loadDataFromServerIfAllowed(clear: Bool = false) async throws { public func loadDataFromServerIfAllowed(clear: Bool = false) async throws {
guard !(self is StoredSingleton<T>) else {
throw StoreError.cannotSyncCollection(name: self.resourceName)
}
do { do {
try await self.storeCenter.sendGetRequest(T.self, storeId: self.storeId, clear: clear) try await self.storeCenter.sendGetRequest(T.self, storeId: self.storeId, clear: clear)
} catch { } catch {
@ -56,6 +59,11 @@ public class SyncedCollection<T : SyncedStorable>: BaseCollection<T>, SomeSynced
} }
} }
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 /// 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. /// 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 /// - serverInstance: the instance of the object on the server
@ -65,153 +73,103 @@ public class SyncedCollection<T : SyncedStorable>: BaseCollection<T>, SomeSynced
return return
} }
DispatchQueue.main.async { Task {
if let localInstance = self.findById(serverInstance.id) { await self.collection.updateLocalInstance(serverInstance)
localInstance.copy(from: serverInstance)
self.setChanged()
}
} }
} }
@MainActor @MainActor
func loadItems(_ items: [T], clear: Bool = false) { func loadItems(_ items: [T], clear: Bool = false) {
if clear { self.collection.loadAndWrite(items, clear: clear)
self.setItems(items)
} else {
self.addOrUpdateNoSync(contentOfs: items, checkLoaded: false)
}
self.setAsLoaded()
self.setChanged()
} }
// MARK: - Basic operations with sync // MARK: - Basic operations with sync
/// Adds or update an instance asynchronously and waits for network operations
func addOrUpdateAsync(instance: T) async {
if let result = _addOrUpdateCore(instance: instance) {
if result.isNewItem {
await self._executeBatchOnce(OperationBatch(insert: result.item))
} else {
await self._executeBatchOnce(OperationBatch(update: result.item))
}
}
}
/// Adds or update an instance synchronously, dispatching network operations to background tasks /// Adds or update an instance synchronously, dispatching network operations to background tasks
public override func addOrUpdate(instance: T) { public func addOrUpdate(instance: T) {
if let result = _addOrUpdateCore(instance: instance) { let result = _addOrUpdateCore(instance: instance)
if result.isNewItem { if result.method == .insert {
Task { await self._sendInsertion(result.item) } Task { await self._sendInsertion(instance) }
} else { } else {
Task { await self._sendUpdate(result.item) } Task { await self._sendUpdate(instance) }
}
} }
} }
/// Private helper function that contains the shared logic /// Private helper function that contains the shared logic
private func _addOrUpdateCore(instance: T) -> (item: T, isNewItem: Bool)? { private func _addOrUpdateCore(instance: T) -> ActionResult<T> {
instance.lastUpdate = Date() instance.lastUpdate = Date()
if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
if self.updateItem(instance, index: index, shouldBeSynchronized: true) {
self.setChanged()
return (instance, false)
}
} else {
if self.addItem(instance: instance, shouldBeSynchronized: true) {
self.setChanged()
return (instance, true)
}
}
return nil
}
// func addOrUpdateAsync(instance: T) async {
// instance.lastUpdate = Date()
// if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
// if self.updateItem(instance, index: index, shouldBeSynchronized: true) {
// await self._sendUpdate(instance)
// self.setChanged()
// }
// } else {
// if self.addItem(instance: instance, shouldBeSynchronized: true) {
// await self._sendInsertion(instance)
// self.setChanged()
// }
// }
// }
//
// /// Adds or update an instance and writes
// public override func addOrUpdate(instance: T) {
// instance.lastUpdate = Date()
// if let index = self.items.firstIndex(where: { $0.id == instance.id }) {
// if self.updateItem(instance, index: index, shouldBeSynchronized: true) {
// Task { await self._sendUpdate(instance) }
// self.setChanged()
// }
// } else {
// if self.addItem(instance: instance, shouldBeSynchronized: true) {
// Task { await self._sendInsertion(instance) }
// self.setChanged()
// }
// }
// }
fileprivate func _addOrUpdateCore(contentOfs sequence: any Sequence<T>) -> OperationBatch<T> { let result = self.collection.addOrUpdate(instance: instance)
if result.method == .update {
if instance.sharing != nil {
self._cleanUpSharedDependencies()
}
}
defer { return result
self.setChanged()
} }
fileprivate func _addOrUpdateCore(contentOfs sequence: any Sequence<T>) -> OperationBatch<T> {
let date = Date() let date = Date()
let batch = OperationBatch<T>() let batch = OperationBatch<T>()
for instance in sequence { for instance in sequence {
instance.lastUpdate = date instance.lastUpdate = date
if let index = self.items.firstIndex(where: { $0.id == instance.id }) { let result = self.collection.addOrUpdate(instance: instance)
if self.updateItem(instance, index: index, shouldBeSynchronized: true) {
batch.addUpdate(instance) if result.method == .insert {
}
} else { // insert
if self.addItem(instance: instance, shouldBeSynchronized: true) {
batch.addInsert(instance) batch.addInsert(instance)
} else {
batch.addUpdate(instance)
} }
} }
}
self._cleanUpSharedDependencies()
return batch return batch
} }
/// Adds or update a sequence and writes /// Adds or update a sequence and writes
override public func addOrUpdate(contentOfs sequence: any Sequence<T>) { public func addOrUpdate(contentOfs sequence: any Sequence<T>) {
let batch = self._addOrUpdateCore(contentOfs: sequence) let batch = self._addOrUpdateCore(contentOfs: sequence)
Task { await self._sendOperationBatch(batch) } Task { await self._sendOperationBatch(batch) }
} }
func addOrUpdateAsync(contentOfs sequence: any Sequence<T>) async { /// Deletes an instance and writes
let batch = self._addOrUpdateCore(contentOfs: sequence) public func delete(instance: T) {
await self._executeBatchOnce(batch)
} self.collection.delete(instance: instance, actionOption: .syncedCascade)
self.storeCenter.createDeleteLog(instance)
Task { await self._sendDeletion(instance) }
/// Proceeds to delete all instance of the collection, properly cleaning up dependencies and sending API calls
override public func deleteAll() throws {
self.delete(contentOfs: self.items)
} }
/// Deletes all items of the sequence by id and sets the collection as changed to trigger a write /// 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>) -> OperationBatch<T> { public func delete(contentOfs sequence: any RandomAccessCollection<T>) {
self.delete(contentOfs: sequence, actionOption: .syncedCascade)
}
defer { func delete(contentOfs sequence: any RandomAccessCollection<T>, actionOption: ActionOption) {
self.setChanged() guard sequence.isNotEmpty else { return }
let batch = self._deleteCore(contentOfs: sequence, actionOption: actionOption)
if actionOption.synchronize {
Task { await self._sendOperationBatch(batch) }
}
} }
var deleted: [T] = [] /// 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> {
for instance in sequence { var deleted: [T] = []
if self.deleteItem(instance, shouldBeSynchronized: true) { self.collection.delete(contentOfs: sequence, actionOption: actionOption) { result in
deleted.append(instance) self.storeCenter.createDeleteLog(result.instance)
if !result.pending {
deleted.append(result.instance)
} }
self.storeCenter.createDeleteLog(instance)
} }
let batch = OperationBatch<T>() let batch = OperationBatch<T>()
@ -219,93 +177,146 @@ public class SyncedCollection<T : SyncedStorable>: BaseCollection<T>, SomeSynced
return batch return batch
} }
/// Deletes all items of the sequence by id and sets the collection as changed to trigger a write fileprivate func _cleanUpSharedDependencies() {
public override func delete(contentOfs sequence: any RandomAccessCollection<T>) { for relationship in T.relationships() {
guard sequence.isNotEmpty else { return } if let syncedType = relationship.type as? (any SyncedStorable.Type) {
let batch = self._deleteCore(contentOfs: sequence) do {
Task { await self._sendOperationBatch(batch) } try self._deleteUnusedSharedInstances(relationship: relationship, type: syncedType, originStoreId: self.storeId)
} catch {
Logger.error(error)
}
}
}
} }
/// Deletes all items of the sequence by id and sets the collection as changed to trigger a write fileprivate func _deleteUnusedSharedInstances<S: SyncedStorable>(relationship: Relationship, type: S.Type, originStoreId: String?) throws {
public func deleteAsync(contentOfs sequence: any RandomAccessCollection<T>) async {
guard sequence.isNotEmpty else { return } let store: Store
let batch = self._deleteCore(contentOfs: sequence) switch relationship.storeLookup {
await self._executeBatchOnce(batch) case .main: store = self.store.storeCenter.mainStore
case .same: store = self.store
case .child:
throw StoreError.invalidStoreLookup(from: type, to: relationship.type)
} }
/// Deletes an instance and writes let collection: SyncedCollection<S> = try store.syncedCollection()
func deleteAsync(instance: T) async { collection._deleteUnusedGrantedInstances(originStoreId: originStoreId)
defer { }
self.setChanged()
fileprivate func _deleteUnusedGrantedInstances(originStoreId: String?) {
let sharedItems = self.collection.items.filter { $0.sharing == .granted }
for sharedItem in sharedItems {
self.store.deleteUnusedGrantedIfNecessary(sharedItem, originStoreId: originStoreId
)
} }
self._deleteNoWrite(instance: instance)
Task { await self._executeBatchOnce(OperationBatch(delete: instance)) }
} }
/// Deletes an instance and writes public func deleteAllItemsAndDependencies(actionOption: ActionOption) {
override public func delete(instance: T) { if actionOption.synchronize {
defer { self.delete(contentOfs: self.items, actionOption: actionOption)
self.setChanged() } else {
self.collection.deleteAllItemsAndDependencies(actionOption: actionOption)
} }
self._deleteNoWrite(instance: instance)
Task { await self._sendDeletion(instance) }
} }
/// Deletes an instance without writing, logs the operation and sends an API call public func deleteDependencies(actionOption: ActionOption, _ isIncluded: (T) -> Bool) {
fileprivate func _deleteNoWrite(instance: T) { let items = self.items.filter(isIncluded)
self.deleteItem(instance, shouldBeSynchronized: true) if actionOption.synchronize {
self.storeCenter.createDeleteLog(instance) self.delete(contentOfs: items, actionOption: actionOption)
// await self._sendDeletion(instance) } else {
self.collection.delete(contentOfs: items)
}
} }
public func deleteDependencies(_ items: any RandomAccessCollection<T>, shouldBeSynchronized: Bool) { // MARK: - Asynchronous operations
guard items.isNotEmpty else { return }
if shouldBeSynchronized { /// Adds or update an instance asynchronously and waits for network operations
self.delete(contentOfs: items) public func addOrUpdateAsync(instance: T) async throws {
let result = _addOrUpdateCore(instance: instance)
if result.method == .insert {
try await self._executeBatchOnce(OperationBatch(insert: instance))
} else { } else {
self.deleteNoSync(contentOfs: items) 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 // MARK: - Basic operations without sync
/// Adds or update an instance without synchronizing it /// Adds or update an instance without synchronizing it
func addOrUpdateNoSync(_ instance: T) { func addOrUpdateNoSync(_ instance: T) {
self.addOrUpdateItem(instance: instance) self.collection.addOrUpdate(instance: instance)
// self.addOrUpdateItem(instance: instance)
} }
/// Adds or update a sequence of elements without synchronizing it /// Adds or update a sequence of elements without synchronizing it
func addOrUpdateNoSync(contentOfs sequence: any Sequence<T>, checkLoaded: Bool = false) { func addOrUpdateNoSync(contentOfs sequence: any Sequence<T>) {
self.addSequence(sequence, checkLoaded: false) 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 /// Deletes the instance in the collection without synchronization
func deleteNoSync(contentOfs sequence: any Sequence<T>) { public func deleteNoSync(instance: T, cascading: Bool = false) {
defer { self.collection.delete(instance: instance, actionOption: .cascade)
self.setChanged()
} }
for item in sequence {
self.deleteItem(item, shouldBeSynchronized: false) 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)
} }
/// Deletes the instance in the collection without synchronization // MARK: - Collection Delegate
func deleteNoSync(instance: T) {
defer { func loadingForMemoryCollection() async {
self.setChanged() do {
try await self.loadDataFromServerIfAllowed()
} catch {
Logger.error(error)
} }
self.deleteItem(instance, shouldBeSynchronized: false)
} }
/// Deletes the instance in the collection without synchronization func itemMerged(_ pendingOperation: PendingOperation<T>) {
func deleteByStringIdNoSync(_ id: String) {
defer { let batch = OperationBatch<T>()
self.setChanged() switch pendingOperation.method {
} case .add:
let realId = T.buildRealId(id: id) batch.inserts.append(pendingOperation.data)
if let instance = self.findById(realId) { case .update:
self.deleteItem(instance, shouldBeSynchronized: false) batch.updates.append(pendingOperation.data)
case .delete:
batch.deletes.append(pendingOperation.data)
case .deleteUnusedShared:
break
} }
Task { await self._sendOperationBatch(batch) }
} }
// MARK: - Send requests // MARK: - Send requests
@ -330,12 +341,8 @@ public class SyncedCollection<T : SyncedStorable>: BaseCollection<T>, SomeSynced
} }
} }
fileprivate func _executeBatchOnce(_ batch: OperationBatch<T>) async { fileprivate func _executeBatchOnce(_ batch: OperationBatch<T>) async throws {
do {
try await self.storeCenter.singleBatchExecution(batch) try await self.storeCenter.singleBatchExecution(batch)
} catch {
Logger.error(error)
}
} }
// MARK: Single calls // MARK: Single calls
@ -352,69 +359,21 @@ public class SyncedCollection<T : SyncedStorable>: BaseCollection<T>, SomeSynced
} }
} }
/// Sends an insert api call for the provided
/// Calls copyFromServerInstance on the instance with the result of the HTTP call
/// - Parameters:
/// - instance: the object to POST
// fileprivate func _sendInsertionIfNecessary(_ instance: T) {
//
// Task {
// do {
// if let result = try await self.store.sendInsertion(instance) {
// self.updateFromServerInstance(result)
// }
// } catch {
// Logger.error(error)
// }
// }
// }
//
// /// Sends an update api call for the provided [instance]
// /// - Parameters:
// /// - instance: the object to PUT
// fileprivate func _sendUpdateIfNecessary(_ instance: T) {
// Task {
// do {
// try await self.store.sendUpdate(instance)
// } catch {
// Logger.error(error)
// }
// }
// }
//
// /// Sends an delete api call for the provided [instance]
// /// - Parameters:
// /// - instance: the object to DELETE
// fileprivate func _sendDeletionIfNecessary(_ instance: T) {
// Task {
// do {
// try await self.store.sendDeletion(instance)
// } catch {
// Logger.error(error)
// }
// }
// }
// MARK: - Synchronization // MARK: - Synchronization
/// Adds or update an instance if it is newer than the local instance /// Adds or update an instance if it is newer than the local instance
func addOrUpdateIfNewer(_ instance: T, shared: Bool) { func addOrUpdateIfNewer(_ instance: T, shared: SharingStatus?) {
defer {
self.setChanged()
}
if let index = self.items.firstIndex(where: { $0.id == instance.id }) { if let index = self.collection.items.firstIndex(where: { $0.id == instance.id }) {
let localInstance = self.items[index] let localInstance = self.collection.items[index]
if instance.lastUpdate > localInstance.lastUpdate { if instance.lastUpdate > localInstance.lastUpdate {
self.updateItem(instance, index: index) self.collection.update(instance, index: index, actionOption: .standard)
} else { } else {
print("do not update \(T.resourceName()): \(instance.lastUpdate.timeIntervalSince1970) / local: \(localInstance.lastUpdate.timeIntervalSince1970)") // print("do not update \(T.resourceName()): \(instance.lastUpdate.timeIntervalSince1970) / local: \(localInstance.lastUpdate.timeIntervalSince1970)")
} }
} else { // insert } else { // insert
if shared { instance.sharing = shared
instance.shared = true self.collection.add(instance: instance, actionOption: .standard)
}
self.addItem(instance: instance, shouldBeSynchronized: false)
} }
} }
@ -423,12 +382,48 @@ public class SyncedCollection<T : SyncedStorable>: BaseCollection<T>, SomeSynced
/// Sends a POST request for the instance, and changes the collection to perform a write /// Sends a POST request for the instance, and changes the collection to perform a write
public func writeChangeAndInsertOnServer(instance: T) { public func writeChangeAndInsertOnServer(instance: T) {
self.collection.addOrUpdate(instance: instance)
Task { Task {
await self._sendInsertion(instance) await self._sendInsertion(instance)
await MainActor.run {
self.setChanged()
} }
} }
// 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)
} }
} }
@ -461,3 +456,23 @@ class OperationBatch<T> {
self.deletes.append(instance) 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)
}
}
}

@ -7,10 +7,15 @@
import Foundation import Foundation
public enum SharingStatus: Int, Codable {
case shared = 1
case granted
}
public protocol SyncedStorable: Storable { public protocol SyncedStorable: Storable {
var lastUpdate: Date { get set } var lastUpdate: Date { get set }
var shared: Bool? { get set } var sharing: SharingStatus? { get set }
init() init()
@ -30,13 +35,7 @@ public protocol SideStorable {
var storeId: String? { get set } var storeId: String? { get set }
} }
public extension SyncedStorable { extension Storable {
func copy() -> Self {
let copy = Self()
copy.copy(from: self)
return copy
}
func getStoreId() -> String? { func getStoreId() -> String? {
if let alt = self as? SideStorable { if let alt = self as? SideStorable {
@ -46,3 +45,13 @@ public extension SyncedStorable {
} }
} }
public extension SyncedStorable {
func copy() -> Self {
let copy = Self()
copy.copy(from: self)
return copy
}
}

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

@ -19,6 +19,7 @@ class WebSocketManager: ObservableObject {
fileprivate var _reconnectAttempts = 0 fileprivate var _reconnectAttempts = 0
fileprivate var _failure = false fileprivate var _failure = false
fileprivate var _error: Error? = nil
fileprivate var _pingOk = false fileprivate var _pingOk = false
init(storeCenter: StoreCenter, urlString: String) { init(storeCenter: StoreCenter, urlString: String) {
@ -59,10 +60,13 @@ class WebSocketManager: ObservableObject {
switch result { switch result {
case .failure(let error): case .failure(let error):
self._failure = true self._failure = true
self._error = error
print("Error in receiving message: \(error)") print("Error in receiving message: \(error)")
self._handleWebSocketError(error) self._handleWebSocketError(error)
case .success(let message): case .success(let message):
self._failure = false self._failure = false
self._error = nil
self._reconnectAttempts = 0
switch message { switch message {
case .string(let deviceId): case .string(let deviceId):
// print("device id = \(StoreCenter.main.deviceId()), origin id: \(deviceId)") // print("device id = \(StoreCenter.main.deviceId()), origin id: \(deviceId)")
@ -71,11 +75,7 @@ class WebSocketManager: ObservableObject {
} }
Task { Task {
do { await self.storeCenter.synchronizeLastUpdates()
try await self.storeCenter.synchronizeLastUpdates()
} catch {
Logger.error(error)
}
} }
case .data(let data): case .data(let data):
@ -94,7 +94,7 @@ class WebSocketManager: ObservableObject {
private func _handleWebSocketError(_ error: Error) { private func _handleWebSocketError(_ error: Error) {
// print("WebSocket error: \(error)") // print("WebSocket error: \(error)")
// Exponential backoff for reconnection // up to 10 seconds of reconnection
let delay = min(Double(self._reconnectAttempts), 10.0) let delay = min(Double(self._reconnectAttempts), 10.0)
self._reconnectAttempts += 1 self._reconnectAttempts += 1
@ -135,7 +135,17 @@ class WebSocketManager: ObservableObject {
var pingStatus: Bool { var pingStatus: Bool {
return self._pingOk return self._pingOk
} }
var failure: Bool { var failure: Bool {
return self._failure return self._failure
} }
var error: Error? {
return self._error
}
var reconnectAttempts: Int {
return self._reconnectAttempts
}
} }

@ -9,6 +9,7 @@ import Testing
@testable import LeStorage @testable import LeStorage
class Thing: SyncedModelObject, SyncedStorable, URLParameterConvertible { class Thing: SyncedModelObject, SyncedStorable, URLParameterConvertible {
override required init() { override required init() {
super.init() super.init()
} }
@ -16,6 +17,8 @@ class Thing: SyncedModelObject, SyncedStorable, URLParameterConvertible {
static func resourceName() -> String { return "thing" } static func resourceName() -> String { return "thing" }
static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { return [] } static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { return [] }
static func filterByStoreIdentifier() -> Bool { return false } static func filterByStoreIdentifier() -> Bool { return false }
static var copyServerResponse: Bool = false
static func storeParent() -> Bool { return false }
var id: String = Store.randomId() var id: String = Store.randomId()
var name: String = "" var name: String = ""

@ -17,6 +17,7 @@ class Car: ModelObject, Storable {
} }
static func relationships() -> [LeStorage.Relationship] { return [] } static func relationships() -> [LeStorage.Relationship] { return [] }
static func storeParent() -> Bool { return false }
} }
@ -24,7 +25,7 @@ class Boat: ModelObject, SyncedStorable {
var id: String = Store.randomId() var id: String = Store.randomId()
var lastUpdate: Date = Date() var lastUpdate: Date = Date()
var shared: Bool? = false var sharing: LeStorage.SharingStatus?
override required init() { override required init() {
super.init() super.init()
@ -32,6 +33,8 @@ class Boat: ModelObject, SyncedStorable {
static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { return [] } static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { return [] }
static func resourceName() -> String { return "boat" } static func resourceName() -> String { return "boat" }
static var copyServerResponse: Bool = false
static func storeParent() -> Bool { return false }
var storeId: String? { return nil } var storeId: String? { return nil }
func copy(from other: any LeStorage.Storable) { func copy(from other: any LeStorage.Storable) {
@ -46,34 +49,19 @@ struct CollectionsTests {
var cars: StoredCollection<Car> var cars: StoredCollection<Car>
var boats: SyncedCollection<Boat> var boats: SyncedCollection<Boat>
init() { init() async {
let storeCenter = StoreCenter.main cars = await StoreCenter.main.mainStore.asyncLoadingStoredCollection(inMemory: true)
cars = storeCenter.mainStore.registerCollection(inMemory: true) boats = await StoreCenter.main.mainStore.asyncLoadingSynchronizedCollection(inMemory: true)
boats = storeCenter.mainStore.registerSynchronizedCollection(inMemory: true)
}
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)) @Test func testLoading() async {
} #expect(self.cars.hasLoaded)
collection.reset() #expect(self.boats.hasLoaded)
} }
@Test func differentiationTest() async throws { @Test func differentiationTest() async throws {
try await ensureCollectionLoaded(cars)
try await ensureCollectionLoaded(boats)
// Cars // Cars
#expect(cars.count == 0) #expect(cars.count == 0)
cars.addOrUpdate(instance: Car()) cars.addOrUpdate(instance: Car())
@ -83,21 +71,17 @@ struct CollectionsTests {
#expect(boats.count == 0) #expect(boats.count == 0)
let oldApiCallCount = await StoreCenter.main.apiCallCount(type: Boat.self) let oldApiCallCount = await StoreCenter.main.apiCallCount(type: Boat.self)
#expect(oldApiCallCount == 0)
boats.addOrUpdate(instance: Boat()) boats.addOrUpdate(instance: Boat())
#expect(boats.count == 1) #expect(boats.count == 1)
let newApiCallCount = await StoreCenter.main.apiCallCount(type: Boat.self)
#expect(oldApiCallCount == newApiCallCount - 1)
// Cars and boats // Cars and boats
cars.reset() cars.reset()
boats.reset() boats.reset()
#expect(cars.count == 0) #expect(cars.count == 0)
#expect(boats.count == 0) #expect(boats.count == 0)
} }
} }

@ -12,6 +12,7 @@ class IntObject: ModelObject, Storable {
static func resourceName() -> String { "int" } static func resourceName() -> String { "int" }
static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { [] } static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { [] }
static func storeParent() -> Bool { return false }
var id: Int var id: Int
var name: String var name: String
@ -30,8 +31,10 @@ class IntObject: ModelObject, Storable {
} }
class StringObject: ModelObject, Storable { class StringObject: ModelObject, Storable {
static func resourceName() -> String { "string" } static func resourceName() -> String { "string" }
static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { [] } static func tokenExemptedMethods() -> [LeStorage.HTTPMethod] { [] }
static func storeParent() -> Bool { return false }
var id: String var id: String
var name: String var name: String
@ -55,7 +58,8 @@ struct IdentifiableTests {
let stringObjects: StoredCollection<StringObject> let stringObjects: StoredCollection<StringObject>
init() { init() {
let storeCenter = StoreCenter.main let dir = "test_" + String.random()
let storeCenter: StoreCenter = StoreCenter(directoryName:dir)
intObjects = storeCenter.mainStore.registerCollection() intObjects = storeCenter.mainStore.registerCollection()
stringObjects = storeCenter.mainStore.registerCollection() stringObjects = storeCenter.mainStore.registerCollection()
} }

@ -20,34 +20,17 @@ struct StoredCollectionTests {
var collection: StoredCollection<MockStorable> var collection: StoredCollection<MockStorable>
init() { init() async {
collection = StoreCenter.main.mainStore.registerCollection() collection = await StoreCenter.main.mainStore.asyncLoadingStoredCollection(inMemory: true)
}
func ensureCollectionLoaded() 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() collection.reset()
} }
@Test func testInitialization() async throws { @Test func testInitialization() async throws {
try await ensureCollectionLoaded() #expect(self.collection.hasLoaded)
#expect(collection.items.count == 0) #expect(collection.items.count == 0)
} }
@Test func testAddOrUpdate() async throws { @Test func testAddOrUpdate() async throws {
try await ensureCollectionLoaded()
let item = MockStorable(id: "1", name: "Test") let item = MockStorable(id: "1", name: "Test")
collection.addOrUpdate(instance: item) collection.addOrUpdate(instance: item)
@ -61,7 +44,6 @@ struct StoredCollectionTests {
} }
@Test func testDelete() async throws { @Test func testDelete() async throws {
try await ensureCollectionLoaded()
let item = MockStorable(id: "1", name: "Test") let item = MockStorable(id: "1", name: "Test")
collection.addOrUpdate(instance: item) collection.addOrUpdate(instance: item)
#expect(collection.items.count == 1) #expect(collection.items.count == 1)
@ -71,7 +53,6 @@ struct StoredCollectionTests {
} }
@Test func testFindById() async throws { @Test func testFindById() async throws {
try await ensureCollectionLoaded()
let item = MockStorable(id: "1", name: "Test") let item = MockStorable(id: "1", name: "Test")
collection.addOrUpdate(instance: item) collection.addOrUpdate(instance: item)
@ -83,17 +64,15 @@ struct StoredCollectionTests {
} }
@Test func testDeleteById() async throws { @Test func testDeleteById() async throws {
try await ensureCollectionLoaded()
let item = MockStorable(id: "1", name: "Test") let item = MockStorable(id: "1", name: "Test")
collection.addOrUpdate(instance: item) collection.addOrUpdate(instance: item)
collection.deleteById("1") collection.deleteByStringId("1")
let search = collection.findById("1") let search = collection.findById("1")
#expect(search == nil) #expect(search == nil)
} }
@Test func testAddOrUpdateMultiple() async throws { @Test func testAddOrUpdateMultiple() async throws {
try await ensureCollectionLoaded()
let items = [ let items = [
MockStorable(id: "1", name: "Test1"), MockStorable(id: "1", name: "Test1"),
MockStorable(id: "2", name: "Test2"), MockStorable(id: "2", name: "Test2"),
@ -104,7 +83,6 @@ struct StoredCollectionTests {
} }
@Test func testDeleteAll() async throws { @Test func testDeleteAll() async throws {
try await ensureCollectionLoaded()
let items = [ let items = [
MockStorable(id: "1", name: "Test1"), MockStorable(id: "1", name: "Test1"),
MockStorable(id: "2", name: "Test2"), MockStorable(id: "2", name: "Test2"),
@ -118,7 +96,6 @@ struct StoredCollectionTests {
} }
@Test func testRandomAccessCollection() async throws { @Test func testRandomAccessCollection() async throws {
try await ensureCollectionLoaded()
let items = [ let items = [
MockStorable(id: "1", name: "Test1"), MockStorable(id: "1", name: "Test1"),
MockStorable(id: "2", name: "Test2"), MockStorable(id: "2", name: "Test2"),
@ -159,5 +136,6 @@ class MockStorable: ModelObject, Storable {
static func relationships() -> [LeStorage.Relationship] { static func relationships() -> [LeStorage.Relationship] {
return [] return []
} }
static func storeParent() -> Bool { return false }
} }

Loading…
Cancel
Save