From 328a4d3d4ff3fd3ebeb28ee21e76dd37666d58af Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 6 Sep 2025 22:18:39 -0500 Subject: [PATCH 01/12] Immediately send pending changes when backgrounding app. --- .../CloudKit/Internal/MockSyncEngine.swift | 306 ++++++++++++++++++ .../Internal/SyncEngineProtocol.swift | 1 + Sources/SQLiteData/CloudKit/SyncEngine.swift | 38 ++- Tests/SQLiteDataTests/AssertQueryTests.swift | 286 ++++++++-------- .../CloudKitTests/AppLifecycleTests.swift | 52 +++ .../CloudKitTests/NewTableSyncTests.swift | 154 ++++----- .../SQLiteDataTests/CustomFunctionTests.swift | 62 ++-- .../Internal/CloudKitTestHelpers.swift | 295 ----------------- 8 files changed, 646 insertions(+), 548 deletions(-) create mode 100644 Tests/SQLiteDataTests/CloudKitTests/AppLifecycleTests.swift diff --git a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift index 82d4819d..da6013a2 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift @@ -56,6 +56,18 @@ package final class MockSyncEngine: SyncEngineProtocol { ) } + package func sendChanges(_ options: CKSyncEngine.SendChangesOptions) async throws { + guard let syncEngine = delegate as? SyncEngine + else { + reportIssue("TODO") + return + } + guard + !syncEngine.syncEngine(for: database.databaseScope).state.pendingRecordZoneChanges.isEmpty + else { return } + try await syncEngine.processPendingRecordZoneChanges(scope: database.databaseScope) + } + package func recordZoneChangeBatch( pendingChanges: [CKSyncEngine.PendingRecordZoneChange], recordProvider: @Sendable (CKRecord.ID) async -> CKRecord? @@ -286,3 +298,297 @@ private func comparePendingDatabaseChange( false } } + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension SyncEngine { + package struct ModifyRecordsCallback { + fileprivate let operation: @Sendable () async -> Void + package func notify() async { + await operation() + } + } + + package func modifyRecordZones( + scope: CKDatabase.Scope, + saving recordZonesToSave: [CKRecordZone] = [], + deleting recordZoneIDsToDelete: [CKRecordZone.ID] = [] + ) throws -> ModifyRecordsCallback { + let syncEngine = syncEngine(for: scope) + + let (saveResults, deleteResults) = try syncEngine.database.modifyRecordZones( + saving: recordZonesToSave, + deleting: recordZoneIDsToDelete + ) + + return ModifyRecordsCallback { + await syncEngine.delegate + .handleEvent( + .fetchedDatabaseChanges( + modifications: saveResults.values.compactMap { try? $0.get().zoneID }, + deletions: deleteResults.compactMap { zoneID, result in + ((try? result.get()) != nil) + ? (zoneID, .deleted) + : nil + } + ), + syncEngine: syncEngine + ) + } + } + + package func modifyRecords( + scope: CKDatabase.Scope, + saving recordsToSave: [CKRecord] = [], + deleting recordIDsToDelete: [CKRecord.ID] = [] + ) throws -> ModifyRecordsCallback { + let syncEngine = syncEngine(for: scope) + let recordsToDeleteByID = Dictionary( + grouping: syncEngine.database.storage.withValue { storage in + recordIDsToDelete.compactMap { recordID in storage[recordID.zoneID]?[recordID] } + }, + by: \.recordID + ) + .compactMapValues(\.first) + + let (saveResults, deleteResults) = try syncEngine.database.modifyRecords( + saving: recordsToSave, + deleting: recordIDsToDelete + ) + + return ModifyRecordsCallback { + await syncEngine.delegate.handleEvent( + .fetchedRecordZoneChanges( + modifications: saveResults.values.compactMap { try? $0.get() }, + deletions: deleteResults.compactMap { recordID, result in + syncEngine.database.storage.withValue { storage in + (recordsToDeleteByID[recordID]?.recordType).flatMap { recordType in + (try? result.get()) != nil + ? (recordID, recordType) + : nil + } + } + } + ), + syncEngine: syncEngine + ) + } + } + + package func processPendingRecordZoneChanges( + options: CKSyncEngine.SendChangesOptions = CKSyncEngine.SendChangesOptions(), + 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.pendingRecordZoneChanges.isEmpty + else { + reportIssue( + "Processing empty set of record zone 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 + } + + let batch = await nextRecordZoneChangeBatch( + reason: .scheduled, + options: options, + syncEngine: { + switch scope { + case .private: + self.private + case .shared: + self.shared + case .public: + fatalError("Public database not supported in tests.") + @unknown default: + fatalError("Unknown database scope not supported in tests.") + } + }() + ) + guard let batch + else { return } + + let (saveResults, deleteResults) = try syncEngine.database.modifyRecords( + saving: batch.recordsToSave, + deleting: batch.recordIDsToDelete, + savePolicy: .ifServerRecordUnchanged, + atomically: true + ) + + var savedRecords: [CKRecord] = [] + var failedRecordSaves: [(record: CKRecord, error: CKError)] = [] + var deletedRecordIDs: [CKRecord.ID] = [] + var failedRecordDeletes: [CKRecord.ID: CKError] = [:] + for (recordID, result) in saveResults { + switch result { + case .success(let record): + savedRecords.append(record) + case .failure(let error as CKError): + guard let record = batch.recordsToSave.first(where: { $0.recordID == recordID }) + else { fatalError("\(recordID.debugDescription) not found in pending changes") } + failedRecordSaves.append((record: record, error: error)) + case .failure: + fatalError("Mocks should only raise 'CKError' values.") + } + } + for (recordID, result) in deleteResults { + switch result { + case .success: + deletedRecordIDs.append(recordID) + case .failure(let error as CKError): + failedRecordDeletes[recordID] = error + case .failure: + fatalError("Mocks should only raise 'CKError' values.") + } + } + syncEngine.state.remove( + pendingRecordZoneChanges: savedRecords.map { .saveRecord($0.recordID) } + ) + syncEngine.state.remove( + pendingRecordZoneChanges: deletedRecordIDs.map { .deleteRecord($0) } + ) + + await syncEngine.delegate + .handleEvent( + .sentRecordZoneChanges( + savedRecords: savedRecords, + failedRecordSaves: failedRecordSaves, + deletedRecordIDs: deletedRecordIDs, + failedRecordDeletes: failedRecordDeletes + ), + syncEngine: syncEngine + ) + } + + 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], + deleteResults: [CKRecordZone.ID: Result] + ) = 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.delegate + .handleEvent( + .sentDatabaseChanges( + savedZones: savedZones, + failedZoneSaves: failedZoneSaves, + deletedZoneIDs: deletedZoneIDs, + failedZoneDeletes: failedZoneDeletes + ), + syncEngine: syncEngine + ) + } + + package var `private`: MockSyncEngine { + syncEngines.private as! MockSyncEngine + } + package var shared: MockSyncEngine { + syncEngines.shared as! MockSyncEngine + } + + package func syncEngine(for scope: CKDatabase.Scope) -> MockSyncEngine { + switch scope { + case .public: + fatalError("Public database not supported in sync engines.") + case .private: + `private` + case .shared: + shared + @unknown default: + fatalError("Unknown database scope not supported in sync engines.") + } + } +} diff --git a/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift b/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift index 99dc5569..6dd52194 100644 --- a/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift +++ b/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift @@ -25,6 +25,7 @@ pendingChanges: [CKSyncEngine.PendingRecordZoneChange], recordProvider: @Sendable (CKRecord.ID) async -> CKRecord? ) async -> CKSyncEngine.RecordZoneChangeBatch? + func sendChanges(_ options: CKSyncEngine.SendChangesOptions) async throws } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 06450fc1..55ba9276 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -10,6 +10,10 @@ import StructuredQueriesCore import SwiftData + #if canImport(UIKit) + import UIKit + #endif + /// An object that manages the synchronization of local and remote SQLite data. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public final class SyncEngine: Observable, Sendable { @@ -248,9 +252,39 @@ tables: allTables, tablesByName: tablesByName ) + #if canImport(UIKit) + observer.withValue { + $0 = NotificationCenter.default.addObserver( + forName: UIScene.willDeactivateNotification, + object: nil, + queue: nil + ) { [syncEngines] _ in + Task { @MainActor in + let taskIdentifier = UIApplication.shared.beginBackgroundTask() + defer { UIApplication.shared.endBackgroundTask(taskIdentifier) } + if let privateSyncEngine = syncEngines.withValue(\.private) { + try await privateSyncEngine.sendChanges(CKSyncEngine.SendChangesOptions()) + } + if let sharedSyncEngine = syncEngines.withValue(\.shared) { + try await sharedSyncEngine.sendChanges(CKSyncEngine.SendChangesOptions()) + } + } + } + } + #endif try validateSchema() } + private let observer = LockIsolated<(any NSObjectProtocol)?>(nil) + + deinit { + observer.withValue { + guard let observer = $0 + else { return } + NotificationCenter.default.removeObserver(observer) + } + } + @TaskLocal package static var _isSynchronizingChanges = false nonisolated package func setUpSyncEngine() throws { @@ -1875,8 +1909,8 @@ throw SyncEngine.SchemaError( reason: .uniquenessConstraint, debugDescription: """ - Uniqueness constraints are not supported for synchronized tables. - """ + Uniqueness constraints are not supported for synchronized tables. + """ ) } } diff --git a/Tests/SQLiteDataTests/AssertQueryTests.swift b/Tests/SQLiteDataTests/AssertQueryTests.swift index 17179471..a2bf1f97 100644 --- a/Tests/SQLiteDataTests/AssertQueryTests.swift +++ b/Tests/SQLiteDataTests/AssertQueryTests.swift @@ -1,143 +1,143 @@ -import DependenciesTestSupport -import Foundation -import SQLiteData -import SQLiteDataTestSupport -import SnapshotTesting -import Testing - -@Suite( - .dependency(\.defaultDatabase, try .database()), - .snapshots(record: .failed), -) -struct AssertQueryTests { - @Test func assertQueryBasic() throws { - assertQuery( - Record.all.select(\.id) - ) { - """ - ┌───┐ - │ 1 │ - │ 2 │ - │ 3 │ - └───┘ - """ - } - } - @Test func assertQueryRecord() throws { - assertQuery( - Record.where { $0.id == 1 } - ) { - """ - ┌────────────────────────────────────────┐ - │ Record( │ - │ id: 1, │ - │ date: Date(1970-01-01T00:00:42.000Z) │ - │ ) │ - └────────────────────────────────────────┘ - """ - } - } - @Test func assertQueryBasicUpdate() throws { - assertQuery( - Record.all - .update { $0.date = Date(timeIntervalSince1970: 45) } - .returning { ($0.id, $0.date) } - ) { - """ - ┌───┬────────────────────────────────┐ - │ 1 │ Date(1970-01-01T00:00:45.000Z) │ - │ 2 │ Date(1970-01-01T00:00:45.000Z) │ - │ 3 │ Date(1970-01-01T00:00:45.000Z) │ - └───┴────────────────────────────────┘ - """ - } - } - @Test func assertQueryRecordUpdate() throws { - assertQuery( - Record - .where { $0.id == 1 } - .update { $0.date = Date(timeIntervalSince1970: 45) } - .returning(\.self) - ) { - """ - ┌────────────────────────────────────────┐ - │ Record( │ - │ id: 1, │ - │ date: Date(1970-01-01T00:00:45.000Z) │ - │ ) │ - └────────────────────────────────────────┘ - """ - } - } - #if DEBUG - @Test func assertQueryBasicIncludeSQL() throws { - assertQuery( - includeSQL: true, - Record.all.select(\.id) - ) { - """ - SELECT "records"."id" - FROM "records" - """ - } results: { - """ - ┌───┐ - │ 1 │ - │ 2 │ - │ 3 │ - └───┘ - """ - } - } - #endif - #if DEBUG - @Test func assertQueryRecordIncludeSQL() throws { - assertQuery( - includeSQL: true, - Record.where { $0.id == 1 } - ) { - """ - SELECT "records"."id", "records"."date" - FROM "records" - WHERE ("records"."id" = 1) - """ - } results: { - """ - ┌────────────────────────────────────────┐ - │ Record( │ - │ id: 1, │ - │ date: Date(1970-01-01T00:00:42.000Z) │ - │ ) │ - └────────────────────────────────────────┘ - """ - } - } - #endif -} - -@Table -private struct Record: Equatable { - let id: Int - @Column(as: Date.UnixTimeRepresentation.self) - var date = Date(timeIntervalSince1970: 42) -} -extension DatabaseWriter where Self == DatabaseQueue { - fileprivate static func database() throws -> DatabaseQueue { - let database = try DatabaseQueue() - try database.write { db in - try #sql( - """ - CREATE TABLE "records" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT, - "date" INTEGER NOT NULL DEFAULT 42 - ) - """ - ) - .execute(db) - for _ in 1...3 { - _ = try Record.insert { Record.Draft() }.execute(db) - } - } - return database - } -} +//import DependenciesTestSupport +//import Foundation +//import SQLiteData +//import SQLiteDataTestSupport +//import SnapshotTesting +//import Testing +// +//@Suite( +// .dependency(\.defaultDatabase, try .database()), +// .snapshots(record: .failed), +//) +//struct AssertQueryTests { +// @Test func assertQueryBasic() throws { +// assertQuery( +// Record.all.select(\.id) +// ) { +// """ +// ┌───┐ +// │ 1 │ +// │ 2 │ +// │ 3 │ +// └───┘ +// """ +// } +// } +// @Test func assertQueryRecord() throws { +// assertQuery( +// Record.where { $0.id == 1 } +// ) { +// """ +// ┌────────────────────────────────────────┐ +// │ Record( │ +// │ id: 1, │ +// │ date: Date(1970-01-01T00:00:42.000Z) │ +// │ ) │ +// └────────────────────────────────────────┘ +// """ +// } +// } +// @Test func assertQueryBasicUpdate() throws { +// assertQuery( +// Record.all +// .update { $0.date = Date(timeIntervalSince1970: 45) } +// .returning { ($0.id, $0.date) } +// ) { +// """ +// ┌───┬────────────────────────────────┐ +// │ 1 │ Date(1970-01-01T00:00:45.000Z) │ +// │ 2 │ Date(1970-01-01T00:00:45.000Z) │ +// │ 3 │ Date(1970-01-01T00:00:45.000Z) │ +// └───┴────────────────────────────────┘ +// """ +// } +// } +// @Test func assertQueryRecordUpdate() throws { +// assertQuery( +// Record +// .where { $0.id == 1 } +// .update { $0.date = Date(timeIntervalSince1970: 45) } +// .returning(\.self) +// ) { +// """ +// ┌────────────────────────────────────────┐ +// │ Record( │ +// │ id: 1, │ +// │ date: Date(1970-01-01T00:00:45.000Z) │ +// │ ) │ +// └────────────────────────────────────────┘ +// """ +// } +// } +// #if DEBUG +// @Test func assertQueryBasicIncludeSQL() throws { +// assertQuery( +// includeSQL: true, +// Record.all.select(\.id) +// ) { +// """ +// SELECT "records"."id" +// FROM "records" +// """ +// } results: { +// """ +// ┌───┐ +// │ 1 │ +// │ 2 │ +// │ 3 │ +// └───┘ +// """ +// } +// } +// #endif +// #if DEBUG +// @Test func assertQueryRecordIncludeSQL() throws { +// assertQuery( +// includeSQL: true, +// Record.where { $0.id == 1 } +// ) { +// """ +// SELECT "records"."id", "records"."date" +// FROM "records" +// WHERE ("records"."id" = 1) +// """ +// } results: { +// """ +// ┌────────────────────────────────────────┐ +// │ Record( │ +// │ id: 1, │ +// │ date: Date(1970-01-01T00:00:42.000Z) │ +// │ ) │ +// └────────────────────────────────────────┘ +// """ +// } +// } +// #endif +//} +// +//@Table +//private struct Record: Equatable { +// let id: Int +// @Column(as: Date.UnixTimeRepresentation.self) +// var date = Date(timeIntervalSince1970: 42) +//} +//extension DatabaseWriter where Self == DatabaseQueue { +// fileprivate static func database() throws -> DatabaseQueue { +// let database = try DatabaseQueue() +// try database.write { db in +// try #sql( +// """ +// CREATE TABLE "records" ( +// "id" INTEGER PRIMARY KEY AUTOINCREMENT, +// "date" INTEGER NOT NULL DEFAULT 42 +// ) +// """ +// ) +// .execute(db) +// for _ in 1...3 { +// _ = try Record.insert { Record.Draft() }.execute(db) +// } +// } +// return database +// } +//} diff --git a/Tests/SQLiteDataTests/CloudKitTests/AppLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/AppLifecycleTests.swift new file mode 100644 index 00000000..210d7ba3 --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/AppLifecycleTests.swift @@ -0,0 +1,52 @@ +#if canImport(CloudKit) && canImport(UIKit) + import CloudKit + import CustomDump + import Foundation + import InlineSnapshotTesting + import SQLiteData + import SnapshotTestingCustomDump + import Testing + import SQLiteDataTestSupport + + import UIKit + + extension BaseCloudKitTests { + // TODO: WRITE MORE TESTS + @MainActor + final class AppLifecycleTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func sendChangesOnBackground() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + NotificationCenter.default.post(name: UIScene.willDeactivateNotification, object: nil) + try await Task.sleep(for: .seconds(1)) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + } + } +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift index 6c0868fb..30dab8ff 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift @@ -1,77 +1,77 @@ -#if canImport(CloudKit) - import CloudKit - import CustomDump - import SQLiteDataTestSupport - import Foundation - import InlineSnapshotTesting - import SQLiteData - import SnapshotTestingCustomDump - import Testing - - extension BaseCloudKitTests { - @MainActor - @Suite( - .prepareDatabase { userDatabase in - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - Reminder(id: 1, title: "Write blog post", remindersListID: 1) - } - } - } - ) - final class NewTableSyncTests: BaseCloudKitTests, @unchecked Sendable { - // * Create records before sync engine starts - // => Records are sent to CloudKit - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func initialSync() async throws { - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - id: 1, - isCompleted: 0, - remindersListID: 1, - title: "Write blog post" - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Personal" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - - assertQuery( - SyncMetadata.order(by: \.recordName).select(\.recordName), - database: syncEngine.metadatabase - ) { - """ - ┌────────────────────┐ - │ "1:reminders" │ - │ "1:remindersLists" │ - └────────────────────┘ - """ - } - } - } - } -#endif +//#if canImport(CloudKit) +// import CloudKit +// import CustomDump +// import SQLiteDataTestSupport +// import Foundation +// import InlineSnapshotTesting +// import SQLiteData +// import SnapshotTestingCustomDump +// import Testing +// +// extension BaseCloudKitTests { +// @MainActor +// @Suite( +// .prepareDatabase { userDatabase in +// try await userDatabase.userWrite { db in +// try db.seed { +// RemindersList(id: 1, title: "Personal") +// Reminder(id: 1, title: "Write blog post", remindersListID: 1) +// } +// } +// } +// ) +// final class NewTableSyncTests: BaseCloudKitTests, @unchecked Sendable { +// // * Create records before sync engine starts +// // => Records are sent to CloudKit +// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +// @Test func initialSync() async throws { +// try await syncEngine.processPendingRecordZoneChanges(scope: .private) +// assertInlineSnapshot(of: container, as: .customDump) { +// """ +// MockCloudContainer( +// privateCloudDatabase: MockCloudDatabase( +// databaseScope: .private, +// storage: [ +// [0]: CKRecord( +// recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), +// recordType: "reminders", +// parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), +// share: nil, +// id: 1, +// isCompleted: 0, +// remindersListID: 1, +// title: "Write blog post" +// ), +// [1]: CKRecord( +// recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), +// recordType: "remindersLists", +// parent: nil, +// share: nil, +// id: 1, +// title: "Personal" +// ) +// ] +// ), +// sharedCloudDatabase: MockCloudDatabase( +// databaseScope: .shared, +// storage: [] +// ) +// ) +// """ +// } +// +// assertQuery( +// SyncMetadata.order(by: \.recordName).select(\.recordName), +// database: syncEngine.metadatabase +// ) { +// """ +// ┌────────────────────┐ +// │ "1:reminders" │ +// │ "1:remindersLists" │ +// └────────────────────┘ +// """ +// } +// } +// } +// } +//#endif diff --git a/Tests/SQLiteDataTests/CustomFunctionTests.swift b/Tests/SQLiteDataTests/CustomFunctionTests.swift index 60eda159..abf60e9d 100644 --- a/Tests/SQLiteDataTests/CustomFunctionTests.swift +++ b/Tests/SQLiteDataTests/CustomFunctionTests.swift @@ -1,31 +1,31 @@ -import Foundation -import SQLiteData -import Testing - -@Suite struct CustomFunctionsTests { - @DatabaseFunction func customDate() -> Date { - Date(timeIntervalSinceReferenceDate: 0) - } - - @Test func basics() throws { - var configuration = Configuration() - configuration.prepareDatabase { db in - db.add(function: $customDate) - } - let database = try DatabaseQueue(configuration: configuration) - let date = try database.read { db in - try Values($customDate()) - .fetchOne(db) - } - #expect(date?.timeIntervalSinceReferenceDate == 0) - - try database.write { db in - db.remove(function: $customDate) - } - #expect(throws: (any Error).self) { - try database.read { db in - _ = try Values($customDate()).fetchOne(db) - } - } - } -} +//import Foundation +//import SQLiteData +//import Testing +// +//@Suite struct CustomFunctionsTests { +// @DatabaseFunction func customDate() -> Date { +// Date(timeIntervalSinceReferenceDate: 0) +// } +// +// @Test func basics() throws { +// var configuration = Configuration() +// configuration.prepareDatabase { db in +// db.add(function: $customDate) +// } +// let database = try DatabaseQueue(configuration: configuration) +// let date = try database.read { db in +// try Values($customDate()) +// .fetchOne(db) +// } +// #expect(date?.timeIntervalSinceReferenceDate == 0) +// +// try database.write { db in +// db.remove(function: $customDate) +// } +// #expect(throws: (any Error).self) { +// try database.read { db in +// _ = try Values($customDate()).fetchOne(db) +// } +// } +// } +//} diff --git a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift index bb6f907d..a694a03e 100644 --- a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift @@ -17,298 +17,3 @@ extension PrimaryKeyedTable where PrimaryKey.QueryOutput: IdentifierStringConver ) } } - -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension SyncEngine { - struct ModifyRecordsCallback { - fileprivate let operation: @Sendable () async -> Void - func notify() async { - await operation() - } - } - - func modifyRecordZones( - scope: CKDatabase.Scope, - saving recordZonesToSave: [CKRecordZone] = [], - deleting recordZoneIDsToDelete: [CKRecordZone.ID] = [] - ) throws -> ModifyRecordsCallback { - let syncEngine = syncEngine(for: scope) - - let (saveResults, deleteResults) = try syncEngine.database.modifyRecordZones( - saving: recordZonesToSave, - deleting: recordZoneIDsToDelete - ) - - return ModifyRecordsCallback { - await syncEngine.delegate - .handleEvent( - .fetchedDatabaseChanges( - modifications: saveResults.values.compactMap { try? $0.get().zoneID }, - deletions: deleteResults.compactMap { zoneID, result in - ((try? result.get()) != nil) - ? (zoneID, .deleted) - : nil - } - ), - syncEngine: syncEngine - ) - } - } - - func modifyRecords( - scope: CKDatabase.Scope, - saving recordsToSave: [CKRecord] = [], - deleting recordIDsToDelete: [CKRecord.ID] = [] - ) throws -> ModifyRecordsCallback { - let syncEngine = syncEngine(for: scope) - let recordsToDeleteByID = Dictionary( - grouping: syncEngine.database.storage.withValue { storage in - recordIDsToDelete.compactMap { recordID in storage[recordID.zoneID]?[recordID] } - }, - by: \.recordID - ) - .compactMapValues(\.first) - - let (saveResults, deleteResults) = try syncEngine.database.modifyRecords( - saving: recordsToSave, - deleting: recordIDsToDelete - ) - - return ModifyRecordsCallback { - await syncEngine.delegate.handleEvent( - .fetchedRecordZoneChanges( - modifications: saveResults.values.compactMap { try? $0.get() }, - deletions: deleteResults.compactMap { recordID, result in - syncEngine.database.storage.withValue { storage in - (recordsToDeleteByID[recordID]?.recordType).flatMap { recordType in - (try? result.get()) != nil - ? (recordID, recordType) - : nil - } - } - } - ), - syncEngine: syncEngine - ) - } - } - - func processPendingRecordZoneChanges( - options: CKSyncEngine.SendChangesOptions = CKSyncEngine.SendChangesOptions(), - 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.pendingRecordZoneChanges.isEmpty - else { - Issue.record( - "Processing empty set of record zone changes.", - sourceLocation: SourceLocation.init( - fileID: String(describing: fileID), - filePath: String(describing: filePath), - line: Int(line), - column: Int(column) - ) - ) - return - } - guard try await container.accountStatus() == .available - else { - Issue.record( - """ - User must be logged in to process pending changes. - """, - sourceLocation: SourceLocation.init( - fileID: String(describing: fileID), - filePath: String(describing: filePath), - line: Int(line), - column: Int(column) - ) - ) - return - } - - let batch = await nextRecordZoneChangeBatch( - reason: .scheduled, - options: options, - syncEngine: { - switch scope { - case .private: - self.private - case .shared: - self.shared - case .public: - fatalError("Public database not supported in tests.") - @unknown default: - fatalError("Unknown database scope not supported in tests.") - } - }() - ) - guard let batch - else { return } - - let (saveResults, deleteResults) = try syncEngine.database.modifyRecords( - saving: batch.recordsToSave, - deleting: batch.recordIDsToDelete, - savePolicy: .ifServerRecordUnchanged, - atomically: true - ) - - var savedRecords: [CKRecord] = [] - var failedRecordSaves: [(record: CKRecord, error: CKError)] = [] - var deletedRecordIDs: [CKRecord.ID] = [] - var failedRecordDeletes: [CKRecord.ID: CKError] = [:] - for (recordID, result) in saveResults { - switch result { - case .success(let record): - savedRecords.append(record) - case .failure(let error as CKError): - guard let record = batch.recordsToSave.first(where: { $0.recordID == recordID }) - else { fatalError("\(recordID.debugDescription) not found in pending changes") } - failedRecordSaves.append((record: record, error: error)) - case .failure: - fatalError("Mocks should only raise 'CKError' values.") - } - } - for (recordID, result) in deleteResults { - switch result { - case .success: - deletedRecordIDs.append(recordID) - case .failure(let error as CKError): - failedRecordDeletes[recordID] = error - case .failure: - fatalError("Mocks should only raise 'CKError' values.") - } - } - syncEngine.state.remove( - pendingRecordZoneChanges: savedRecords.map { .saveRecord($0.recordID) } - ) - syncEngine.state.remove( - pendingRecordZoneChanges: deletedRecordIDs.map { .deleteRecord($0) } - ) - - await syncEngine.delegate - .handleEvent( - .sentRecordZoneChanges( - savedRecords: savedRecords, - failedRecordSaves: failedRecordSaves, - deletedRecordIDs: deletedRecordIDs, - failedRecordDeletes: failedRecordDeletes - ), - syncEngine: syncEngine - ) - } - - 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 { - Issue.record( - "Processing empty set of database changes.", - sourceLocation: SourceLocation.init( - fileID: String(describing: fileID), - filePath: String(describing: filePath), - line: Int(line), - column: Int(column) - ) - ) - return - } - guard try await container.accountStatus() == .available - else { - Issue.record( - """ - User must be logged in to process pending changes. - """, - sourceLocation: SourceLocation.init( - fileID: String(describing: fileID), - filePath: String(describing: filePath), - line: Int(line), - column: Int(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], - deleteResults: [CKRecordZone.ID: Result] - ) = 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.delegate - .handleEvent( - .sentDatabaseChanges( - savedZones: savedZones, - failedZoneSaves: failedZoneSaves, - deletedZoneIDs: deletedZoneIDs, - failedZoneDeletes: failedZoneDeletes - ), - syncEngine: syncEngine - ) - } - - private func syncEngine(for scope: CKDatabase.Scope) -> MockSyncEngine { - switch scope { - case .public: - fatalError("Public database not supported in tests.") - case .private: - `private` - case .shared: - shared - @unknown default: - fatalError("Unknown database scope not supported in tests.") - } - } -} From fb68ce739530bbd4634b1222618450d5138c88eb Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 6 Sep 2025 22:24:34 -0500 Subject: [PATCH 02/12] wip; --- .../CloudKit/Internal/MockSyncEngine.swift | 25 ++++++++----------- Sources/SQLiteData/CloudKit/SyncEngine.swift | 4 +-- .../Internal/BaseCloudKitTests.swift | 4 +-- 3 files changed, 14 insertions(+), 19 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift index da6013a2..cd598dd6 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift @@ -5,18 +5,18 @@ import OrderedCollections @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package final class MockSyncEngine: SyncEngineProtocol { package let database: MockCloudDatabase - package let delegate: any SyncEngineDelegate + package let underlyingSyncEngine: SyncEngine private let _state: LockIsolated private let _fetchChangesScopes = LockIsolated<[CKSyncEngine.FetchChangesOptions.Scope]>([]) private let _acceptedShareMetadata = LockIsolated>([]) package init( database: MockCloudDatabase, - delegate: any SyncEngineDelegate, + syncEngine: SyncEngine, state: MockSyncEngineState ) { self.database = database - self.delegate = delegate + self.underlyingSyncEngine = syncEngine self._state = LockIsolated(state) } @@ -50,22 +50,17 @@ package final class MockSyncEngine: SyncEngineProtocol { ($0[zoneID]?.values).map { Array($0) } ?? [] } } - await delegate.handleEvent( + await underlyingSyncEngine.handleEvent( .fetchedRecordZoneChanges(modifications: records, deletions: []), syncEngine: self ) } package func sendChanges(_ options: CKSyncEngine.SendChangesOptions) async throws { - guard let syncEngine = delegate as? SyncEngine - else { - reportIssue("TODO") - return - } guard - !syncEngine.syncEngine(for: database.databaseScope).state.pendingRecordZoneChanges.isEmpty + !underlyingSyncEngine.syncEngine(for: database.databaseScope).state.pendingRecordZoneChanges.isEmpty else { return } - try await syncEngine.processPendingRecordZoneChanges(scope: database.databaseScope) + try await underlyingSyncEngine.processPendingRecordZoneChanges(scope: database.databaseScope) } package func recordZoneChangeBatch( @@ -321,7 +316,7 @@ extension SyncEngine { ) return ModifyRecordsCallback { - await syncEngine.delegate + await syncEngine.underlyingSyncEngine .handleEvent( .fetchedDatabaseChanges( modifications: saveResults.values.compactMap { try? $0.get().zoneID }, @@ -356,7 +351,7 @@ extension SyncEngine { ) return ModifyRecordsCallback { - await syncEngine.delegate.handleEvent( + await syncEngine.underlyingSyncEngine.handleEvent( .fetchedRecordZoneChanges( modifications: saveResults.values.compactMap { try? $0.get() }, deletions: deleteResults.compactMap { recordID, result in @@ -467,7 +462,7 @@ extension SyncEngine { pendingRecordZoneChanges: deletedRecordIDs.map { .deleteRecord($0) } ) - await syncEngine.delegate + await syncEngine.underlyingSyncEngine .handleEvent( .sentRecordZoneChanges( savedRecords: savedRecords, @@ -560,7 +555,7 @@ extension SyncEngine { syncEngine.state.remove(pendingDatabaseChanges: savedZones.map { .saveZone($0) }) syncEngine.state.remove(pendingDatabaseChanges: deletedZoneIDs.map { .deleteZone($0) }) - await syncEngine.delegate + await syncEngine.underlyingSyncEngine .handleEvent( .sentDatabaseChanges( savedZones: savedZones, diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 55ba9276..ebafff46 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -115,12 +115,12 @@ ( private: MockSyncEngine( database: privateDatabase, - delegate: syncEngine, + syncEngine: syncEngine, state: MockSyncEngineState() ), shared: MockSyncEngine( database: sharedDatabase, - delegate: syncEngine, + syncEngine: syncEngine, state: MockSyncEngineState() ) ) diff --git a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift index 659acd77..be376a13 100644 --- a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift @@ -158,12 +158,12 @@ extension SyncEngine { ( MockSyncEngine( database: container.privateCloudDatabase as! MockCloudDatabase, - delegate: syncEngine, + syncEngine: syncEngine, state: MockSyncEngineState() ), MockSyncEngine( database: container.sharedCloudDatabase as! MockCloudDatabase, - delegate: syncEngine, + syncEngine: syncEngine, state: MockSyncEngineState() ) ) From 2ddfe3db27b0569113189b1dedb959c40637cfd3 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 6 Sep 2025 22:25:17 -0500 Subject: [PATCH 03/12] Revert "wip;" This reverts commit fb68ce739530bbd4634b1222618450d5138c88eb. --- .../CloudKit/Internal/MockSyncEngine.swift | 25 +++++++++++-------- Sources/SQLiteData/CloudKit/SyncEngine.swift | 4 +-- .../Internal/BaseCloudKitTests.swift | 4 +-- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift index cd598dd6..da6013a2 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift @@ -5,18 +5,18 @@ import OrderedCollections @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package final class MockSyncEngine: SyncEngineProtocol { package let database: MockCloudDatabase - package let underlyingSyncEngine: SyncEngine + package let delegate: any SyncEngineDelegate private let _state: LockIsolated private let _fetchChangesScopes = LockIsolated<[CKSyncEngine.FetchChangesOptions.Scope]>([]) private let _acceptedShareMetadata = LockIsolated>([]) package init( database: MockCloudDatabase, - syncEngine: SyncEngine, + delegate: any SyncEngineDelegate, state: MockSyncEngineState ) { self.database = database - self.underlyingSyncEngine = syncEngine + self.delegate = delegate self._state = LockIsolated(state) } @@ -50,17 +50,22 @@ package final class MockSyncEngine: SyncEngineProtocol { ($0[zoneID]?.values).map { Array($0) } ?? [] } } - await underlyingSyncEngine.handleEvent( + await delegate.handleEvent( .fetchedRecordZoneChanges(modifications: records, deletions: []), syncEngine: self ) } package func sendChanges(_ options: CKSyncEngine.SendChangesOptions) async throws { + guard let syncEngine = delegate as? SyncEngine + else { + reportIssue("TODO") + return + } guard - !underlyingSyncEngine.syncEngine(for: database.databaseScope).state.pendingRecordZoneChanges.isEmpty + !syncEngine.syncEngine(for: database.databaseScope).state.pendingRecordZoneChanges.isEmpty else { return } - try await underlyingSyncEngine.processPendingRecordZoneChanges(scope: database.databaseScope) + try await syncEngine.processPendingRecordZoneChanges(scope: database.databaseScope) } package func recordZoneChangeBatch( @@ -316,7 +321,7 @@ extension SyncEngine { ) return ModifyRecordsCallback { - await syncEngine.underlyingSyncEngine + await syncEngine.delegate .handleEvent( .fetchedDatabaseChanges( modifications: saveResults.values.compactMap { try? $0.get().zoneID }, @@ -351,7 +356,7 @@ extension SyncEngine { ) return ModifyRecordsCallback { - await syncEngine.underlyingSyncEngine.handleEvent( + await syncEngine.delegate.handleEvent( .fetchedRecordZoneChanges( modifications: saveResults.values.compactMap { try? $0.get() }, deletions: deleteResults.compactMap { recordID, result in @@ -462,7 +467,7 @@ extension SyncEngine { pendingRecordZoneChanges: deletedRecordIDs.map { .deleteRecord($0) } ) - await syncEngine.underlyingSyncEngine + await syncEngine.delegate .handleEvent( .sentRecordZoneChanges( savedRecords: savedRecords, @@ -555,7 +560,7 @@ extension SyncEngine { syncEngine.state.remove(pendingDatabaseChanges: savedZones.map { .saveZone($0) }) syncEngine.state.remove(pendingDatabaseChanges: deletedZoneIDs.map { .deleteZone($0) }) - await syncEngine.underlyingSyncEngine + await syncEngine.delegate .handleEvent( .sentDatabaseChanges( savedZones: savedZones, diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index ebafff46..55ba9276 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -115,12 +115,12 @@ ( private: MockSyncEngine( database: privateDatabase, - syncEngine: syncEngine, + delegate: syncEngine, state: MockSyncEngineState() ), shared: MockSyncEngine( database: sharedDatabase, - syncEngine: syncEngine, + delegate: syncEngine, state: MockSyncEngineState() ) ) diff --git a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift index be376a13..659acd77 100644 --- a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift @@ -158,12 +158,12 @@ extension SyncEngine { ( MockSyncEngine( database: container.privateCloudDatabase as! MockCloudDatabase, - syncEngine: syncEngine, + delegate: syncEngine, state: MockSyncEngineState() ), MockSyncEngine( database: container.sharedCloudDatabase as! MockCloudDatabase, - syncEngine: syncEngine, + delegate: syncEngine, state: MockSyncEngineState() ) ) From 3a1bb119f7b017782acec22f2d57583a8db7b051 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 7 Sep 2025 09:30:18 -0500 Subject: [PATCH 04/12] wip --- .../Internal/DefaultNotificationCenter.swift | 18 ++++++++++++++++++ Sources/SQLiteData/CloudKit/SyncEngine.swift | 3 ++- .../CloudKitTests/AppLifecycleTests.swift | 7 +++++-- 3 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 Sources/SQLiteData/CloudKit/Internal/DefaultNotificationCenter.swift diff --git a/Sources/SQLiteData/CloudKit/Internal/DefaultNotificationCenter.swift b/Sources/SQLiteData/CloudKit/Internal/DefaultNotificationCenter.swift new file mode 100644 index 00000000..3f4b0596 --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Internal/DefaultNotificationCenter.swift @@ -0,0 +1,18 @@ +#if canImport(UIKit) + import UIKit +#endif + +#if canImport(UIKit) + private enum DefaultNotificationCenterKey: DependencyKey { + static let liveValue = NotificationCenter.default + static var testValue: NotificationCenter { + NotificationCenter() + } + } + extension DependencyValues { + package var defaultNotificationCenter: NotificationCenter { + get { self[DefaultNotificationCenterKey.self] } + set { self[DefaultNotificationCenterKey.self] = newValue } + } + } +#endif diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 55ba9276..819fd8f5 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -253,8 +253,9 @@ tablesByName: tablesByName ) #if canImport(UIKit) + @Dependency(\.defaultNotificationCenter) var defaultNotificationCenter observer.withValue { - $0 = NotificationCenter.default.addObserver( + $0 = defaultNotificationCenter.addObserver( forName: UIScene.willDeactivateNotification, object: nil, queue: nil diff --git a/Tests/SQLiteDataTests/CloudKitTests/AppLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/AppLifecycleTests.swift index 210d7ba3..5be5a6e4 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/AppLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/AppLifecycleTests.swift @@ -13,7 +13,10 @@ extension BaseCloudKitTests { // TODO: WRITE MORE TESTS @MainActor + @Suite final class AppLifecycleTests: BaseCloudKitTests, @unchecked Sendable { + @Dependency(\.defaultNotificationCenter) var defaultNotificationCenter + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func sendChangesOnBackground() async throws { try await userDatabase.userWrite { db in @@ -21,8 +24,8 @@ RemindersList(id: 1, title: "Personal") } } - NotificationCenter.default.post(name: UIScene.willDeactivateNotification, object: nil) - try await Task.sleep(for: .seconds(1)) + defaultNotificationCenter.post(name: UIScene.willDeactivateNotification, object: nil) + try await Task.sleep(for: .seconds(0.1)) assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( From 504b6faf07715ea4c1594a39834dc5ba5ef21f20 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 7 Sep 2025 09:30:24 -0500 Subject: [PATCH 05/12] Revert "Revert "wip;"" This reverts commit 2ddfe3db27b0569113189b1dedb959c40637cfd3. --- .../CloudKit/Internal/MockSyncEngine.swift | 25 ++++++++----------- Sources/SQLiteData/CloudKit/SyncEngine.swift | 4 +-- .../Internal/BaseCloudKitTests.swift | 4 +-- 3 files changed, 14 insertions(+), 19 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift index da6013a2..cd598dd6 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift @@ -5,18 +5,18 @@ import OrderedCollections @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package final class MockSyncEngine: SyncEngineProtocol { package let database: MockCloudDatabase - package let delegate: any SyncEngineDelegate + package let underlyingSyncEngine: SyncEngine private let _state: LockIsolated private let _fetchChangesScopes = LockIsolated<[CKSyncEngine.FetchChangesOptions.Scope]>([]) private let _acceptedShareMetadata = LockIsolated>([]) package init( database: MockCloudDatabase, - delegate: any SyncEngineDelegate, + syncEngine: SyncEngine, state: MockSyncEngineState ) { self.database = database - self.delegate = delegate + self.underlyingSyncEngine = syncEngine self._state = LockIsolated(state) } @@ -50,22 +50,17 @@ package final class MockSyncEngine: SyncEngineProtocol { ($0[zoneID]?.values).map { Array($0) } ?? [] } } - await delegate.handleEvent( + await underlyingSyncEngine.handleEvent( .fetchedRecordZoneChanges(modifications: records, deletions: []), syncEngine: self ) } package func sendChanges(_ options: CKSyncEngine.SendChangesOptions) async throws { - guard let syncEngine = delegate as? SyncEngine - else { - reportIssue("TODO") - return - } guard - !syncEngine.syncEngine(for: database.databaseScope).state.pendingRecordZoneChanges.isEmpty + !underlyingSyncEngine.syncEngine(for: database.databaseScope).state.pendingRecordZoneChanges.isEmpty else { return } - try await syncEngine.processPendingRecordZoneChanges(scope: database.databaseScope) + try await underlyingSyncEngine.processPendingRecordZoneChanges(scope: database.databaseScope) } package func recordZoneChangeBatch( @@ -321,7 +316,7 @@ extension SyncEngine { ) return ModifyRecordsCallback { - await syncEngine.delegate + await syncEngine.underlyingSyncEngine .handleEvent( .fetchedDatabaseChanges( modifications: saveResults.values.compactMap { try? $0.get().zoneID }, @@ -356,7 +351,7 @@ extension SyncEngine { ) return ModifyRecordsCallback { - await syncEngine.delegate.handleEvent( + await syncEngine.underlyingSyncEngine.handleEvent( .fetchedRecordZoneChanges( modifications: saveResults.values.compactMap { try? $0.get() }, deletions: deleteResults.compactMap { recordID, result in @@ -467,7 +462,7 @@ extension SyncEngine { pendingRecordZoneChanges: deletedRecordIDs.map { .deleteRecord($0) } ) - await syncEngine.delegate + await syncEngine.underlyingSyncEngine .handleEvent( .sentRecordZoneChanges( savedRecords: savedRecords, @@ -560,7 +555,7 @@ extension SyncEngine { syncEngine.state.remove(pendingDatabaseChanges: savedZones.map { .saveZone($0) }) syncEngine.state.remove(pendingDatabaseChanges: deletedZoneIDs.map { .deleteZone($0) }) - await syncEngine.delegate + await syncEngine.underlyingSyncEngine .handleEvent( .sentDatabaseChanges( savedZones: savedZones, diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 819fd8f5..106dca73 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -115,12 +115,12 @@ ( private: MockSyncEngine( database: privateDatabase, - delegate: syncEngine, + syncEngine: syncEngine, state: MockSyncEngineState() ), shared: MockSyncEngine( database: sharedDatabase, - delegate: syncEngine, + syncEngine: syncEngine, state: MockSyncEngineState() ) ) diff --git a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift index 659acd77..be376a13 100644 --- a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift @@ -158,12 +158,12 @@ extension SyncEngine { ( MockSyncEngine( database: container.privateCloudDatabase as! MockCloudDatabase, - delegate: syncEngine, + syncEngine: syncEngine, state: MockSyncEngineState() ), MockSyncEngine( database: container.sharedCloudDatabase as! MockCloudDatabase, - delegate: syncEngine, + syncEngine: syncEngine, state: MockSyncEngineState() ) ) From 567544cd0df996f39e237ee8a17899071650814f Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 8 Sep 2025 13:32:52 -0500 Subject: [PATCH 06/12] wip --- Tests/SQLiteDataTests/AssertQueryTests.swift | 286 +++++++++--------- .../CloudKitTests/NewTableSyncTests.swift | 154 +++++----- .../SQLiteDataTests/CustomFunctionTests.swift | 62 ++-- 3 files changed, 251 insertions(+), 251 deletions(-) diff --git a/Tests/SQLiteDataTests/AssertQueryTests.swift b/Tests/SQLiteDataTests/AssertQueryTests.swift index a2bf1f97..17179471 100644 --- a/Tests/SQLiteDataTests/AssertQueryTests.swift +++ b/Tests/SQLiteDataTests/AssertQueryTests.swift @@ -1,143 +1,143 @@ -//import DependenciesTestSupport -//import Foundation -//import SQLiteData -//import SQLiteDataTestSupport -//import SnapshotTesting -//import Testing -// -//@Suite( -// .dependency(\.defaultDatabase, try .database()), -// .snapshots(record: .failed), -//) -//struct AssertQueryTests { -// @Test func assertQueryBasic() throws { -// assertQuery( -// Record.all.select(\.id) -// ) { -// """ -// ┌───┐ -// │ 1 │ -// │ 2 │ -// │ 3 │ -// └───┘ -// """ -// } -// } -// @Test func assertQueryRecord() throws { -// assertQuery( -// Record.where { $0.id == 1 } -// ) { -// """ -// ┌────────────────────────────────────────┐ -// │ Record( │ -// │ id: 1, │ -// │ date: Date(1970-01-01T00:00:42.000Z) │ -// │ ) │ -// └────────────────────────────────────────┘ -// """ -// } -// } -// @Test func assertQueryBasicUpdate() throws { -// assertQuery( -// Record.all -// .update { $0.date = Date(timeIntervalSince1970: 45) } -// .returning { ($0.id, $0.date) } -// ) { -// """ -// ┌───┬────────────────────────────────┐ -// │ 1 │ Date(1970-01-01T00:00:45.000Z) │ -// │ 2 │ Date(1970-01-01T00:00:45.000Z) │ -// │ 3 │ Date(1970-01-01T00:00:45.000Z) │ -// └───┴────────────────────────────────┘ -// """ -// } -// } -// @Test func assertQueryRecordUpdate() throws { -// assertQuery( -// Record -// .where { $0.id == 1 } -// .update { $0.date = Date(timeIntervalSince1970: 45) } -// .returning(\.self) -// ) { -// """ -// ┌────────────────────────────────────────┐ -// │ Record( │ -// │ id: 1, │ -// │ date: Date(1970-01-01T00:00:45.000Z) │ -// │ ) │ -// └────────────────────────────────────────┘ -// """ -// } -// } -// #if DEBUG -// @Test func assertQueryBasicIncludeSQL() throws { -// assertQuery( -// includeSQL: true, -// Record.all.select(\.id) -// ) { -// """ -// SELECT "records"."id" -// FROM "records" -// """ -// } results: { -// """ -// ┌───┐ -// │ 1 │ -// │ 2 │ -// │ 3 │ -// └───┘ -// """ -// } -// } -// #endif -// #if DEBUG -// @Test func assertQueryRecordIncludeSQL() throws { -// assertQuery( -// includeSQL: true, -// Record.where { $0.id == 1 } -// ) { -// """ -// SELECT "records"."id", "records"."date" -// FROM "records" -// WHERE ("records"."id" = 1) -// """ -// } results: { -// """ -// ┌────────────────────────────────────────┐ -// │ Record( │ -// │ id: 1, │ -// │ date: Date(1970-01-01T00:00:42.000Z) │ -// │ ) │ -// └────────────────────────────────────────┘ -// """ -// } -// } -// #endif -//} -// -//@Table -//private struct Record: Equatable { -// let id: Int -// @Column(as: Date.UnixTimeRepresentation.self) -// var date = Date(timeIntervalSince1970: 42) -//} -//extension DatabaseWriter where Self == DatabaseQueue { -// fileprivate static func database() throws -> DatabaseQueue { -// let database = try DatabaseQueue() -// try database.write { db in -// try #sql( -// """ -// CREATE TABLE "records" ( -// "id" INTEGER PRIMARY KEY AUTOINCREMENT, -// "date" INTEGER NOT NULL DEFAULT 42 -// ) -// """ -// ) -// .execute(db) -// for _ in 1...3 { -// _ = try Record.insert { Record.Draft() }.execute(db) -// } -// } -// return database -// } -//} +import DependenciesTestSupport +import Foundation +import SQLiteData +import SQLiteDataTestSupport +import SnapshotTesting +import Testing + +@Suite( + .dependency(\.defaultDatabase, try .database()), + .snapshots(record: .failed), +) +struct AssertQueryTests { + @Test func assertQueryBasic() throws { + assertQuery( + Record.all.select(\.id) + ) { + """ + ┌───┐ + │ 1 │ + │ 2 │ + │ 3 │ + └───┘ + """ + } + } + @Test func assertQueryRecord() throws { + assertQuery( + Record.where { $0.id == 1 } + ) { + """ + ┌────────────────────────────────────────┐ + │ Record( │ + │ id: 1, │ + │ date: Date(1970-01-01T00:00:42.000Z) │ + │ ) │ + └────────────────────────────────────────┘ + """ + } + } + @Test func assertQueryBasicUpdate() throws { + assertQuery( + Record.all + .update { $0.date = Date(timeIntervalSince1970: 45) } + .returning { ($0.id, $0.date) } + ) { + """ + ┌───┬────────────────────────────────┐ + │ 1 │ Date(1970-01-01T00:00:45.000Z) │ + │ 2 │ Date(1970-01-01T00:00:45.000Z) │ + │ 3 │ Date(1970-01-01T00:00:45.000Z) │ + └───┴────────────────────────────────┘ + """ + } + } + @Test func assertQueryRecordUpdate() throws { + assertQuery( + Record + .where { $0.id == 1 } + .update { $0.date = Date(timeIntervalSince1970: 45) } + .returning(\.self) + ) { + """ + ┌────────────────────────────────────────┐ + │ Record( │ + │ id: 1, │ + │ date: Date(1970-01-01T00:00:45.000Z) │ + │ ) │ + └────────────────────────────────────────┘ + """ + } + } + #if DEBUG + @Test func assertQueryBasicIncludeSQL() throws { + assertQuery( + includeSQL: true, + Record.all.select(\.id) + ) { + """ + SELECT "records"."id" + FROM "records" + """ + } results: { + """ + ┌───┐ + │ 1 │ + │ 2 │ + │ 3 │ + └───┘ + """ + } + } + #endif + #if DEBUG + @Test func assertQueryRecordIncludeSQL() throws { + assertQuery( + includeSQL: true, + Record.where { $0.id == 1 } + ) { + """ + SELECT "records"."id", "records"."date" + FROM "records" + WHERE ("records"."id" = 1) + """ + } results: { + """ + ┌────────────────────────────────────────┐ + │ Record( │ + │ id: 1, │ + │ date: Date(1970-01-01T00:00:42.000Z) │ + │ ) │ + └────────────────────────────────────────┘ + """ + } + } + #endif +} + +@Table +private struct Record: Equatable { + let id: Int + @Column(as: Date.UnixTimeRepresentation.self) + var date = Date(timeIntervalSince1970: 42) +} +extension DatabaseWriter where Self == DatabaseQueue { + fileprivate static func database() throws -> DatabaseQueue { + let database = try DatabaseQueue() + try database.write { db in + try #sql( + """ + CREATE TABLE "records" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "date" INTEGER NOT NULL DEFAULT 42 + ) + """ + ) + .execute(db) + for _ in 1...3 { + _ = try Record.insert { Record.Draft() }.execute(db) + } + } + return database + } +} diff --git a/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift index 30dab8ff..6c0868fb 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift @@ -1,77 +1,77 @@ -//#if canImport(CloudKit) -// import CloudKit -// import CustomDump -// import SQLiteDataTestSupport -// import Foundation -// import InlineSnapshotTesting -// import SQLiteData -// import SnapshotTestingCustomDump -// import Testing -// -// extension BaseCloudKitTests { -// @MainActor -// @Suite( -// .prepareDatabase { userDatabase in -// try await userDatabase.userWrite { db in -// try db.seed { -// RemindersList(id: 1, title: "Personal") -// Reminder(id: 1, title: "Write blog post", remindersListID: 1) -// } -// } -// } -// ) -// final class NewTableSyncTests: BaseCloudKitTests, @unchecked Sendable { -// // * Create records before sync engine starts -// // => Records are sent to CloudKit -// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -// @Test func initialSync() async throws { -// try await syncEngine.processPendingRecordZoneChanges(scope: .private) -// assertInlineSnapshot(of: container, as: .customDump) { -// """ -// MockCloudContainer( -// privateCloudDatabase: MockCloudDatabase( -// databaseScope: .private, -// storage: [ -// [0]: CKRecord( -// recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), -// recordType: "reminders", -// parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), -// share: nil, -// id: 1, -// isCompleted: 0, -// remindersListID: 1, -// title: "Write blog post" -// ), -// [1]: CKRecord( -// recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), -// recordType: "remindersLists", -// parent: nil, -// share: nil, -// id: 1, -// title: "Personal" -// ) -// ] -// ), -// sharedCloudDatabase: MockCloudDatabase( -// databaseScope: .shared, -// storage: [] -// ) -// ) -// """ -// } -// -// assertQuery( -// SyncMetadata.order(by: \.recordName).select(\.recordName), -// database: syncEngine.metadatabase -// ) { -// """ -// ┌────────────────────┐ -// │ "1:reminders" │ -// │ "1:remindersLists" │ -// └────────────────────┘ -// """ -// } -// } -// } -// } -//#endif +#if canImport(CloudKit) + import CloudKit + import CustomDump + import SQLiteDataTestSupport + import Foundation + import InlineSnapshotTesting + import SQLiteData + import SnapshotTestingCustomDump + import Testing + + extension BaseCloudKitTests { + @MainActor + @Suite( + .prepareDatabase { userDatabase in + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "Write blog post", remindersListID: 1) + } + } + } + ) + final class NewTableSyncTests: BaseCloudKitTests, @unchecked Sendable { + // * Create records before sync engine starts + // => Records are sent to CloudKit + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func initialSync() async throws { + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 1, + title: "Write blog post" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + assertQuery( + SyncMetadata.order(by: \.recordName).select(\.recordName), + database: syncEngine.metadatabase + ) { + """ + ┌────────────────────┐ + │ "1:reminders" │ + │ "1:remindersLists" │ + └────────────────────┘ + """ + } + } + } + } +#endif diff --git a/Tests/SQLiteDataTests/CustomFunctionTests.swift b/Tests/SQLiteDataTests/CustomFunctionTests.swift index abf60e9d..60eda159 100644 --- a/Tests/SQLiteDataTests/CustomFunctionTests.swift +++ b/Tests/SQLiteDataTests/CustomFunctionTests.swift @@ -1,31 +1,31 @@ -//import Foundation -//import SQLiteData -//import Testing -// -//@Suite struct CustomFunctionsTests { -// @DatabaseFunction func customDate() -> Date { -// Date(timeIntervalSinceReferenceDate: 0) -// } -// -// @Test func basics() throws { -// var configuration = Configuration() -// configuration.prepareDatabase { db in -// db.add(function: $customDate) -// } -// let database = try DatabaseQueue(configuration: configuration) -// let date = try database.read { db in -// try Values($customDate()) -// .fetchOne(db) -// } -// #expect(date?.timeIntervalSinceReferenceDate == 0) -// -// try database.write { db in -// db.remove(function: $customDate) -// } -// #expect(throws: (any Error).self) { -// try database.read { db in -// _ = try Values($customDate()).fetchOne(db) -// } -// } -// } -//} +import Foundation +import SQLiteData +import Testing + +@Suite struct CustomFunctionsTests { + @DatabaseFunction func customDate() -> Date { + Date(timeIntervalSinceReferenceDate: 0) + } + + @Test func basics() throws { + var configuration = Configuration() + configuration.prepareDatabase { db in + db.add(function: $customDate) + } + let database = try DatabaseQueue(configuration: configuration) + let date = try database.read { db in + try Values($customDate()) + .fetchOne(db) + } + #expect(date?.timeIntervalSinceReferenceDate == 0) + + try database.write { db in + db.remove(function: $customDate) + } + #expect(throws: (any Error).self) { + try database.read { db in + _ = try Values($customDate()).fetchOne(db) + } + } + } +} From 222cdbbeccae84adc95f4081660e3a2c09fc2c98 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 8 Sep 2025 13:35:41 -0500 Subject: [PATCH 07/12] wip --- .../Internal/DefaultNotificationCenter.swift | 2 - .../CloudKit/Internal/MockSyncEngine.swift | 87 ++----------------- .../Internal/CloudKitTestHelpers.swift | 77 ++++++++++++++++ 3 files changed, 84 insertions(+), 82 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/Internal/DefaultNotificationCenter.swift b/Sources/SQLiteData/CloudKit/Internal/DefaultNotificationCenter.swift index 3f4b0596..9fc612d8 100644 --- a/Sources/SQLiteData/CloudKit/Internal/DefaultNotificationCenter.swift +++ b/Sources/SQLiteData/CloudKit/Internal/DefaultNotificationCenter.swift @@ -1,8 +1,6 @@ #if canImport(UIKit) import UIKit -#endif -#if canImport(UIKit) private enum DefaultNotificationCenterKey: DependencyKey { static let liveValue = NotificationCenter.default static var testValue: NotificationCenter { diff --git a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift index cd598dd6..72d89609 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift @@ -5,7 +5,7 @@ import OrderedCollections @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package final class MockSyncEngine: SyncEngineProtocol { package let database: MockCloudDatabase - package let underlyingSyncEngine: SyncEngine + package let parentSyncEngine: SyncEngine private let _state: LockIsolated private let _fetchChangesScopes = LockIsolated<[CKSyncEngine.FetchChangesOptions.Scope]>([]) private let _acceptedShareMetadata = LockIsolated>([]) @@ -16,7 +16,7 @@ package final class MockSyncEngine: SyncEngineProtocol { state: MockSyncEngineState ) { self.database = database - self.underlyingSyncEngine = syncEngine + self.parentSyncEngine = syncEngine self._state = LockIsolated(state) } @@ -50,7 +50,7 @@ package final class MockSyncEngine: SyncEngineProtocol { ($0[zoneID]?.values).map { Array($0) } ?? [] } } - await underlyingSyncEngine.handleEvent( + await parentSyncEngine.handleEvent( .fetchedRecordZoneChanges(modifications: records, deletions: []), syncEngine: self ) @@ -58,9 +58,9 @@ package final class MockSyncEngine: SyncEngineProtocol { package func sendChanges(_ options: CKSyncEngine.SendChangesOptions) async throws { guard - !underlyingSyncEngine.syncEngine(for: database.databaseScope).state.pendingRecordZoneChanges.isEmpty + !parentSyncEngine.syncEngine(for: database.databaseScope).state.pendingRecordZoneChanges.isEmpty else { return } - try await underlyingSyncEngine.processPendingRecordZoneChanges(scope: database.databaseScope) + try await parentSyncEngine.processPendingRecordZoneChanges(scope: database.databaseScope) } package func recordZoneChangeBatch( @@ -296,79 +296,6 @@ private func comparePendingDatabaseChange( @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncEngine { - package struct ModifyRecordsCallback { - fileprivate let operation: @Sendable () async -> Void - package func notify() async { - await operation() - } - } - - package func modifyRecordZones( - scope: CKDatabase.Scope, - saving recordZonesToSave: [CKRecordZone] = [], - deleting recordZoneIDsToDelete: [CKRecordZone.ID] = [] - ) throws -> ModifyRecordsCallback { - let syncEngine = syncEngine(for: scope) - - let (saveResults, deleteResults) = try syncEngine.database.modifyRecordZones( - saving: recordZonesToSave, - deleting: recordZoneIDsToDelete - ) - - return ModifyRecordsCallback { - await syncEngine.underlyingSyncEngine - .handleEvent( - .fetchedDatabaseChanges( - modifications: saveResults.values.compactMap { try? $0.get().zoneID }, - deletions: deleteResults.compactMap { zoneID, result in - ((try? result.get()) != nil) - ? (zoneID, .deleted) - : nil - } - ), - syncEngine: syncEngine - ) - } - } - - package func modifyRecords( - scope: CKDatabase.Scope, - saving recordsToSave: [CKRecord] = [], - deleting recordIDsToDelete: [CKRecord.ID] = [] - ) throws -> ModifyRecordsCallback { - let syncEngine = syncEngine(for: scope) - let recordsToDeleteByID = Dictionary( - grouping: syncEngine.database.storage.withValue { storage in - recordIDsToDelete.compactMap { recordID in storage[recordID.zoneID]?[recordID] } - }, - by: \.recordID - ) - .compactMapValues(\.first) - - let (saveResults, deleteResults) = try syncEngine.database.modifyRecords( - saving: recordsToSave, - deleting: recordIDsToDelete - ) - - return ModifyRecordsCallback { - await syncEngine.underlyingSyncEngine.handleEvent( - .fetchedRecordZoneChanges( - modifications: saveResults.values.compactMap { try? $0.get() }, - deletions: deleteResults.compactMap { recordID, result in - syncEngine.database.storage.withValue { storage in - (recordsToDeleteByID[recordID]?.recordType).flatMap { recordType in - (try? result.get()) != nil - ? (recordID, recordType) - : nil - } - } - } - ), - syncEngine: syncEngine - ) - } - } - package func processPendingRecordZoneChanges( options: CKSyncEngine.SendChangesOptions = CKSyncEngine.SendChangesOptions(), scope: CKDatabase.Scope, @@ -462,7 +389,7 @@ extension SyncEngine { pendingRecordZoneChanges: deletedRecordIDs.map { .deleteRecord($0) } ) - await syncEngine.underlyingSyncEngine + await syncEngine.parentSyncEngine .handleEvent( .sentRecordZoneChanges( savedRecords: savedRecords, @@ -555,7 +482,7 @@ extension SyncEngine { syncEngine.state.remove(pendingDatabaseChanges: savedZones.map { .saveZone($0) }) syncEngine.state.remove(pendingDatabaseChanges: deletedZoneIDs.map { .deleteZone($0) }) - await syncEngine.underlyingSyncEngine + await syncEngine.parentSyncEngine .handleEvent( .sentDatabaseChanges( savedZones: savedZones, diff --git a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift index a694a03e..f580b83a 100644 --- a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift @@ -17,3 +17,80 @@ extension PrimaryKeyedTable where PrimaryKey.QueryOutput: IdentifierStringConver ) } } + + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension SyncEngine { + struct ModifyRecordsCallback { + fileprivate let operation: @Sendable () async -> Void + func notify() async { + await operation() + } + } + + func modifyRecordZones( + scope: CKDatabase.Scope, + saving recordZonesToSave: [CKRecordZone] = [], + deleting recordZoneIDsToDelete: [CKRecordZone.ID] = [] + ) throws -> ModifyRecordsCallback { + let syncEngine = syncEngine(for: scope) + + let (saveResults, deleteResults) = try syncEngine.database.modifyRecordZones( + saving: recordZonesToSave, + deleting: recordZoneIDsToDelete + ) + + return ModifyRecordsCallback { + await syncEngine.parentSyncEngine + .handleEvent( + .fetchedDatabaseChanges( + modifications: saveResults.values.compactMap { try? $0.get().zoneID }, + deletions: deleteResults.compactMap { zoneID, result in + ((try? result.get()) != nil) + ? (zoneID, .deleted) + : nil + } + ), + syncEngine: syncEngine + ) + } + } + + func modifyRecords( + scope: CKDatabase.Scope, + saving recordsToSave: [CKRecord] = [], + deleting recordIDsToDelete: [CKRecord.ID] = [] + ) throws -> ModifyRecordsCallback { + let syncEngine = syncEngine(for: scope) + let recordsToDeleteByID = Dictionary( + grouping: syncEngine.database.storage.withValue { storage in + recordIDsToDelete.compactMap { recordID in storage[recordID.zoneID]?[recordID] } + }, + by: \.recordID + ) + .compactMapValues(\.first) + + let (saveResults, deleteResults) = try syncEngine.database.modifyRecords( + saving: recordsToSave, + deleting: recordIDsToDelete + ) + + return ModifyRecordsCallback { + await syncEngine.parentSyncEngine.handleEvent( + .fetchedRecordZoneChanges( + modifications: saveResults.values.compactMap { try? $0.get() }, + deletions: deleteResults.compactMap { recordID, result in + syncEngine.database.storage.withValue { storage in + (recordsToDeleteByID[recordID]?.recordType).flatMap { recordType in + (try? result.get()) != nil + ? (recordID, recordType) + : nil + } + } + } + ), + syncEngine: syncEngine + ) + } + } +} From 8bcfc28a7288a6d922ed9501b6812c7461aaf921 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 8 Sep 2025 13:37:16 -0500 Subject: [PATCH 08/12] wip --- .../CloudKit/Internal/MockSyncEngine.swift | 93 ------------------- .../Internal/CloudKitTestHelpers.swift | 93 +++++++++++++++++++ 2 files changed, 93 insertions(+), 93 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift index 72d89609..fd1f078d 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift @@ -401,99 +401,6 @@ extension SyncEngine { ) } - 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], - deleteResults: [CKRecordZone.ID: Result] - ) = 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 } diff --git a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift index f580b83a..7a013654 100644 --- a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift @@ -93,4 +93,97 @@ extension SyncEngine { ) } } + + 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], + deleteResults: [CKRecordZone.ID: Result] + ) = 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 + ) + } } From e434de2c36e67faf24c26b5593548b9b3825fc83 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 8 Sep 2025 13:41:34 -0500 Subject: [PATCH 09/12] wip --- .../CloudKit/Internal/MockSyncEngine.swift | 4 ++-- Sources/SQLiteData/CloudKit/SyncEngine.swift | 15 +++++++-------- .../Internal/BaseCloudKitTests.swift | 4 ++-- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift index fd1f078d..c71c7ea1 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift @@ -12,11 +12,11 @@ package final class MockSyncEngine: SyncEngineProtocol { package init( database: MockCloudDatabase, - syncEngine: SyncEngine, + parentSyncEngine: SyncEngine, state: MockSyncEngineState ) { self.database = database - self.parentSyncEngine = syncEngine + self.parentSyncEngine = parentSyncEngine self._state = LockIsolated(state) } diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index e86b53bd..73b5e69f 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -115,12 +115,12 @@ ( private: MockSyncEngine( database: privateDatabase, - syncEngine: syncEngine, + parentSyncEngine: syncEngine, state: MockSyncEngineState() ), shared: MockSyncEngine( database: sharedDatabase, - syncEngine: syncEngine, + parentSyncEngine: syncEngine, state: MockSyncEngineState() ) ) @@ -253,7 +253,7 @@ tablesByName: tablesByName ) #if canImport(UIKit) - @Dependency(\.defaultNotificationCenter) var defaultNotificationCenter + @Dependency(\.defaultNotificationCenter) var defaultNotificationCenter observer.withValue { $0 = defaultNotificationCenter.addObserver( forName: UIScene.willDeactivateNotification, @@ -263,12 +263,11 @@ Task { @MainActor in let taskIdentifier = UIApplication.shared.beginBackgroundTask() defer { UIApplication.shared.endBackgroundTask(taskIdentifier) } - if let privateSyncEngine = syncEngines.withValue(\.private) { - try await privateSyncEngine.sendChanges(CKSyncEngine.SendChangesOptions()) - } - if let sharedSyncEngine = syncEngines.withValue(\.shared) { - try await sharedSyncEngine.sendChanges(CKSyncEngine.SendChangesOptions()) + let (privateSyncEngine, sharedSyncEngine) = syncEngines.withValue { + ($0.private, $0.shared) } + try await privateSyncEngine?.sendChanges(CKSyncEngine.SendChangesOptions()) + try await sharedSyncEngine?.sendChanges(CKSyncEngine.SendChangesOptions()) } } } diff --git a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift index be376a13..4989c864 100644 --- a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift @@ -158,12 +158,12 @@ extension SyncEngine { ( MockSyncEngine( database: container.privateCloudDatabase as! MockCloudDatabase, - syncEngine: syncEngine, + parentSyncEngine: syncEngine, state: MockSyncEngineState() ), MockSyncEngine( database: container.sharedCloudDatabase as! MockCloudDatabase, - syncEngine: syncEngine, + parentSyncEngine: syncEngine, state: MockSyncEngineState() ) ) From 5afb10fd6ac965d10d70f79377dea1f53cd682d7 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 8 Sep 2025 13:42:58 -0500 Subject: [PATCH 10/12] wip --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 73b5e69f..2de4ae84 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -33,6 +33,7 @@ package let container: any CloudContainer let dataManager = Dependency(\.dataManager) private let observationRegistrar = ObservationRegistrar() + private let notificationsObserver = LockIsolated<(any NSObjectProtocol)?>(nil) /// The error message used when a write occurs to a record for which the current user /// does not have permission. @@ -254,7 +255,7 @@ ) #if canImport(UIKit) @Dependency(\.defaultNotificationCenter) var defaultNotificationCenter - observer.withValue { + notificationsObserver.withValue { $0 = defaultNotificationCenter.addObserver( forName: UIScene.willDeactivateNotification, object: nil, @@ -275,10 +276,8 @@ try validateSchema() } - private let observer = LockIsolated<(any NSObjectProtocol)?>(nil) - deinit { - observer.withValue { + notificationsObserver.withValue { guard let observer = $0 else { return } NotificationCenter.default.removeObserver(observer) From 8d07db26760374fb7f34d197b9d8f122e5fcd97d Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 8 Sep 2025 13:45:33 -0500 Subject: [PATCH 11/12] wip --- .../CloudKitTests/AppLifecycleTests.swift | 84 ++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/AppLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/AppLifecycleTests.swift index 5be5a6e4..18a2183c 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/AppLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/AppLifecycleTests.swift @@ -11,7 +11,6 @@ import UIKit extension BaseCloudKitTests { - // TODO: WRITE MORE TESTS @MainActor @Suite final class AppLifecycleTests: BaseCloudKitTests, @unchecked Sendable { @@ -50,6 +49,89 @@ """ } } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func sendSharedChanges() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + let share = CKShare( + rootRecord: remindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(remindersListRecord.recordID.recordName)", + zoneID: remindersListRecord.recordID.zoneID + ) + ) + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: remindersListRecord.recordID, + rootRecord: remindersListRecord, + share: share + ) + ) + + try await userDatabase.userWrite { db in + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + + defaultNotificationCenter.post(name: UIScene.willDeactivateNotification, object: nil) + try await Task.sleep(for: .seconds(0.1)) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:reminders/external.zone/external.owner), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 1, + title: "Get milk" + ), + [2]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), + recordType: "remindersLists", + parent: nil, + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner)), + id: 1, + title: "Personal" + ) + ] + ) + ) + """ + } + } } } #endif From baf2aa21d37f45cbfb3743e3801a62d39f2bfa95 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 8 Sep 2025 13:46:56 -0500 Subject: [PATCH 12/12] wip --- .../Internal/CloudKitTestHelpers.swift | 49 ++++++++++--------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift index 7a013654..0b924e71 100644 --- a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift @@ -18,7 +18,6 @@ extension PrimaryKeyedTable where PrimaryKey.QueryOutput: IdentifierStringConver } } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncEngine { struct ModifyRecordsCallback { @@ -47,8 +46,8 @@ extension SyncEngine { modifications: saveResults.values.compactMap { try? $0.get().zoneID }, deletions: deleteResults.compactMap { zoneID, result in ((try? result.get()) != nil) - ? (zoneID, .deleted) - : nil + ? (zoneID, .deleted) + : nil } ), syncEngine: syncEngine @@ -68,7 +67,7 @@ extension SyncEngine { }, by: \.recordID ) - .compactMapValues(\.first) + .compactMapValues(\.first) let (saveResults, deleteResults) = try syncEngine.database.modifyRecords( saving: recordsToSave, @@ -83,8 +82,8 @@ extension SyncEngine { syncEngine.database.storage.withValue { storage in (recordsToDeleteByID[recordID]?.recordType).flatMap { recordType in (try? result.get()) != nil - ? (recordID, recordType) - : nil + ? (recordID, recordType) + : nil } } } @@ -104,25 +103,29 @@ extension SyncEngine { let syncEngine = syncEngine(for: scope) guard !syncEngine.state.pendingDatabaseChanges.isEmpty else { - reportIssue( + Issue.record( "Processing empty set of database changes.", - fileID: fileID, - filePath: filePath, - line: line, - column: column + sourceLocation: SourceLocation( + fileID: String(describing: fileID), + filePath: String(describing: filePath), + line: Int(line), + column: Int(column) + ) ) return } guard try await container.accountStatus() == .available else { - reportIssue( + Issue.record( """ User must be logged in to process pending changes. """, - fileID: fileID, - filePath: filePath, - line: line, - column: column + sourceLocation: SourceLocation( + fileID: String(describing: fileID), + filePath: String(describing: filePath), + line: Int(line), + column: Int(column) + ) ) return } @@ -140,13 +143,13 @@ extension SyncEngine { } } let results: - ( - saveResults: [CKRecordZone.ID: Result], - deleteResults: [CKRecordZone.ID: Result] - ) = try syncEngine.database.modifyRecordZones( - saving: zonesToSave, - deleting: zoneIDsToDelete - ) + ( + saveResults: [CKRecordZone.ID: Result], + deleteResults: [CKRecordZone.ID: Result] + ) = try syncEngine.database.modifyRecordZones( + saving: zonesToSave, + deleting: zoneIDsToDelete + ) var savedZones: [CKRecordZone] = [] var failedZoneSaves: [(zone: CKRecordZone, error: CKError)] = [] var deletedZoneIDs: [CKRecordZone.ID] = []