Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions Packages/Store/CloudStore/Protocols/CloudSyncable.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
108 changes: 108 additions & 0 deletions Packages/Store/CloudStore/Services/CloudSyncService.swift
Original file line number Diff line number Diff line change
@@ -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<T: CloudSyncable>(_ item: T) async throws {
try await database.save(try createRecord(for: item))
}

public func save<T: CloudSyncable>(_ 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<T: CloudSyncable>(_ 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<T: CloudSyncable>(_ item: T) async throws {
try await database.deleteRecord(withID: item.recordID)
}

public func delete<T: CloudSyncable>(_ items: [T]) async throws {
_ = try await database.modifyRecords(saving: [], deleting: items.map { $0.recordID })
}

public func deleteAll<T: CloudSyncable>(_ 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<T: CloudSyncable>(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<T: CloudSyncable>(_ 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<CKRecord, any Error>)] {
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 }
}
}
7 changes: 7 additions & 0 deletions Packages/Store/CloudStore/Types/CloudSyncError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Copyright (c). Gem Wallet. All rights reserved.

import Foundation

public enum CloudSyncError: Error, Sendable {
case invalidRecordData
}
15 changes: 0 additions & 15 deletions Packages/Store/Package.resolved

This file was deleted.

12 changes: 11 additions & 1 deletion Packages/Store/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ let package = Package(
),
.library(
name: "StoreTestKit",
targets: ["StoreTestKit"]),
targets: ["StoreTestKit"]
),
.library(
name: "CloudStore",
targets: ["CloudStore"]
),
],
dependencies: [
.package(name: "Primitives", path: "../Primitives"),
Expand Down Expand Up @@ -40,8 +45,13 @@ let package = Package(
dependencies: [
"Store",
"StoreTestKit",
"CloudStore",
.product(name: "PrimitivesTestKit", package: "Primitives"),
]
),
.target(
name: "CloudStore",
path: "CloudStore"
),
]
)