Skip to content
7 changes: 5 additions & 2 deletions Examples/Reminders/Schema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,13 @@ func appDatabase() throws -> any DatabaseWriter {
db.add(function: $handleReminderStatusUpdate)
#if DEBUG
db.trace(options: .profile) {
if context == .live {
switch context {
case .live:
logger.debug("\($0.expandedDescription)")
} else {
case .preview:
print("\($0.expandedDescription)")
case .test:
break
}
}
#endif
Expand Down
5 changes: 4 additions & 1 deletion Examples/RemindersTests/Internal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import Testing
.dependency(\.continuousClock, ImmediateClock()),
.dependency(\.date.now, Date(timeIntervalSince1970: 1_234_567_890)),
.dependency(\.uuid, .incrementing),
.dependencies { try $0.bootstrapDatabase() },
.dependencies {
try $0.bootstrapDatabase()
try await $0.defaultSyncEngine.sendChanges()
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now users of our library can decide to send all changes to "iCloud" after they bootstrap their database (in reality this sends the records to a completely mocked, in-memory version of iCloud).

},
.snapshots(record: .failed)
)
struct BaseTestSuite {}
Expand Down
54 changes: 54 additions & 0 deletions Examples/RemindersTests/RemindersListsTests.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import Dependencies
import DependenciesTestSupport
import Foundation
import InlineSnapshotTesting
import SnapshotTestingCustomDump
import Testing
Expand All @@ -8,6 +10,9 @@ import Testing
extension BaseTestSuite {
@MainActor
struct RemindersListsTests {
@Dependency(\.defaultDatabase) var database
@Dependency(\.defaultSyncEngine) var syncEngine

@Test func basics() async throws {
let model = RemindersListsModel()
try await model.$remindersLists.load()
Expand Down Expand Up @@ -100,5 +105,54 @@ extension BaseTestSuite {
"""
}
}

@Test func share() async throws {
let model = RemindersListsModel()

let personalRemindersList = try #require(
try await database.read { db in
try RemindersList.find(UUID(0)).fetchOne(db)
}
)
let _ = try await syncEngine.share(record: personalRemindersList, configure: { _ in })
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There isn't a ton we can test in our demo when it comes to sharing, but we can at least see that the act of sharing this list does indeed populate the share state in the snapshot below. If there was more nuanced logic in this query we would be able to test that.


try await model.$remindersLists.load()
assertInlineSnapshot(of: model.remindersLists, as: .customDump) {
"""
[
[0]: RemindersListsModel.ReminderListState(
remindersCount: 4,
remindersList: RemindersList(
id: UUID(00000000-0000-0000-0000-000000000000),
color: 1218047999,
position: 1,
title: "Personal"
),
share: CKShare()
),
[1]: RemindersListsModel.ReminderListState(
remindersCount: 2,
remindersList: RemindersList(
id: UUID(00000000-0000-0000-0000-000000000001),
color: 3985191935,
position: 2,
title: "Family"
),
share: nil
),
[2]: RemindersListsModel.ReminderListState(
remindersCount: 2,
remindersList: RemindersList(
id: UUID(00000000-0000-0000-0000-000000000002),
color: 2992493567,
position: 3,
title: "Business"
),
share: nil
)
]
"""
}
}
}
}
110 changes: 105 additions & 5 deletions Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#if canImport(CloudKit)
import CloudKit
import IssueReporting
import OrderedCollections

@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
Expand Down Expand Up @@ -57,11 +58,19 @@
}

package func sendChanges(_ options: CKSyncEngine.SendChangesOptions) async throws {
guard
!parentSyncEngine.syncEngine(for: database.databaseScope).state.pendingRecordZoneChanges
.isEmpty
else { return }
try await parentSyncEngine.processPendingRecordZoneChanges(scope: database.databaseScope)

if !parentSyncEngine.syncEngine(for: database.databaseScope).state.pendingDatabaseChanges
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently our mock sync engine was not sending pending database changes (only record changes), and so this fixes that. I'm also realizing that our mocks do not interpret the SendChangesOptions to determine what records and zones we should be processing, but that can be something we incorporate later if we need.

.isEmpty
{

try await parentSyncEngine.processPendingDatabaseChanges(scope: database.databaseScope)
}
if !parentSyncEngine.syncEngine(for: database.databaseScope).state.pendingRecordZoneChanges
.isEmpty
{

try await parentSyncEngine.processPendingRecordZoneChanges(scope: database.databaseScope)
}
}

package func recordZoneChangeBatch(
Expand Down Expand Up @@ -270,6 +279,97 @@
)
}

package func processPendingDatabaseChanges(
scope: CKDatabase.Scope,
fileID: StaticString = #fileID,
filePath: StaticString = #filePath,
line: UInt = #line,
column: UInt = #column
) async throws {
let syncEngine = syncEngine(for: scope)
guard !syncEngine.state.pendingDatabaseChanges.isEmpty
else {
reportIssue(
"Processing empty set of database changes.",
fileID: fileID,
filePath: filePath,
line: line,
column: column
)
return
}
guard try await container.accountStatus() == .available
else {
reportIssue(
"User must be logged in to process pending changes.",
fileID: fileID,
filePath: filePath,
line: line,
column: column
)
return
}

var zonesToSave: [CKRecordZone] = []
var zoneIDsToDelete: [CKRecordZone.ID] = []
for pendingDatabaseChange in syncEngine.state.pendingDatabaseChanges {
switch pendingDatabaseChange {
case .saveZone(let zone):
zonesToSave.append(zone)
case .deleteZone(let zoneID):
zoneIDsToDelete.append(zoneID)
@unknown default:
fatalError("Unsupported pendingDatabaseChange: \(pendingDatabaseChange)")
}
}
let results:
(
saveResults: [CKRecordZone.ID: Result<CKRecordZone, any Error>],
deleteResults: [CKRecordZone.ID: Result<Void, any Error>]
) = try syncEngine.database.modifyRecordZones(
saving: zonesToSave,
deleting: zoneIDsToDelete
)
var savedZones: [CKRecordZone] = []
var failedZoneSaves: [(zone: CKRecordZone, error: CKError)] = []
var deletedZoneIDs: [CKRecordZone.ID] = []
var failedZoneDeletes: [CKRecordZone.ID: CKError] = [:]
for (zoneID, saveResult) in results.saveResults {
switch saveResult {
case .success(let zone):
savedZones.append(zone)
case .failure(let error as CKError):
failedZoneSaves.append((zonesToSave.first(where: { $0.zoneID == zoneID })!, error))
case .failure(let error):
reportIssue("Error thrown not CKError: \(error)")
}
}
for (zoneID, deleteResult) in results.deleteResults {
switch deleteResult {
case .success:
deletedZoneIDs.append(zoneID)
case .failure(let error as CKError):
failedZoneDeletes[zoneID] = error
case .failure(let error):
reportIssue("Error thrown not CKError: \(error)")
}
}

syncEngine.state.remove(pendingDatabaseChanges: savedZones.map { .saveZone($0) })
syncEngine.state.remove(pendingDatabaseChanges: deletedZoneIDs.map { .deleteZone($0) })

await syncEngine.parentSyncEngine
.handleEvent(
.sentDatabaseChanges(
savedZones: savedZones,
failedZoneSaves: failedZoneSaves,
deletedZoneIDs: deletedZoneIDs,
failedZoneDeletes: failedZoneDeletes
),
syncEngine: syncEngine
)
}

package var `private`: MockSyncEngine {
syncEngines.private as! MockSyncEngine
}
Expand Down
32 changes: 22 additions & 10 deletions Sources/SQLiteData/CloudKit/SyncEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
private let observationRegistrar = ObservationRegistrar()
private let notificationsObserver = LockIsolated<(any NSObjectProtocol)?>(nil)
private let activityCounts = LockIsolated(ActivityCounts())
private let startTask = LockIsolated<Task<Void, Never>?>(nil)

/// The error message used when a write occurs to a record for which the current user does not
/// have permission.
Expand Down Expand Up @@ -87,7 +88,7 @@
privateTables: repeat (each T2).Type,
containerIdentifier: String? = nil,
defaultZone: CKRecordZone = CKRecordZone(zoneName: "co.pointfree.SQLiteData.defaultZone"),
startImmediately: Bool = DependencyValues._current.context == .live,
startImmediately: Bool = true,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We previously did not start the sync engine automatically in tests because we weren't sure it was 100% ready for tests. Well now it is a lot more useful in tests so I think we can now start it by default.

delegate: (any SyncEngineDelegate)? = nil,
logger: Logger = isTesting
? Logger(.disabled) : Logger(subsystem: "SQLiteData", category: "CloudKit")
Expand Down Expand Up @@ -117,12 +118,15 @@
else {
let privateDatabase = MockCloudDatabase(databaseScope: .private)
let sharedDatabase = MockCloudDatabase(databaseScope: .shared)
let container = MockCloudContainer(
containerIdentifier: containerIdentifier ?? "iCloud.co.pointfree.SQLiteData.Tests",
privateCloudDatabase: privateDatabase,
sharedCloudDatabase: sharedDatabase
)
privateDatabase.set(container: container)
sharedDatabase.set(container: container)
try self.init(
container: MockCloudContainer(
containerIdentifier: containerIdentifier ?? "iCloud.co.pointfree.SQLiteData.Tests",
privateCloudDatabase: privateDatabase,
sharedCloudDatabase: sharedDatabase
),
container: container,
defaultZone: defaultZone,
defaultSyncEngines: { _, syncEngine in
(
Expand Down Expand Up @@ -487,10 +491,14 @@
($0.tableName, $0)
}
)
return Task {

let startTask = Task<Void, Never> {
await withErrorReporting(.sqliteDataCloudKitFailure) {
guard try await container.accountStatus() == .available
else { return }
syncEngines.withValue {
$0.private?.state.add(pendingDatabaseChanges: [.saveZone(defaultZone)])
}
try await uploadRecordsToCloudKit(
previousRecordTypeByTableName: previousRecordTypeByTableName,
currentRecordTypeByTableName: currentRecordTypeByTableName
Expand All @@ -502,8 +510,10 @@
try await cacheUserTables(recordTypes: currentRecordTypes)
}
}
self.startTask.withValue { $0 = startTask }
return startTask
}

/// Fetches pending remote changes from the server.
///
/// Use this method to ensure the sync engine immediately fetches all pending remote changes
Expand All @@ -515,6 +525,7 @@
public func fetchChanges(
_ options: CKSyncEngine.FetchChangesOptions = CKSyncEngine.FetchChangesOptions()
) async throws {
await startTask.withValue(\.self)?.value
let (privateSyncEngine, sharedSyncEngine) = syncEngines.withValue {
($0.private, $0.shared)
}
Expand All @@ -524,7 +535,7 @@
async let shared: Void = sharedSyncEngine.fetchChanges(options)
_ = try await (`private`, shared)
}

/// Sends pending local changes to the server.
///
/// Use this method to ensure the sync engine sends all pending local changes to the server
Expand All @@ -536,6 +547,7 @@
public func sendChanges(
_ options: CKSyncEngine.SendChangesOptions = CKSyncEngine.SendChangesOptions()
) async throws {
await startTask.withValue(\.self)?.value
let (privateSyncEngine, sharedSyncEngine) = syncEngines.withValue {
($0.private, $0.shared)
}
Expand All @@ -545,7 +557,7 @@
async let shared: Void = sharedSyncEngine.sendChanges(options)
_ = try await (`private`, shared)
}

/// Synchronizes local and remote pending changes.
///
/// Use this method to ensure the sync engine immediately fetches all pending remote changes
Expand Down
2 changes: 2 additions & 0 deletions Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,7 @@
try syncEngine.tearDownSyncEngine()
try syncEngine.setUpSyncEngine()
try await syncEngine.start()
try await syncEngine.processPendingDatabaseChanges(scope: .private)
let recordTypesAfterReSetup = try await syncEngine.metadatabase.read { db in
try RecordType.all.fetchAll(db)
}
Expand All @@ -422,6 +423,7 @@
}
try syncEngine.setUpSyncEngine()
try await syncEngine.start()
try await syncEngine.processPendingDatabaseChanges(scope: .private)

let recordTypesAfterMigration = try await syncEngine.metadatabase.read { db in
try RecordType.order(by: \.tableName).fetchAll(db)
Expand Down
1 change: 1 addition & 0 deletions Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2676,6 +2676,7 @@
}

try await syncEngine.start()
try await syncEngine.processPendingDatabaseChanges(scope: .private)
try await syncEngine.processPendingRecordZoneChanges(scope: .private)
try await syncEngine.processPendingRecordZoneChanges(scope: .shared)

Expand Down
Loading