diff --git a/Packages/Store/Package.resolved b/Packages/Store/Package.resolved deleted file mode 100644 index 3851ee1d8..000000000 --- a/Packages/Store/Package.resolved +++ /dev/null @@ -1,15 +0,0 @@ -{ - "originHash" : "967d39c8b3922e71eebcea9724f14480569febbaed95f2bf271eaf332523426c", - "pins" : [ - { - "identity" : "grdb.swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/groue/GRDB.swift.git", - "state" : { - "revision" : "aa0079aeb82a4bf00324561a40bffe68c6fe1c26", - "version" : "7.9.0" - } - } - ], - "version" : 3 -} diff --git a/Packages/Store/Package.swift b/Packages/Store/Package.swift index 25156b496..d7ed48525 100644 --- a/Packages/Store/Package.swift +++ b/Packages/Store/Package.swift @@ -12,7 +12,8 @@ let package = Package( ), .library( name: "StoreTestKit", - targets: ["StoreTestKit"]), + targets: ["StoreTestKit"] + ), ], dependencies: [ .package(name: "Primitives", path: "../Primitives"), diff --git a/Packages/SystemServices/CloudStore/Protocols/CloudSyncable.swift b/Packages/SystemServices/CloudStore/Protocols/CloudSyncable.swift new file mode 100644 index 000000000..57bd662fa --- /dev/null +++ b/Packages/SystemServices/CloudStore/Protocols/CloudSyncable.swift @@ -0,0 +1,18 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import CloudKit + +public protocol CloudSyncable: Identifiable, Codable, Sendable where ID == String { + static var recordType: String { get } +} + +extension CloudSyncable { + public static var recordType: String { + String(describing: Self.self) + } + + var recordID: CKRecord.ID { + CKRecord.ID(recordName: id) + } +} diff --git a/Packages/SystemServices/CloudStore/Services/CloudSyncService.swift b/Packages/SystemServices/CloudStore/Services/CloudSyncService.swift new file mode 100644 index 000000000..66cadd644 --- /dev/null +++ b/Packages/SystemServices/CloudStore/Services/CloudSyncService.swift @@ -0,0 +1,108 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import CloudKit + +public actor CloudSyncService { + private let container: CKContainer + private let database: CKDatabase + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + public init( + containerIdentifier: String, + encoder: JSONEncoder = JSONEncoder(), + decoder: JSONDecoder = JSONDecoder() + ) { + self.container = CKContainer(identifier: containerIdentifier) + self.database = container.privateCloudDatabase + self.encoder = encoder + self.decoder = decoder + } + + // MARK: - Account Status + + public func checkAccountStatus() async throws -> CKAccountStatus { + try await container.accountStatus() + } + + public func isAvailable() async -> Bool { + (try? await checkAccountStatus()) == .available + } + + // MARK: - Save + + public func save(_ item: T) async throws { + try await database.save(try createRecord(for: item)) + } + + public func save(_ items: [T]) async throws { + let records = try items.map { try createRecord(for: $0) } + _ = try await database.modifyRecords(saving: records, deleting: []) + } + + // MARK: - Fetch + + public func fetch(_ type: T.Type) async throws -> [T] { + try await fetchAllRecords(recordType: T.recordType).compactMap { _, result in + switch result { + case .success(let record): try decodeRecord(record, as: type) + case .failure: nil + } + } + } + + // MARK: - Delete + + public func delete(_ item: T) async throws { + try await database.deleteRecord(withID: item.recordID) + } + + public func delete(_ items: [T]) async throws { + _ = try await database.modifyRecords(saving: [], deleting: items.map { $0.recordID }) + } + + public func deleteAll(_ type: T.Type) async throws { + let ids = try await fetchAllRecords(recordType: T.recordType, desiredKeys: []).map { $0.0 } + guard !ids.isEmpty else { return } + _ = try await database.modifyRecords(saving: [], deleting: ids) + } + + // MARK: - Private + + private func createRecord(for item: T) throws -> CKRecord { + let record = CKRecord(recordType: T.recordType, recordID: item.recordID) + record.payload = try encoder.encode(item) + return record + } + + private func decodeRecord(_ record: CKRecord, as type: T.Type) throws -> T { + guard let data = record.payload else { + throw CloudSyncError.invalidRecordData + } + return try decoder.decode(type, from: data) + } + + private func fetchAllRecords( + recordType: String, + desiredKeys: [CKRecord.FieldKey]? = nil + ) async throws -> [(CKRecord.ID, Result)] { + let query = CKQuery(recordType: recordType, predicate: NSPredicate(value: true)) + var (allResults, cursor) = try await database.records(matching: query, desiredKeys: desiredKeys) + + while let currentCursor = cursor { + let (nextResults, nextCursor) = try await database.records(continuingMatchFrom: currentCursor, desiredKeys: desiredKeys) + allResults.append(contentsOf: nextResults) + cursor = nextCursor + } + + return allResults + } +} + +private extension CKRecord { + var payload: Data? { + get { self["data"] as? Data } + set { self["data"] = newValue } + } +} diff --git a/Packages/SystemServices/CloudStore/Types/CloudSyncError.swift b/Packages/SystemServices/CloudStore/Types/CloudSyncError.swift new file mode 100644 index 000000000..9f960fa6e --- /dev/null +++ b/Packages/SystemServices/CloudStore/Types/CloudSyncError.swift @@ -0,0 +1,7 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +public enum CloudSyncError: Error, Sendable { + case invalidRecordData +} diff --git a/Packages/SystemServices/Package.swift b/Packages/SystemServices/Package.swift index 450ec974f..2edf84e2f 100644 --- a/Packages/SystemServices/Package.swift +++ b/Packages/SystemServices/Package.swift @@ -9,7 +9,8 @@ let package = Package( .macOS(.v15) ], products: [ - .library(name: "ImageGalleryService", targets: ["ImageGalleryService"]) + .library(name: "ImageGalleryService", targets: ["ImageGalleryService"]), + .library(name: "CloudStore", targets: ["CloudStore"]) ], dependencies: [], targets: [ @@ -18,6 +19,11 @@ let package = Package( dependencies: [], path: "ImageGalleryService", exclude: ["Tests", "TestKit"] + ), + .target( + name: "CloudStore", + dependencies: [], + path: "CloudStore" ) ] )