From 455b7c11646517f30b3e37505519d93c29611dd8 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 5 Sep 2025 16:23:23 -0500 Subject: [PATCH 1/8] Audit metadatabase --- .../SQLiteData/CloudKit/CloudKitSharing.swift | 2 +- Sources/SQLiteData/CloudKit/SyncEngine.swift | 2 +- .../CloudKitTests/AccountLifecycleTests.swift | 37 +- .../CloudKitTests/CloudKitTests.swift | 191 ++----- .../FetchRecordZoneChangesTests.swift | 248 ++++----- .../ForeignKeyConstraintTests.swift | 514 +++++++++--------- .../CloudKitTests/MetadataTests.swift | 482 +++++++++++----- .../CloudKitTests/NewTableSyncTests.swift | 71 +-- .../CloudKitTests/RecordTypeTests.swift | 12 +- .../CloudKitTests/SharingTests.swift | 149 ++--- .../SyncEngineLifecycleTests.swift | 193 +++++-- .../Internal/BaseCloudKitTests.swift | 22 +- Tests/SQLiteDataTests/Internal/Schema.swift | 2 +- 13 files changed, 1050 insertions(+), 875 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift index fa347e46..33b4264d 100644 --- a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift +++ b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift @@ -171,7 +171,7 @@ public func unshare(record: T) async throws where T.TableColumns.PrimaryKey.QueryOutput: IdentifierStringConvertible { - let share = try await userDatabase.read { [recordName = record.recordName] db in + let share = try await metadatabase.read { [recordName = record.recordName] db in try SyncMetadata .where { $0.recordName.eq(recordName) } .select(\.share) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 73a4e84f..33013810 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -344,9 +344,9 @@ private func start() throws -> Task { guard !isRunning else { return Task {} } - let (privateSyncEngine, sharedSyncEngine) = defaultSyncEngines(metadatabase, self) observationRegistrar.withMutation(of: self, keyPath: \.isRunning) { syncEngines.withValue { + let (privateSyncEngine, sharedSyncEngine) = defaultSyncEngines(metadatabase, self) $0 = SyncEngines( private: privateSyncEngine, shared: sharedSyncEngine diff --git a/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift index e5e601bd..d88129b6 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift @@ -25,15 +25,16 @@ await signOut() - try { - try userDatabase.userRead { db in - try #expect(RemindersList.count().fetchOne(db) == 0) - try #expect(Reminder.count().fetchOne(db) == 0) - try #expect(RemindersListPrivate.count().fetchOne(db) == 0) - try #expect(UnsyncedModel.count().fetchOne(db) == 1) - try #expect(SyncMetadata.count().fetchOne(db) == 0) - } - }() + try await userDatabase.userRead { db in + try #expect(RemindersList.count().fetchOne(db) == 0) + try #expect(Reminder.count().fetchOne(db) == 0) + try #expect(RemindersListPrivate.count().fetchOne(db) == 0) + try #expect(UnsyncedModel.count().fetchOne(db) == 1) + } + + try await syncEngine.metadatabase.read { db in + try #expect(SyncMetadata.count().fetchOne(db) == 0) + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -47,15 +48,15 @@ } } - try { - try userDatabase.read { db in - try #expect(RemindersList.count().fetchOne(db) == 1) - try #expect(Reminder.count().fetchOne(db) == 1) - try #expect(RemindersListPrivate.count().fetchOne(db) == 1) - try #expect(UnsyncedModel.count().fetchOne(db) == 1) - try #expect(SyncMetadata.count().fetchOne(db) == 3) - } - }() + try await userDatabase.read { db in + try #expect(RemindersList.count().fetchOne(db) == 1) + try #expect(Reminder.count().fetchOne(db) == 1) + try #expect(RemindersListPrivate.count().fetchOne(db) == 1) + try #expect(UnsyncedModel.count().fetchOne(db) == 1) + } + try await syncEngine.metadatabase.read { db in + try #expect(SyncMetadata.count().fetchOne(db) == 3) + } await signIn() diff --git a/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift index 89d9a155..e3a06d93 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift @@ -5,6 +5,7 @@ import InlineSnapshotTesting import OrderedCollections import SQLiteData + import SQLiteDataTestSupport import SnapshotTestingCustomDump import Testing @@ -396,6 +397,14 @@ } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertQuery(SyncMetadata.select(\.recordName), database: syncEngine.metadatabase) { + """ + ┌────────────────────┐ + │ "1:remindersLists" │ + └────────────────────┘ + """ + } assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -419,12 +428,6 @@ ) """ } - - let metadata = - try await userDatabase.userRead { db in - try RemindersList.metadata(for: 1).fetchOne(db) - } - #expect(metadata != nil) } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -564,59 +567,35 @@ } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: container, as: .customDump) { + + try await withDependencies { + $0.datetime.now.addTimeInterval(60) + } operation: { + let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: 1)) + record.setValue("Work", forKey: "title", at: now) + try await syncEngine.modifyRecords(scope: .private, saving: [record]).notify() + } + + assertQuery(RemindersList.all, database: userDatabase.database) { """ - 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: [] - ) - ) + ┌─────────────────┐ + │ RemindersList( │ + │ id: 1, │ + │ title: "Work" │ + │ ) │ + └─────────────────┘ + """ + } + assertQuery( + SyncMetadata.select(\.userModificationDate), + database: syncEngine.metadatabase + ) { + """ + ┌────────────────────────────────┐ + │ Date(1970-01-01T00:01:00.000Z) │ + └────────────────────────────────┘ """ } - - let userModificationDate = try #require( - try await userDatabase.userRead { db in - try RemindersList.metadata(for: 1) - .select(\.userModificationDate) - .fetchOne(db) ?? nil - } - ) - - let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: 1)) - let serverModificationDate = userModificationDate.addingTimeInterval(60) - record.setValue("Work", forKey: "title", at: serverModificationDate) - try await syncEngine.modifyRecords(scope: .private, saving: [record]).notify() - - expectNoDifference( - try { - try userDatabase.userRead { db in - try RemindersList.find(1).fetchOne(db) - } - }(), - RemindersList(id: 1, title: "Work") - ) - - let metadata = try #require( - try await userDatabase.userRead { db in - try RemindersList.metadata(for: 1) - .fetchOne(db) - } - ) - #expect(metadata.userModificationDate == serverModificationDate) assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -695,56 +674,24 @@ } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - 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: [] - ) - ) - """ - } - - let userModificationDate = try #require( - try await userDatabase.userRead { db in - try RemindersList.metadata(for: 1) - .select(\.userModificationDate) - .fetchOne(db) ?? nil - } - ) let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: 1)) - record.encryptedValues["title"] = "Work" + record.setValue("Work", forKey: "title", at: now) // NB: Manually setting '_recordChangeTag' simulates another device saving a record. record._recordChangeTag = UUID().uuidString try await syncEngine.modifyRecords(scope: .private, saving: [record]).notify() - expectNoDifference( - try { try userDatabase.userRead { db in try RemindersList.find(1).fetchOne(db) } }(), - RemindersList(id: 1, title: "Personal") - ) - - let metadata = try #require( - try await userDatabase.userRead { db in - try RemindersList.metadata(for: 1) - .fetchOne(db) - } - ) - #expect(metadata.userModificationDate == userModificationDate) + assertQuery(Reminder.all, database: userDatabase.database) + assertQuery( + SyncMetadata.select(\.userModificationDate), + database: syncEngine.metadatabase + ) { + """ + ┌────────────────────────────────┐ + │ Date(1970-01-01T00:00:00.000Z) │ + └────────────────────────────────┘ + """ + } assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -778,43 +725,21 @@ } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: container, as: .customDump) { + + try await syncEngine.modifyRecords( + scope: .private, + deleting: [RemindersList.recordID(for: 1)] + ) + .notify() + + assertQuery(RemindersList.all, database: userDatabase.database) { """ - 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: [] - ) - ) """ } - - let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: 1)) - try await syncEngine.modifyRecords(scope: .private, deleting: [record.recordID]).notify() - - #expect( - try await userDatabase.userRead { db in - try RemindersList.find(1).fetchAll(db) - } == [] - ) - let metadata = try await userDatabase.userRead { db in - try RemindersList.metadata(for: 1) - .fetchOne(db) + assertQuery(SyncMetadata.all, database: syncEngine.metadatabase) { + """ + """ } - #expect(metadata == nil) assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( diff --git a/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift index c0847578..1e6beb65 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -133,89 +133,38 @@ try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() } - assertInlineSnapshot( - of: syncEngine.private.database - .storage[syncEngine.defaultZone.zoneID]?[Reminder.recordID(for: 1)], - as: .customDump - ) { - """ - CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__)), - share: nil, - id: 1, - isCompleted: 0, - remindersListID: "2", - title: "Get milk" - ) - """ - } - - try await userDatabase.read { db in - let metadata = try #require( - try Reminder.metadata(for: 1).fetchOne(db) - ) - #expect(metadata.parentRecordName == RemindersList.recordName(for: 2)) - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect(reminder == Reminder(id: 1, title: "Get milk", remindersListID: 2)) - } - try await userDatabase.userWrite { db in try Reminder.find(1).update { $0.isCompleted.toggle() }.execute(db) } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot( - of: syncEngine.private.database.storage[syncEngine.defaultZone.zoneID]?[ - Reminder.recordID(for: 1) - ], - as: .customDump - ) { + assertQuery(Reminder.all, database: userDatabase.database) { """ - CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__)), - share: nil, - id: 1, - isCompleted: 0, - remindersListID: "2", - title: "Get milk" - ) + ┌──────────────────────┐ + │ Reminder( │ + │ id: 1, │ + │ dueDate: nil, │ + │ isCompleted: true, │ + │ priority: nil, │ + │ title: "Get milk", │ + │ remindersListID: 2 │ + │ ) │ + └──────────────────────┘ """ } - - try await userDatabase.read { db in - let metadata = try #require( - try Reminder.metadata(for: 1).fetchOne(db) - ) - #expect(metadata.parentRecordName == RemindersList.recordName(for: 2)) - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect( - reminder - == Reminder( - id: 1, - isCompleted: true, - title: "Get milk", - remindersListID: 2 - ) - ) + assertQuery( + SyncMetadata.order(by: \.recordName).select { ($0.recordName, $0.parentRecordName) }, + database: syncEngine.metadatabase + ) { + """ + ┌────────────────────┬────────────────────┐ + │ "1:reminders" │ "2:remindersLists" │ + │ "1:remindersLists" │ nil │ + │ "2:remindersLists" │ nil │ + └────────────────────┴────────────────────┘ + """ } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func receiveNewRecordFromCloudKit() async throws { - let remindersListRecord = CKRecord( - recordType: RemindersList.tableName, - recordID: RemindersList.recordID(for: 1) - ) - remindersListRecord.setValue("1", forKey: "id", at: now) - remindersListRecord.setValue("Personal", forKey: "title", at: now) - - try await syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]).notify() - assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -223,12 +172,30 @@ databaseScope: .private, storage: [ [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: "2", + title: "Get milk" + ), + [1]: CKRecord( recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), recordType: "remindersLists", parent: nil, share: nil, - id: "1", + id: 1, title: "Personal" + ), + [2]: CKRecord( + recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 2, + title: "Business" ) ] ), @@ -239,15 +206,18 @@ ) """ } + } - try await userDatabase.read { db in - let metadata = try #require( - try RemindersList.metadata(for: 1).fetchOne(db) - ) - #expect(metadata.recordName == RemindersList.recordName(for: 1)) - let remindersList = try #require(try RemindersList.find(1).fetchOne(db)) - #expect(remindersList == RemindersList(id: 1, title: "Personal")) - } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func editRecordReceivedFromCloudKit() async throws { + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1) + ) + remindersListRecord.setValue("1", forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + + try await syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]).notify() try await withDependencies { $0.datetime.now.addTimeInterval(1) @@ -255,10 +225,29 @@ try await userDatabase.userWrite { db in try RemindersList.find(1).update { $0.title = "My stuff" }.execute(db) } - - try await syncEngine.processPendingRecordZoneChanges(scope: .private) } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertQuery(RemindersList.all, database: userDatabase.database) { + """ + ┌─────────────────────┐ + │ RemindersList( │ + │ id: 1, │ + │ title: "My stuff" │ + │ ) │ + └─────────────────────┘ + """ + } + assertQuery( + SyncMetadata.order(by: \.recordName).select(\.recordName), + database: syncEngine.metadatabase + ) { + """ + ┌────────────────────┐ + │ "1:remindersLists" │ + └────────────────────┘ + """ + } assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -282,11 +271,6 @@ ) """ } - - try await userDatabase.read { db in - let remindersList = try #require(try RemindersList.find(1).fetchOne(db)) - #expect(remindersList == RemindersList(id: 1, title: "My stuff")) - } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -315,72 +299,41 @@ saving: [remindersListRecord] ) try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() - - 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", - remindersListID: "1", - title: "Get milk" - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: "1", - title: "Personal" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - await remindersListModification.notify() - try await userDatabase.read { db in - let reminderMetadata = try #require( - try Reminder.metadata(for: 1).fetchOne(db) - ) - #expect(reminderMetadata.recordName == Reminder.recordName(for: 1)) - #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) - - let remindersListMetadata = try #require( - try RemindersList.metadata(for: 1).fetchOne(db) - ) - #expect(remindersListMetadata.recordName == RemindersList.recordName(for: 1)) - #expect(remindersListMetadata.parentRecordName == nil) - - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect(reminder == Reminder(id: 1, title: "Get milk", remindersListID: 1)) - - let remindersList = try #require(try RemindersList.find(1).fetchOne(db)) - #expect(remindersList == RemindersList(id: 1, title: "Personal")) - } - try await withDependencies { $0.datetime.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in try Reminder.find(1).update { $0.title = "Buy milk" }.execute(db) } - - try await syncEngine.processPendingRecordZoneChanges(scope: .private) } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertQuery(Reminder.all, database: userDatabase.database) { + """ + ┌───────────────────────┐ + │ Reminder( │ + │ id: 1, │ + │ dueDate: nil, │ + │ isCompleted: false, │ + │ priority: nil, │ + │ title: "Buy milk", │ + │ remindersListID: 1 │ + │ ) │ + └───────────────────────┘ + """ + } + assertQuery(RemindersList.all, database: userDatabase.database) { + """ + ┌─────────────────────┐ + │ RemindersList( │ + │ id: 1, │ + │ title: "Personal" │ + │ ) │ + └─────────────────────┘ + """ + } assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -414,11 +367,6 @@ ) """ } - - try await userDatabase.read { db in - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect(reminder == Reminder(id: 1, title: "Buy milk", remindersListID: 1)) - } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) diff --git a/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift b/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift index dcb1e869..c0ecaf7e 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift @@ -2,6 +2,7 @@ import CloudKit import CustomDump import Foundation + import SQLiteDataTestSupport import InlineSnapshotTesting import SQLiteData import SnapshotTestingCustomDump @@ -10,6 +11,9 @@ extension BaseCloudKitTests { @MainActor final class ForeignKeyConstraintTests: BaseCloudKitTests, @unchecked Sendable { + // * Receive child record with no parent record. + // * Receive parent record. + // => Both records are synchronized. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func receiveChildBeforeParent() async throws { let remindersListRecord = CKRecord( @@ -36,79 +40,41 @@ saving: [remindersListRecord] ) try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() - - 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, - remindersListID: 1, - title: "Get milk" - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Personal" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - - try await userDatabase.read { db in - let remindersList = try RemindersList.find(1).fetchOne(db) - #expect(remindersList == nil) - let reminder = try Reminder.find(1).fetchOne(db) - #expect(reminder == nil) - } - await remindersListModification.notify() - try await userDatabase.read { db in - let reminderMetadata = try #require( - try Reminder.metadata(for: 1).fetchOne(db) - ) - #expect(reminderMetadata.recordName == Reminder.recordName(for: 1)) - #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) - - let remindersListMetadata = try #require( - try RemindersList.metadata(for: 1).fetchOne(db) - ) - #expect(remindersListMetadata.recordName == RemindersList.recordName(for: 1)) - #expect(remindersListMetadata.parentRecordName == nil) - - let remindersList = try #require(try RemindersList.find(1).fetchOne(db)) - #expect(remindersList == RemindersList(id: 1, title: "Personal")) - - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect(reminder == Reminder(id: 1, title: "Get milk", remindersListID: 1)) - } - try await withDependencies { $0.datetime.now.addTimeInterval(1) } operation: { try await userDatabase.userWrite { db in try Reminder.find(1).update { $0.title = "Buy milk" }.execute(db) } - - try await syncEngine.processPendingRecordZoneChanges(scope: .private) } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertQuery(Reminder.all, database: userDatabase.database) { + """ + ┌───────────────────────┐ + │ Reminder( │ + │ id: 1, │ + │ dueDate: nil, │ + │ isCompleted: false, │ + │ priority: nil, │ + │ title: "Buy milk", │ + │ remindersListID: 1 │ + │ ) │ + └───────────────────────┘ + """ + } + assertQuery(RemindersList.all, database: userDatabase.database) { + """ + ┌─────────────────────┐ + │ RemindersList( │ + │ id: 1, │ + │ title: "Personal" │ + │ ) │ + └─────────────────────┘ + """ + } assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -142,18 +108,13 @@ ) """ } - - try await userDatabase.read { db in - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect(reminder == Reminder.init(id: 1, title: "Buy milk", remindersListID: 1)) - } } /* * Remote client creates records A <- B <- C * Records A and C are sync'd to local client. * Remote deletes record B and C. - * C should be deleted from local client. + * Unsynced C should be deleted from local client. */ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func remoteCreatesRecordABC_localReceivesAC_remoteDeletesBC() async throws { @@ -169,12 +130,35 @@ _ = try syncEngine.modifyRecords(scope: .private, saving: [modelBRecord]) try await syncEngine.modifyRecords(scope: .private, saving: [modelCRecord]).notify() - try await userDatabase.read { db in - try #expect( - UnsyncedRecordID.all.fetchAll(db) == [ - UnsyncedRecordID(recordID: ModelC.recordID(for: 1)) - ] - ) + assertQuery(ModelA.all, database: userDatabase.database) { + """ + ┌────────────────┐ + │ ModelA( │ + │ id: 1, │ + │ count: 0, │ + │ isEven: true │ + │ ) │ + └────────────────┘ + """ + } + assertQuery(ModelB.all, database: userDatabase.database) { + """ + """ + } + assertQuery(ModelC.all, database: userDatabase.database) { + """ + """ + } + assertQuery(UnsyncedRecordID.all, database: syncEngine.metadatabase) { + """ + ┌─────────────────────────────────┐ + │ UnsyncedRecordID( │ + │ recordName: "1:modelCs", │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__" │ + │ ) │ + └─────────────────────────────────┘ + """ } try await syncEngine.modifyRecords( @@ -183,12 +167,29 @@ ) .notify() - try await userDatabase.read { db in - try #expect( - UnsyncedRecordID.all.fetchAll(db) == [] - ) + assertQuery(ModelA.all, database: userDatabase.database) { + """ + ┌────────────────┐ + │ ModelA( │ + │ id: 1, │ + │ count: 0, │ + │ isEven: true │ + │ ) │ + └────────────────┘ + """ + } + assertQuery(ModelB.all, database: userDatabase.database) { + """ + """ + } + assertQuery(ModelC.all, database: userDatabase.database) { + """ + """ + } + assertQuery(UnsyncedRecordID.all, database: syncEngine.metadatabase) { + """ + """ } - assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -213,7 +214,7 @@ } // * Receive child record with no parent record. - // * Receive parent record. + // * Receive both child and parent together. // => Both records are synchronized. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func receiveChildRecordBeforeParent_ReceiveChildAndParentRecord() async throws { @@ -250,6 +251,30 @@ ) .notify() + assertQuery(Reminder.all, database: userDatabase.database) { + """ + ┌───────────────────────┐ + │ Reminder( │ + │ id: 1, │ + │ dueDate: nil, │ + │ isCompleted: false, │ + │ priority: nil, │ + │ title: "Get milk", │ + │ remindersListID: 1 │ + │ ) │ + └───────────────────────┘ + """ + } + assertQuery(RemindersList.all, database: userDatabase.database) { + """ + ┌─────────────────────┐ + │ RemindersList( │ + │ id: 1, │ + │ title: "Personal" │ + │ ) │ + └─────────────────────┘ + """ + } assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -282,19 +307,6 @@ ) """ } - - try await userDatabase.read { db in - try #expect( - RemindersList.all.fetchAll(db) == [ - RemindersList(id: 1, title: "Personal") - ] - ) - try #expect( - Reminder.all.fetchAll(db) == [ - Reminder(id: 1, title: "Get milk", remindersListID: 1) - ] - ) - } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -318,47 +330,14 @@ action: .none ) - _ = try { try syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]) }() + _ = try syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]) try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() - assertInlineSnapshot(of: container, as: .customDump) { + assertQuery(Reminder.all, database: userDatabase.database) { """ - 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, - remindersListID: 1, - title: "Get milk" - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Personal" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) """ } - try await userDatabase.read { db in - let reminder = try Reminder.find(1).fetchOne(db) - #expect(reminder == nil) - } - let relaunchedSyncEngine = try await SyncEngine( container: syncEngine.container, userDatabase: syncEngine.userDatabase, @@ -372,24 +351,40 @@ syncEngine: relaunchedSyncEngine.private ) - try await userDatabase.read { db in - let reminderMetadata = try #require( - try Reminder.metadata(for: 1).fetchOne(db) - ) - #expect(reminderMetadata.recordName == Reminder.recordName(for: 1)) - #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) - - let remindersListMetadata = try #require( - try RemindersList.metadata(for: 1).fetchOne(db) - ) - #expect(remindersListMetadata.recordName == RemindersList.recordName(for: 1)) - #expect(remindersListMetadata.parentRecordName == nil) - - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect(reminder == Reminder(id: 1, title: "Get milk", remindersListID: 1)) - - let remindersList = try #require(try RemindersList.find(1).fetchOne(db)) - #expect(remindersList == RemindersList(id: 1, title: "Personal")) + assertQuery( + SyncMetadata.order(by: \.recordName).select { ($0.recordName, $0.parentRecordName) }, + database: syncEngine.metadatabase + ) { + """ + ┌────────────────────┬────────────────────┐ + │ "1:reminders" │ "1:remindersLists" │ + │ "1:remindersLists" │ nil │ + └────────────────────┴────────────────────┘ + """ + } + assertQuery(Reminder.all, database: userDatabase.database) { + """ + ┌───────────────────────┐ + │ Reminder( │ + │ id: 1, │ + │ dueDate: nil, │ + │ isCompleted: false, │ + │ priority: nil, │ + │ title: "Get milk", │ + │ remindersListID: 1 │ + │ ) │ + └───────────────────────┘ + """ + } + assertQuery(RemindersList.all, database: userDatabase.database) { + """ + ┌─────────────────────┐ + │ RemindersList( │ + │ id: 1, │ + │ title: "Personal" │ + │ ) │ + └─────────────────────┘ + """ } try await withDependencies { @@ -402,6 +397,20 @@ try await relaunchedSyncEngine.processPendingRecordZoneChanges(scope: .private) } + assertQuery(Reminder.all, database: userDatabase.database) { + """ + ┌───────────────────────┐ + │ Reminder( │ + │ id: 1, │ + │ dueDate: nil, │ + │ isCompleted: false, │ + │ priority: nil, │ + │ title: "Buy milk", │ + │ remindersListID: 1 │ + │ ) │ + └───────────────────────┘ + """ + } assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -435,11 +444,6 @@ ) """ } - - try await userDatabase.read { db in - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect(reminder == Reminder.init(id: 1, title: "Buy milk", remindersListID: 1)) - } } // * Remote moves child to a parent the local client does not know about. @@ -480,39 +484,6 @@ saving: [reminderRecord, personalListRecord] ).notify() - 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, - remindersListID: 1, - title: "Get milk" - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Personal" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - let modifications = try await withDependencies { $0.datetime.now.addTimeInterval(1) } operation: { @@ -532,6 +503,47 @@ await modifications.notify() + assertQuery( + SyncMetadata.order(by: \.recordName).select { ($0.recordName, $0.parentRecordName) }, + database: syncEngine.metadatabase + ) { + """ + ┌────────────────────┬────────────────────┐ + │ "1:reminders" │ "2:remindersLists" │ + │ "1:remindersLists" │ nil │ + │ "2:remindersLists" │ nil │ + └────────────────────┴────────────────────┘ + """ + } + assertQuery(Reminder.all, database: userDatabase.database) { + """ + ┌───────────────────────┐ + │ Reminder( │ + │ id: 1, │ + │ dueDate: nil, │ + │ isCompleted: false, │ + │ priority: nil, │ + │ title: "Get milk", │ + │ remindersListID: 2 │ + │ ) │ + └───────────────────────┘ + """ + } + assertQuery(RemindersList.all, database: userDatabase.database) { + """ + ┌─────────────────────┐ + │ RemindersList( │ + │ id: 1, │ + │ title: "Personal" │ + │ ) │ + ├─────────────────────┤ + │ RemindersList( │ + │ id: 2, │ + │ title: "Business" │ + │ ) │ + └─────────────────────┘ + """ + } assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -572,19 +584,15 @@ ) """ } - - try await userDatabase.read { db in - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect(reminder == Reminder(id: 1, title: "Get milk", remindersListID: 2)) - - let reminderMetadata = try #require( - try Reminder.metadata(for: 1) - .fetchOne(db) - ) - #expect(reminderMetadata.parentRecordName == "2:remindersLists") - } } + // * Create 3 reminders lists and a reminder + // * Sync to CloudKit + // * Move reminder to different list on CloudKit, do not synchronize it right away. + // * A moment ater, move local reminder to different list + // * Sync CloudKit to local + // * Then send local to CloudKit + // => Local edit wins @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func changeParentRelationship_RemotelyThenLocally() async throws { try await userDatabase.userWrite { db in @@ -624,18 +632,35 @@ } await modifications.notify() - - try await userDatabase.read { db in - let metadata = try #require( - try Reminder.metadata(for: 1).fetchOne(db) - ) - #expect(metadata.parentRecordName == RemindersList.recordName(for: 3)) - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect(reminder == Reminder(id: 1, title: "Buy milk", remindersListID: 3)) - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertQuery( + SyncMetadata.select { ($0.recordName, $0.parentRecordName) }, + database: syncEngine.metadatabase + ) { + """ + ┌────────────────────┬────────────────────┐ + │ "1:remindersLists" │ nil │ + │ "2:remindersLists" │ nil │ + │ "3:remindersLists" │ nil │ + │ "1:reminders" │ "3:remindersLists" │ + └────────────────────┴────────────────────┘ + """ + } + assertQuery(Reminder.all, database: userDatabase.database) { + """ + ┌───────────────────────┐ + │ Reminder( │ + │ id: 1, │ + │ dueDate: nil, │ + │ isCompleted: false, │ + │ priority: nil, │ + │ title: "Buy milk", │ + │ remindersListID: 3 │ + │ ) │ + └───────────────────────┘ + """ + } assertInlineSnapshot( of: syncEngine.private.database.storage[syncEngine.defaultZone.zoneID]?[ Reminder.recordID(for: 1) @@ -655,17 +680,15 @@ ) """ } - - try await userDatabase.read { db in - let metadata = try #require( - try Reminder.metadata(for: 1).fetchOne(db) - ) - #expect(metadata.parentRecordName == RemindersList.recordName(for: 3)) - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect(reminder == Reminder(id: 1, title: "Buy milk", remindersListID: 3)) - } } + // * Create 3 reminders lists and a reminder + // * Sync to CloudKit + // * Move reminder to different list on CloudKit, do not synchronize it right away. + // * A moment ater, move local reminder to different list + // * Send local data to CloudKit + // * The synchronize CloudKit to local + // => Local edit wins @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func changeParentRelationship_RemoteFirstEdited_LocalSecondEdited_SendBatch_ReceiveCloudKit() @@ -703,40 +726,36 @@ } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - try await userDatabase.read { db in - let metadata = try #require( - try Reminder.metadata(for: 1).fetchOne(db) - ) - #expect(metadata.parentRecordName == RemindersList.recordName(for: 3)) - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect(reminder == Reminder(id: 1, title: "Get milk", remindersListID: 3)) - } - await modifications.notify() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot( - of: syncEngine.private.database.storage[syncEngine.defaultZone.zoneID]?[ - Reminder.recordID(for: 1) - ], - as: .customDump + assertQuery( + SyncMetadata.select { ($0.recordName, $0.parentRecordName) }, + database: syncEngine.metadatabase ) { """ - CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(3:remindersLists/zone/__defaultOwner__)), - share: nil, - id: 1, - isCompleted: 0, - remindersListID: 3, - title: "Get milk" - ) + ┌────────────────────┬────────────────────┐ + │ "1:remindersLists" │ nil │ + │ "2:remindersLists" │ nil │ + │ "3:remindersLists" │ nil │ + │ "1:reminders" │ "3:remindersLists" │ + └────────────────────┴────────────────────┘ + """ + } + assertQuery(Reminder.all, database: userDatabase.database) { + """ + ┌───────────────────────┐ + │ Reminder( │ + │ id: 1, │ + │ dueDate: nil, │ + │ isCompleted: false, │ + │ priority: nil, │ + │ title: "Get milk", │ + │ remindersListID: 3 │ + │ ) │ + └───────────────────────┘ """ } - - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot( of: syncEngine.private.database.storage[syncEngine.defaultZone.zoneID]?[ Reminder.recordID(for: 1) @@ -756,15 +775,6 @@ ) """ } - - try await userDatabase.read { db in - let metadata = try #require( - try Reminder.metadata(for: 1).fetchOne(db) - ) - #expect(metadata.parentRecordName == RemindersList.recordName(for: 3)) - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - #expect(reminder == Reminder(id: 1, title: "Get milk", remindersListID: 3)) - } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) diff --git a/Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift index 67d4d1e3..6559843e 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift @@ -1,6 +1,7 @@ #if canImport(CloudKit) import CloudKit import CustomDump + import SQLiteDataTestSupport import Foundation import InlineSnapshotTesting import OrderedCollections @@ -12,7 +13,7 @@ @MainActor final class MetadataTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func parentRecordName() async throws { + @Test func parentRecordNameUpdatesAfterMovingReminderToDifferentList() async throws { try await userDatabase.userWrite { db in try db.seed { RemindersList(id: 1, title: "Personal") @@ -22,76 +23,45 @@ } 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: "Groceries" - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Personal" - ), - [2]: CKRecord( - recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 2, - title: "Work" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - - try await userDatabase.userRead { db in - let reminderMetadata = try #require( - try SyncMetadata - .where { $0.recordName.eq(Reminder.recordName(for: 1)) } - .fetchOne(db) - ) - #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) - } try withDependencies { $0.datetime.now.addTimeInterval(60) } operation: { - _ = try { - try userDatabase.userWrite { db in - try Reminder.find(1) - .update { $0.remindersListID = 2 } - .execute(db) - let reminderMetadata = try #require( - try SyncMetadata - .where { $0.recordName.eq(Reminder.recordName(for: 1)) } - .fetchOne(db) - ) - #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 2)) - } - }() + try userDatabase.userWrite { db in + try Reminder.find(1) + .update { $0.remindersListID = 2 } + .execute(db) + } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertQuery(Reminder.all, database: userDatabase.database) { + """ + ┌───────────────────────┐ + │ Reminder( │ + │ id: 1, │ + │ dueDate: nil, │ + │ isCompleted: false, │ + │ priority: nil, │ + │ title: "Groceries", │ + │ remindersListID: 2 │ + │ ) │ + └───────────────────────┘ + """ + } + assertQuery( + SyncMetadata.select { ($0.recordName, $0.parentRecordName) }, + database: syncEngine.metadatabase + ) { + """ + ┌────────────────────┬────────────────────┐ + │ "1:remindersLists" │ nil │ + │ "2:remindersLists" │ nil │ + │ "1:reminders" │ "2:remindersLists" │ + └────────────────────┴────────────────────┘ + """ + } assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -135,6 +105,7 @@ } } + // 'parent' association is not set on CKRecord for records with multiple foreign keys. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func noParentRecordForRecordsWithMultipleForeignKeys() async throws { try await userDatabase.userWrite { db in @@ -197,95 +168,324 @@ """ } - let parentRecordNames = try await userDatabase.userRead { db in - try SyncMetadata - .where { $0.recordType != Reminder.tableName } - .select(\.parentRecordName) - .fetchAll(db) - } - #expect(parentRecordNames.allSatisfy { $0 == nil }) - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func recordType() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - Reminder(id: 2, title: "Groceries", remindersListID: 1) - Reminder(id: 3, title: "Groceries", remindersListID: 1) - Reminder(id: 4, title: "Groceries", remindersListID: 1) - } - } - - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - let reminderMetadata = try await userDatabase.userRead { db in - try SyncMetadata - .where { $0.recordType == Reminder.tableName } - .fetchAll(db) - } - #expect( - reminderMetadata.map(\.recordName) == [ - Reminder.recordName(for: 2), - Reminder.recordName(for: 3), - Reminder.recordName(for: 4), - ] - ) - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func parentRecordType() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - Reminder(id: 2, title: "Groceries", remindersListID: 1) - Reminder(id: 3, title: "Groceries", remindersListID: 1) - Reminder(id: 4, title: "Groceries", remindersListID: 1) - } - } - - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - try await userDatabase.userRead { db in - let reminderMetadata = - try SyncMetadata - .where { $0.parentRecordType == RemindersList.tableName } - .fetchAll(db) - #expect( - reminderMetadata.map(\.recordName) == [ - Reminder.recordName(for: 2), - Reminder.recordName(for: 3), - Reminder.recordName(for: 4), - ] - ) + assertQuery( + SyncMetadata.order(by: \.recordName).select { ($0.recordName, $0.parentRecordName) }, + database: syncEngine.metadatabase + ) { + """ + ┌────────────────────┬────────────────────┐ + │ "1:reminderTags" │ nil │ + │ "1:reminders" │ "1:remindersLists" │ + │ "1:remindersLists" │ nil │ + │ "weekend:tags" │ nil │ + └────────────────────┴────────────────────┘ + """ } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func parentRecordPrimaryKey() async throws { + @Test func metadataFields() async throws { try await userDatabase.userWrite { db in try db.seed { RemindersList(id: 1, title: "Personal") - Reminder(id: 2, title: "Groceries", remindersListID: 1) - Reminder(id: 3, title: "Groceries", remindersListID: 1) - Reminder(id: 4, title: "Groceries", remindersListID: 1) + RemindersList(id: 2, title: "Business") + Reminder(id: 1, title: "Groceries", remindersListID: 1) + Reminder(id: 2, title: "Take a walk", remindersListID: 1) + Reminder(id: 3, title: "Call accountant", remindersListID: 2) + Tag(title: "weekend") + Tag(title: "optional") + ReminderTag(id: 1, reminderID: 1, tagID: "weekend") + ReminderTag(id: 2, reminderID: 2, tagID: "weekend") + ReminderTag(id: 3, reminderID: 3, tagID: "optional") } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try await userDatabase.userRead { db in - let reminderMetadata = - try SyncMetadata - .where { $0.parentRecordPrimaryKey.eq("1") } - .fetchAll(db) - #expect( - reminderMetadata.map(\.recordName) == [ - Reminder.recordName(for: 2), - Reminder.recordName(for: 3), - Reminder.recordName(for: 4), - ] - ) + assertQuery( + SyncMetadata.order(by: \.recordName), + database: syncEngine.metadatabase + ) { + """ + ┌─────────────────────────────────────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "reminderTags", │ + │ recordName: "1:reminderTags", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:reminderTags/zone/__defaultOwner__), │ + │ recordType: "reminderTags", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:reminderTags/zone/__defaultOwner__), │ + │ recordType: "reminderTags", │ + │ parent: nil, │ + │ share: nil, │ + │ id: 1, │ + │ reminderID: 1, │ + │ tagID: "weekend" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "reminders", │ + │ recordName: "1:reminders", │ + │ parentRecordPrimaryKey: "1", │ + │ parentRecordType: "remindersLists", │ + │ parentRecordName: "1:remindersLists", │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), │ + │ recordType: "reminders", │ + │ parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: 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: "Groceries" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "remindersLists", │ + │ recordName: "1:remindersLists", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), │ + │ recordType: "remindersLists", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), │ + │ recordType: "remindersLists", │ + │ parent: nil, │ + │ share: nil, │ + │ id: 1, │ + │ title: "Personal" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "2", │ + │ recordType: "reminderTags", │ + │ recordName: "2:reminderTags", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(2:reminderTags/zone/__defaultOwner__), │ + │ recordType: "reminderTags", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(2:reminderTags/zone/__defaultOwner__), │ + │ recordType: "reminderTags", │ + │ parent: nil, │ + │ share: nil, │ + │ id: 2, │ + │ reminderID: 2, │ + │ tagID: "weekend" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "2", │ + │ recordType: "reminders", │ + │ recordName: "2:reminders", │ + │ parentRecordPrimaryKey: "1", │ + │ parentRecordType: "remindersLists", │ + │ parentRecordName: "1:remindersLists", │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(2:reminders/zone/__defaultOwner__), │ + │ recordType: "reminders", │ + │ parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(2:reminders/zone/__defaultOwner__), │ + │ recordType: "reminders", │ + │ parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), │ + │ share: nil, │ + │ id: 2, │ + │ isCompleted: 0, │ + │ remindersListID: 1, │ + │ title: "Take a walk" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "2", │ + │ recordType: "remindersLists", │ + │ recordName: "2:remindersLists", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__), │ + │ recordType: "remindersLists", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__), │ + │ recordType: "remindersLists", │ + │ parent: nil, │ + │ share: nil, │ + │ id: 2, │ + │ title: "Business" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "3", │ + │ recordType: "reminderTags", │ + │ recordName: "3:reminderTags", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(3:reminderTags/zone/__defaultOwner__), │ + │ recordType: "reminderTags", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(3:reminderTags/zone/__defaultOwner__), │ + │ recordType: "reminderTags", │ + │ parent: nil, │ + │ share: nil, │ + │ id: 3, │ + │ reminderID: 3, │ + │ tagID: "optional" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "3", │ + │ recordType: "reminders", │ + │ recordName: "3:reminders", │ + │ parentRecordPrimaryKey: "2", │ + │ parentRecordType: "remindersLists", │ + │ parentRecordName: "2:remindersLists", │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(3:reminders/zone/__defaultOwner__), │ + │ recordType: "reminders", │ + │ parent: CKReference(recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__)), │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(3:reminders/zone/__defaultOwner__), │ + │ recordType: "reminders", │ + │ parent: CKReference(recordID: CKRecord.ID(2:remindersLists/zone/__defaultOwner__)), │ + │ share: nil, │ + │ id: 3, │ + │ isCompleted: 0, │ + │ remindersListID: 2, │ + │ title: "Call accountant" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "optional", │ + │ recordType: "tags", │ + │ recordName: "optional:tags", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(optional:tags/zone/__defaultOwner__), │ + │ recordType: "tags", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(optional:tags/zone/__defaultOwner__), │ + │ recordType: "tags", │ + │ parent: nil, │ + │ share: nil, │ + │ title: "optional" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "weekend", │ + │ recordType: "tags", │ + │ recordName: "weekend:tags", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(weekend:tags/zone/__defaultOwner__), │ + │ recordType: "tags", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(weekend:tags/zone/__defaultOwner__), │ + │ recordType: "tags", │ + │ parent: nil, │ + │ share: nil, │ + │ title: "weekend" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + └─────────────────────────────────────────────────────────────────────────────────────────┘ + """ } } } diff --git a/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift index e2577b62..f24ce8e4 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift @@ -1,6 +1,7 @@ #if canImport(CloudKit) import CloudKit import CustomDump + import SQLiteDataTestSupport import Foundation import InlineSnapshotTesting import SQLiteData @@ -24,6 +25,8 @@ ) } + // * 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) @@ -61,67 +64,15 @@ """ } - let metadata = try await userDatabase.userRead { db in - try SyncMetadata.order(by: \.recordName).fetchAll(db) - } - assertInlineSnapshot(of: metadata, as: .customDump) { + assertQuery( + SyncMetadata.order(by: \.recordName).select(\.recordName), + database: syncEngine.metadatabase + ) { """ - [ - [0]: SyncMetadata( - recordPrimaryKey: "1", - recordType: "reminders", - recordName: "1:reminders", - parentRecordPrimaryKey: "1", - parentRecordType: "remindersLists", - parentRecordName: "1:remindersLists", - lastKnownServerRecord: CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil - ), - _lastKnownServerRecordAllFields: 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" - ), - share: nil, - _isDeleted: false, - isShared: false, - userModificationDate: Date(1970-01-01T00:00:00.000Z) - ), - [1]: SyncMetadata( - recordPrimaryKey: "1", - recordType: "remindersLists", - recordName: "1:remindersLists", - parentRecordPrimaryKey: nil, - parentRecordType: nil, - parentRecordName: nil, - lastKnownServerRecord: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil - ), - _lastKnownServerRecordAllFields: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Personal" - ), - share: nil, - _isDeleted: false, - isShared: false, - userModificationDate: Date(1970-01-01T00:00:00.000Z) - ) - ] + ┌────────────────────┐ + │ "1:reminders" │ + │ "1:remindersLists" │ + └────────────────────┘ """ } } diff --git a/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift b/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift index 69a2dbf9..6853cc5c 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift @@ -12,7 +12,7 @@ final class RecordTypeTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func setUp() async throws { - let recordTypes = try await userDatabase.userRead { db in + let recordTypes = try await syncEngine.metadatabase.read { db in try RecordType.all.fetchAll(db) } assertInlineSnapshot(of: recordTypes, as: .customDump) { @@ -393,15 +393,15 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func resetUp() async throws { - let recordTypes = try await userDatabase.userRead { db in + @Test func reSetUp() async throws { + let recordTypes = try await syncEngine.metadatabase.read { db in try RecordType.all.fetchAll(db) } syncEngine.stop() try syncEngine.tearDownSyncEngine() try syncEngine.setUpSyncEngine() try await syncEngine.start() - let recordTypesAfterReSetup = try await userDatabase.userRead { db in + let recordTypesAfterReSetup = try await syncEngine.metadatabase.read { db in try RecordType.all.fetchAll(db) } expectNoDifference(recordTypes, recordTypesAfterReSetup) @@ -409,7 +409,7 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func migration() async throws { - let recordTypes = try await userDatabase.userRead { db in + let recordTypes = try await syncEngine.metadatabase.read { db in try RecordType.order(by: \.tableName).fetchAll(db) } syncEngine.stop() @@ -425,7 +425,7 @@ try syncEngine.setUpSyncEngine() try await syncEngine.start() - let recordTypesAfterMigration = try await userDatabase.userRead { db in + let recordTypesAfterMigration = try await syncEngine.metadatabase.read { db in try RecordType.order(by: \.tableName).fetchAll(db) } let remindersTableIndex = try #require( diff --git a/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift index a43fe6da..d6d63194 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift @@ -1,6 +1,7 @@ #if canImport(CloudKit) import CloudKit import CustomDump + import SQLiteDataTestSupport import Foundation import GRDB import InlineSnapshotTesting @@ -283,45 +284,42 @@ """ } - let metadata = try await userDatabase.read { db in - try SyncMetadata.order(by: \.recordName).fetchAll(db) - } - assertInlineSnapshot(of: metadata, as: .customDump) { - """ - [ - [0]: SyncMetadata( - recordPrimaryKey: "1", - recordType: "remindersLists", - recordName: "1:remindersLists", - parentRecordPrimaryKey: nil, - parentRecordType: nil, - parentRecordName: nil, - lastKnownServerRecord: 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)) - ), - _lastKnownServerRecordAllFields: 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, - isCompleted: 0, - title: "Personal" - ), - share: CKRecord( - recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), - recordType: "cloudkit.share", - parent: nil, - share: nil - ), - _isDeleted: false, - isShared: true, - userModificationDate: Date(1970-01-01T00:00:00.000Z) - ) - ] + assertQuery(SyncMetadata.order(by: \.recordName), database: syncEngine.metadatabase) { + """ + ┌─────────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "remindersLists", │ + │ recordName: "1:remindersLists", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: 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)) │ + │ ), │ + │ _lastKnownServerRecordAllFields: 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, │ + │ isCompleted: 0, │ + │ title: "Personal" │ + │ ), │ + │ share: CKRecord( │ + │ recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), │ + │ recordType: "cloudkit.share", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _isDeleted: false, │ + │ isShared: true, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + └─────────────────────────────────────────────────────────────────────────────────────────────────────┘ """ } } @@ -474,15 +472,19 @@ } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - let sharedRecord = try await syncEngine.share(record: remindersList, configure: { _ in }) + let _ = try await syncEngine.share(record: remindersList, configure: { _ in }) - try await userDatabase.read { db in - let metadata = try #require( - try SyncMetadata - .where { $0.recordPrimaryKey.eq("1") } - .fetchOne(db) - ) - #expect(metadata.share?.recordID == sharedRecord.share.recordID) + assertQuery(SyncMetadata.select(\.share), database: syncEngine.metadatabase) { + """ + ┌────────────────────────────────────────────────────────────────────────┐ + │ CKRecord( │ + │ recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__), │ + │ recordType: "cloudkit.share", │ + │ parent: nil, │ + │ share: nil │ + │ ) │ + └────────────────────────────────────────────────────────────────────────┘ + """ } assertInlineSnapshot(of: container, as: .customDump) { @@ -635,18 +637,17 @@ ) ) - try await userDatabase.read { db in - let remindersList = try #require(try RemindersList.find(1).fetchOne(db)) - let metadata = try #require( - try SyncMetadata - .where { $0.recordName.eq(remindersListRecord.recordID.recordName) } - .fetchOne(db) - ) - #expect(remindersList.title == "Personal") - #expect( - metadata.share?.recordID.recordName - == "share-\(remindersListRecord.recordID.recordName)" - ) + assertQuery(SyncMetadata.select(\.share), database: syncEngine.metadatabase) { + """ + ┌───────────────────────────────────────────────────────────────────────────────┐ + │ CKRecord( │ + │ recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), │ + │ recordType: "cloudkit.share", │ + │ parent: nil, │ + │ share: nil │ + │ ) │ + └───────────────────────────────────────────────────────────────────────────────┘ + """ } assertInlineSnapshot(of: container, as: .customDump) { @@ -722,13 +723,16 @@ try await syncEngine.processPendingRecordZoneChanges(scope: .shared) - try await userDatabase.read { db in - let metadata = try #require( - try SyncMetadata - .where { $0.recordName.eq("1:reminders") } - .fetchOne(db) - ) - #expect(metadata.parentRecordName == "1:remindersLists") + assertQuery( + SyncMetadata.select { ($0.recordName, $0.parentRecordName) }, + database: syncEngine.metadatabase + ) { + """ + ┌────────────────────┬────────────────────┐ + │ "1:remindersLists" │ nil │ + │ "1:reminders" │ "1:remindersLists" │ + └────────────────────┴────────────────────┘ + """ } assertInlineSnapshot(of: container, as: .customDump) { @@ -859,13 +863,12 @@ try await syncEngine.processPendingRecordZoneChanges(scope: .shared) - try await userDatabase.read { db in - let share = - try SyncMetadata - .where { $0.recordName.eq(remindersListRecord.recordID.recordName) } - .select(\.share) - .fetchOne(db) - #expect(share == .none) + assertQuery( + SyncMetadata.select { ($0.recordName, $0.share) }, + database: syncEngine.metadatabase + ) { + """ + """ } assertInlineSnapshot(of: container, as: .customDump) { diff --git a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift index 31efb4e2..df5da821 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift @@ -2,6 +2,7 @@ import CloudKit import DependenciesTestSupport import InlineSnapshotTesting + import SQLiteDataTestSupport import OrderedCollections import SQLiteData import SnapshotTesting @@ -30,16 +31,41 @@ try await Task.sleep(for: .seconds(0.5)) - try await userDatabase.userRead { db in - let remindersListMetadata = try #require( - try RemindersList.metadata(for: 1).fetchOne(db)) - #expect(remindersListMetadata.lastKnownServerRecord == nil) - - let reminderMetadata = try #require(try Reminder.metadata(for: 1).fetchOne(db)) - #expect(reminderMetadata.lastKnownServerRecord == nil) - #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) + assertQuery(SyncMetadata.all, database: syncEngine.metadatabase) { + """ + ┌────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "remindersLists", │ + │ recordName: "1:remindersLists", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: nil, │ + │ _lastKnownServerRecordAllFields: nil, │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + ├────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "reminders", │ + │ recordName: "1:reminders", │ + │ parentRecordPrimaryKey: "1", │ + │ parentRecordType: "remindersLists", │ + │ parentRecordName: "1:remindersLists", │ + │ lastKnownServerRecord: nil, │ + │ _lastKnownServerRecordAllFields: nil, │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + └────────────────────────────────────────────────────────┘ + """ } - assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -155,17 +181,33 @@ try await userDatabase.userWrite { db in try RemindersList.find(1).update { $0.title += "!" }.execute(db) } - try await Task.sleep(for: .seconds(0.5)) + } + try await Task.sleep(for: .seconds(0.5)) - try await userDatabase.read { db in - try #expect(PendingRecordZoneChange.all.fetchCount(db) == 1) - try #expect(RemindersList.find(1).fetchOne(db)?.title == "Personal!") - } + assertQuery(PendingRecordZoneChange.all, database: syncEngine.metadatabase) { + """ + ┌─────────────────────────────────────────────────────────────────────────────────────────────┐ + │ PendingRecordZoneChange( │ + │ pendingRecordZoneChange: .saveRecord(CKRecord.ID(1:remindersLists/zone/__defaultOwner__)) │ + │ ) │ + └─────────────────────────────────────────────────────────────────────────────────────────────┘ + """ + } + assertQuery(RemindersList.all, database: userDatabase.database) { + """ + ┌──────────────────────┐ + │ RemindersList( │ + │ id: 1, │ + │ title: "Personal!" │ + │ ) │ + └──────────────────────┘ + """ + } - try await syncEngine.start() - try await syncEngine.processPendingRecordZoneChanges(scope: .private) + try await syncEngine.start() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: container, as: .customDump) { + assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( @@ -187,12 +229,8 @@ ) ) """ - } - - try await userDatabase.read { db in - try #expect(PendingRecordZoneChange.all.fetchCount(db) == 0) - } } + assertQuery(PendingRecordZoneChange.all, database: syncEngine.metadatabase) } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -385,6 +423,12 @@ try await super.init(startImmediately: false) } + // * Start with sync engine off + // * Write a few rows + // * Verify sync metadata is created. + // * Verify cloud data is still empty + // * Start sync engine + // * Verify that data is sent to CloudKit database and cached locally. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func writeAndThenStart() async throws { try await userDatabase.userWrite { db in @@ -393,17 +437,43 @@ Reminder(id: 1, title: "Get milk", remindersListID: 1) } } + try await Task.sleep(for: .seconds(0.1)) - try await userDatabase.userRead { db in - let remindersListMetadata = try #require( - try RemindersList.metadata(for: 1).fetchOne(db)) - #expect(remindersListMetadata.lastKnownServerRecord == nil) - - let reminderMetadata = try #require(try Reminder.metadata(for: 1).fetchOne(db)) - #expect(reminderMetadata.lastKnownServerRecord == nil) - #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) + assertQuery(SyncMetadata.all, database: syncEngine.metadatabase) { + """ + ┌────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "remindersLists", │ + │ recordName: "1:remindersLists", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: nil, │ + │ _lastKnownServerRecordAllFields: nil, │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + ├────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "reminders", │ + │ recordName: "1:reminders", │ + │ parentRecordPrimaryKey: "1", │ + │ parentRecordType: "remindersLists", │ + │ parentRecordName: "1:remindersLists", │ + │ lastKnownServerRecord: nil, │ + │ _lastKnownServerRecordAllFields: nil, │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + └────────────────────────────────────────────────────────┘ + """ } - assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -424,6 +494,67 @@ try await syncEngine.processPendingDatabaseChanges(scope: .private) try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertQuery(SyncMetadata.all, database: syncEngine.metadatabase) { + """ + ┌─────────────────────────────────────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "remindersLists", │ + │ recordName: "1:remindersLists", │ + │ parentRecordPrimaryKey: nil, │ + │ parentRecordType: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), │ + │ recordType: "remindersLists", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), │ + │ recordType: "remindersLists", │ + │ parent: nil, │ + │ share: nil, │ + │ id: 1, │ + │ title: "Personal" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + ├─────────────────────────────────────────────────────────────────────────────────────────┤ + │ SyncMetadata( │ + │ recordPrimaryKey: "1", │ + │ recordType: "reminders", │ + │ recordName: "1:reminders", │ + │ parentRecordPrimaryKey: "1", │ + │ parentRecordType: "remindersLists", │ + │ parentRecordName: "1:remindersLists", │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), │ + │ recordType: "reminders", │ + │ parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: 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: "Get milk" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + └─────────────────────────────────────────────────────────────────────────────────────────┘ + """ + } assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( diff --git a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift index 198145d7..144f7871 100644 --- a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift @@ -115,14 +115,20 @@ class BaseCloudKitTests: @unchecked Sendable { deinit { if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { - syncEngine.shared.assertFetchChangesScopes([]) - syncEngine.shared.state.assertPendingDatabaseChanges([]) - syncEngine.shared.state.assertPendingRecordZoneChanges([]) - syncEngine.shared.assertAcceptedShareMetadata([]) - syncEngine.private.assertFetchChangesScopes([]) - syncEngine.private.state.assertPendingDatabaseChanges([]) - syncEngine.private.state.assertPendingRecordZoneChanges([]) - syncEngine.private.assertAcceptedShareMetadata([]) + guard + let shared = syncEngine.syncEngines.shared as? MockSyncEngine, + let `private` = syncEngine.syncEngines.private as? MockSyncEngine + else { + return + } + shared.assertFetchChangesScopes([]) + shared.state.assertPendingDatabaseChanges([]) + shared.state.assertPendingRecordZoneChanges([]) + shared.assertAcceptedShareMetadata([]) + `private`.assertFetchChangesScopes([]) + `private`.state.assertPendingDatabaseChanges([]) + `private`.state.assertPendingRecordZoneChanges([]) + `private`.assertAcceptedShareMetadata([]) try! syncEngine.metadatabase.read { db in try #expect(UnsyncedRecordID.count().fetchOne(db) == 0) } diff --git a/Tests/SQLiteDataTests/Internal/Schema.swift b/Tests/SQLiteDataTests/Internal/Schema.swift index d1bc07b9..fdc4fe5d 100644 --- a/Tests/SQLiteDataTests/Internal/Schema.swift +++ b/Tests/SQLiteDataTests/Internal/Schema.swift @@ -76,7 +76,7 @@ import SQLiteData func database(containerIdentifier: String) throws -> DatabasePool { var configuration = Configuration() configuration.prepareDatabase { db in - try db.attachMetadatabase(containerIdentifier: containerIdentifier) + //try db.attachMetadatabase(containerIdentifier: containerIdentifier) } let url = URL.temporaryDirectory.appending(path: "\(UUID().uuidString).sqlite") let database = try DatabasePool(path: url.path(), configuration: configuration) From b66cc1f08b64a9d0553270e4d3f679841dfd9000 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 5 Sep 2025 16:45:11 -0500 Subject: [PATCH 2/8] convert more userDatabase to metadatabase --- Sources/SQLiteData/CloudKit/CloudKitSharing.swift | 2 +- Sources/SQLiteData/CloudKit/SyncEngine.swift | 10 +++++----- .../CloudKitTests/SyncEngineLifecycleTests.swift | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift index 33b4264d..83ea4054 100644 --- a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift +++ b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift @@ -159,7 +159,7 @@ saving: [sharedRecord, rootRecord], deleting: [] ) - try await userDatabase.write { db in + try await metadatabase.write { db in try SyncMetadata .where { $0.recordName.eq(recordName) } .update { $0.share = sharedRecord } diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 33013810..8124bf3d 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -403,7 +403,7 @@ } private func cacheUserTables(recordTypes: [RecordType]) async throws { - try await userDatabase.write { db in + try await metadatabase.write { db in try RecordType .upsert { recordTypes.map { RecordType.Draft($0) } } .execute(db) @@ -432,7 +432,7 @@ $0.shared?.state.add(pendingRecordZoneChanges: changesByIsPrivate[false] ?? []) } - try await userDatabase.write { db in + try await metadatabase.write { db in try PendingRecordZoneChange.delete().execute(db) } @@ -550,7 +550,7 @@ guard isRunning else { Task { await withErrorReporting(.sqliteDataCloudKitFailure) { - try await userDatabase.write { db in + try await metadatabase.write { db in try PendingRecordZoneChange .insert { PendingRecordZoneChange(change) } .execute(db) @@ -587,7 +587,7 @@ guard isRunning else { Task { [changes] in await withErrorReporting(.sqliteDataCloudKitFailure) { - try await userDatabase.write { db in + try await metadatabase.write { db in try PendingRecordZoneChange .insert { changes.map { PendingRecordZoneChange($0) } } .execute(db) @@ -966,7 +966,7 @@ } await withErrorReporting(.sqliteDataCloudKitFailure) { - try await userDatabase.write { db in + try await metadatabase.write { db in try SyncMetadata .where { $0.recordName.in(deletedRecordNames) } .delete() diff --git a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift index df5da821..2c740a6a 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift @@ -139,7 +139,7 @@ try RemindersList.find(1).delete().execute(db) } - try await Task.sleep(for: .seconds(0.5)) + try await Task.sleep(for: .seconds(1)) try await syncEngine.start() try await syncEngine.processPendingRecordZoneChanges(scope: .private) From 06ec0835d73d2ab4d21fcfa752bb847feac59a2d Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 5 Sep 2025 16:58:34 -0500 Subject: [PATCH 3/8] wip --- .../SQLiteData/CloudKit/CloudKitSharing.swift | 2 +- Sources/SQLiteData/CloudKit/SyncEngine.swift | 63 ++++++++++--------- .../SyncEngineLifecycleTests.swift | 5 +- 3 files changed, 37 insertions(+), 33 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift index 83ea4054..33b4264d 100644 --- a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift +++ b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift @@ -159,7 +159,7 @@ saving: [sharedRecord, rootRecord], deleting: [] ) - try await metadatabase.write { db in + try await userDatabase.write { db in try SyncMetadata .where { $0.recordName.eq(recordName) } .update { $0.share = sharedRecord } diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 8124bf3d..fa4eaaef 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -403,7 +403,7 @@ } private func cacheUserTables(recordTypes: [RecordType]) async throws { - try await metadatabase.write { db in + try await userDatabase.write { db in try RecordType .upsert { recordTypes.map { RecordType.Draft($0) } } .execute(db) @@ -432,7 +432,7 @@ $0.shared?.state.add(pendingRecordZoneChanges: changesByIsPrivate[false] ?? []) } - try await metadatabase.write { db in + try await userDatabase.write { db in try PendingRecordZoneChange.delete().execute(db) } @@ -550,7 +550,7 @@ guard isRunning else { Task { await withErrorReporting(.sqliteDataCloudKitFailure) { - try await metadatabase.write { db in + try await userDatabase.write { db in try PendingRecordZoneChange .insert { PendingRecordZoneChange(change) } .execute(db) @@ -587,7 +587,7 @@ guard isRunning else { Task { [changes] in await withErrorReporting(.sqliteDataCloudKitFailure) { - try await metadatabase.write { db in + try await userDatabase.write { db in try PendingRecordZoneChange .insert { changes.map { PendingRecordZoneChange($0) } } .execute(db) @@ -671,7 +671,7 @@ case .accountChange(let changeType): await handleAccountChange(changeType: changeType, syncEngine: syncEngine) case .stateUpdate(let stateSerialization): - handleStateUpdate(stateSerialization: stateSerialization, syncEngine: syncEngine) + await handleStateUpdate(stateSerialization: stateSerialization, syncEngine: syncEngine) case .fetchedDatabaseChanges(let modifications, let deletions): await handleFetchedDatabaseChanges( modifications: modifications, @@ -966,7 +966,7 @@ } await withErrorReporting(.sqliteDataCloudKitFailure) { - try await metadatabase.write { db in + try await userDatabase.write { db in try SyncMetadata .where { $0.recordName.in(deletedRecordNames) } .delete() @@ -989,8 +989,11 @@ syncEngine.state.add(pendingDatabaseChanges: [.saveZone(defaultZone)]) await withErrorReporting(.sqliteDataCloudKitFailure) { try await userDatabase.write { db in - for table in self.tables { - try self.uploadRecordsToCloudKit(table: table, db: db) + // TODO: write a test for this + try Self.$_isSynchronizingChanges.withValue(false) { + for table in self.tables { + try self.uploadRecordsToCloudKit(table: table, db: db) + } } } } @@ -1006,9 +1009,9 @@ package func handleStateUpdate( stateSerialization: CKSyncEngine.State.Serialization, syncEngine: any SyncEngineProtocol - ) { - withErrorReporting(.sqliteDataCloudKitFailure) { - try userDatabase.write { db in + ) async { + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await userDatabase.write { db in try StateSerialization.upsert { StateSerialization.Draft( scope: syncEngine.database.databaseScope, @@ -1136,8 +1139,8 @@ open(table) } else if recordType == CKRecord.SystemType.share { for recordID in recordIDs { - withErrorReporting(.sqliteDataCloudKitFailure) { - try deleteShare(recordID: recordID) + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await deleteShare(recordID: recordID) } } } else { @@ -1249,9 +1252,9 @@ syncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) } for (failedRecord, error) in failedRecordSaves { - func clearServerRecord() { - withErrorReporting(.sqliteDataCloudKitFailure) { - try userDatabase.write { db in + func clearServerRecord() async { + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await userDatabase.write { db in try SyncMetadata .where { $0.recordName.eq(failedRecord.recordID.recordName) } .update { $0.setLastKnownServerRecord(nil) } @@ -1270,14 +1273,14 @@ let zone = CKRecordZone(zoneID: failedRecord.recordID.zoneID) newPendingDatabaseChanges.append(.saveZone(zone)) newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) - clearServerRecord() + await clearServerRecord() case .unknownItem: newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID)) - clearServerRecord() + await clearServerRecord() case .serverRejectedRequest: - clearServerRecord() + await clearServerRecord() case .referenceViolation: guard @@ -1286,8 +1289,8 @@ foreignKeysByTableName[table.tableName]?.count == 1, let foreignKey = foreignKeysByTableName[table.tableName]?.first else { continue } - func open(_: T.Type) throws { - try userDatabase.write { db in + func open(_: T.Type) async throws { + try await userDatabase.write { db in try Self.$_isSynchronizingChanges.withValue(false) { switch foreignKey.onDelete { case .cascade: @@ -1333,8 +1336,8 @@ } } } - withErrorReporting(.sqliteDataCloudKitFailure) { - try open(table) + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await open(table) } case .permissionFailure: @@ -1411,8 +1414,8 @@ } } - func deleteShare(recordID: CKRecord.ID) throws { - try userDatabase.write { db in + func deleteShare(recordID: CKRecord.ID) async throws { + try await userDatabase.write { db in let shareAndRecordName = try SyncMetadata .where(\.isShared) @@ -1522,9 +1525,9 @@ private func refreshLastKnownServerRecord(_ record: CKRecord) async { let metadata = await metadataFor(recordName: record.recordID.recordName) - func updateLastKnownServerRecord() { - withErrorReporting(.sqliteDataCloudKitFailure) { - try userDatabase.write { db in + func updateLastKnownServerRecord() async { + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await userDatabase.write { db in try SyncMetadata .where { $0.recordName.eq(record.recordID.recordName) } .update { $0.setLastKnownServerRecord(record) } @@ -1535,10 +1538,10 @@ if let lastKnownDate = metadata?.lastKnownServerRecord?.modificationDate { if let recordDate = record.modificationDate, lastKnownDate < recordDate { - updateLastKnownServerRecord() + await updateLastKnownServerRecord() } } else { - updateLastKnownServerRecord() + await updateLastKnownServerRecord() } } diff --git a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift index 2c740a6a..e9dd04f9 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift @@ -29,7 +29,7 @@ } } - try await Task.sleep(for: .seconds(0.5)) + try await Task.sleep(for: .seconds(1)) assertQuery(SyncMetadata.all, database: syncEngine.metadatabase) { """ @@ -330,6 +330,7 @@ try await userDatabase.userWrite { db in try RemindersList.find(1).delete().execute(db) } + try await Task.sleep(for: .seconds(1)) try await syncEngine.start() try await syncEngine.processPendingRecordZoneChanges(scope: .shared) @@ -437,7 +438,7 @@ Reminder(id: 1, title: "Get milk", remindersListID: 1) } } - try await Task.sleep(for: .seconds(0.1)) + try await Task.sleep(for: .seconds(1)) assertQuery(SyncMetadata.all, database: syncEngine.metadatabase) { """ From 0a265f10c59a17ada59f5eee74af70a03d9b007f Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 5 Sep 2025 17:09:33 -0500 Subject: [PATCH 4/8] wip --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index fa4eaaef..4a6e04aa 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -989,7 +989,6 @@ syncEngine.state.add(pendingDatabaseChanges: [.saveZone(defaultZone)]) await withErrorReporting(.sqliteDataCloudKitFailure) { try await userDatabase.write { db in - // TODO: write a test for this try Self.$_isSynchronizingChanges.withValue(false) { for table in self.tables { try self.uploadRecordsToCloudKit(table: table, db: db) From 666144a4e5dbcea72e1fc030d65bc8672bf2168a Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 5 Sep 2025 17:38:40 -0500 Subject: [PATCH 5/8] wip --- .../SQLiteData/CloudKit/CloudKitSharing.swift | 6 +- .../CloudKit/Internal/RecordType.swift | 2 +- Sources/SQLiteData/CloudKit/SyncEngine.swift | 17 +-- .../CloudKitTests/AccountLifecycleTests.swift | 24 ++-- .../MockCloudDatabaseTests.swift | 2 +- .../CloudKitTests/NewTableSyncTests.swift | 22 ++-- .../SyncEngineLifecycleTests.swift | 14 +-- .../CloudKitTests/UserlandTests.swift | 5 +- .../Internal/AccountStatusScope.swift | 30 ----- .../Internal/BaseCloudKitTests.swift | 44 ++++---- Tests/SQLiteDataTests/Internal/Schema.swift | 9 +- .../SQLiteDataTests/Internal/TestScopes.swift | 104 ++++++++++++++++++ 12 files changed, 166 insertions(+), 113 deletions(-) delete mode 100644 Tests/SQLiteDataTests/Internal/AccountStatusScope.swift create mode 100644 Tests/SQLiteDataTests/Internal/TestScopes.swift diff --git a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift index 33b4264d..3fe61b71 100644 --- a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift +++ b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift @@ -289,8 +289,10 @@ } public func cloudSharingControllerDidStopSharing(_ csc: UICloudSharingController) { - withErrorReporting(.sqliteDataCloudKitFailure) { - try syncEngine.deleteShare(recordID: share.recordID) + Task { + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await syncEngine.deleteShare(recordID: share.recordID) + } } didStopSharing() } diff --git a/Sources/SQLiteData/CloudKit/Internal/RecordType.swift b/Sources/SQLiteData/CloudKit/Internal/RecordType.swift index 5e9e8a93..e18e772c 100644 --- a/Sources/SQLiteData/CloudKit/Internal/RecordType.swift +++ b/Sources/SQLiteData/CloudKit/Internal/RecordType.swift @@ -1,7 +1,7 @@ import CustomDump @Table("sqlitedata_icloud_recordTypes") -package struct RecordType: Hashable { +package struct RecordType: Hashable, Sendable { @Column(primaryKey: true) package let tableName: String package let schema: String diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 4a6e04aa..644876a4 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -434,13 +434,11 @@ try await userDatabase.write { db in try PendingRecordZoneChange.delete().execute(db) - } - let newTableNames = currentRecordTypeByTableName.keys.filter { tableName in - previousRecordTypeByTableName[tableName] == nil - } + let newTableNames = currentRecordTypeByTableName.keys.filter { tableName in + previousRecordTypeByTableName[tableName] == nil + } - try await userDatabase.write { db in try Self.$_isSynchronizingChanges.withValue(false) { for tableName in newTableNames { try self.uploadRecordsToCloudKit(tableName: tableName, db: db) @@ -987,15 +985,6 @@ switch changeType { case .signIn: syncEngine.state.add(pendingDatabaseChanges: [.saveZone(defaultZone)]) - await withErrorReporting(.sqliteDataCloudKitFailure) { - try await userDatabase.write { db in - try Self.$_isSynchronizingChanges.withValue(false) { - for table in self.tables { - try self.uploadRecordsToCloudKit(table: table, db: db) - } - } - } - } case .signOut, .switchAccounts: withErrorReporting(.sqliteDataCloudKitFailure) { try deleteLocalData() diff --git a/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift index d88129b6..399e0e87 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift @@ -108,22 +108,20 @@ } @MainActor - @Suite(.accountStatus(.noAccount)) - final class SignedOutTests: BaseCloudKitTests, @unchecked Sendable { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - init() async throws { - try await super.init { userDatabase in - try await userDatabase.write { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - Reminder(id: 1, title: "Get milk", remindersListID: 1) - RemindersListPrivate(id: 1, remindersListID: 1) - UnsyncedModel(id: 1) - } + @Suite( + .accountStatus(.noAccount), + .prepareDatabase { userDatabase in + try await userDatabase.write { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + RemindersListPrivate(id: 1, remindersListID: 1) + UnsyncedModel(id: 1) } } } - + ) + final class SignedOutTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func doNotUploadExistingDataToCloudKitWhenSignedOut() { assertQuery(SyncMetadata.all, database: userDatabase.database) diff --git a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift index fd94e0f8..bddfd88b 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift @@ -12,7 +12,7 @@ @MainActor final class MockCloudDatabaseTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - init() async throws { + override init() async throws { try await super.init() let (saveZoneResults, _) = try syncEngine.private.database.modifyRecordZones( saving: [ diff --git a/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift index f24ce8e4..6c0868fb 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift @@ -10,21 +10,17 @@ extension BaseCloudKitTests { @MainActor - final class NewTableSyncTests: BaseCloudKitTests, @unchecked Sendable { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - init() async throws { - try await super.init( - setUpUserDatabase: { 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) - } - } + @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, *) diff --git a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift index e9dd04f9..fa09d9f5 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift @@ -413,17 +413,7 @@ """ } } - } - - @MainActor - final class SyncEngineLifecycleTests_ImmediatelyStopped: BaseCloudKitTests, @unchecked - Sendable - { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - init() async throws { - try await super.init(startImmediately: false) - } - + // * Start with sync engine off // * Write a few rows // * Verify sync metadata is created. @@ -431,7 +421,7 @@ // * Start sync engine // * Verify that data is sent to CloudKit database and cached locally. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func writeAndThenStart() async throws { + @Test(.startImmediately(false)) func writeAndThenStart() async throws { try await userDatabase.userWrite { db in try db.seed { RemindersList(id: 1, title: "Personal") diff --git a/Tests/SQLiteDataTests/CloudKitTests/UserlandTests.swift b/Tests/SQLiteDataTests/CloudKitTests/UserlandTests.swift index 6d90cbfb..af67d365 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/UserlandTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/UserlandTests.swift @@ -6,7 +6,10 @@ @Suite struct UserlandTests { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func basics() async throws { - let database = try SQLiteDataTests.database(containerIdentifier: "tests") + let database = try SQLiteDataTests.database( + containerIdentifier: "tests", + attachMetadatabase: false + ) let syncEngine = try SyncEngine( for: database, tables: ModelA.self, diff --git a/Tests/SQLiteDataTests/Internal/AccountStatusScope.swift b/Tests/SQLiteDataTests/Internal/AccountStatusScope.swift deleted file mode 100644 index a1683928..00000000 --- a/Tests/SQLiteDataTests/Internal/AccountStatusScope.swift +++ /dev/null @@ -1,30 +0,0 @@ -#if canImport(CloudKit) - import CloudKit - import Testing - - struct _AccountStatusScope: SuiteTrait, TestScoping, TestTrait { - @TaskLocal static var accountStatus = CKAccountStatus.available - - let accountStatus: CKAccountStatus - init(_ accountStatus: CKAccountStatus = .available) { - self.accountStatus = accountStatus - } - - func provideScope( - for test: Test, - testCase: Test.Case?, - performing function: @Sendable () async throws -> Void - ) async throws { - try await Self.$accountStatus.withValue(accountStatus) { - try await function() - } - } - } - - extension Trait where Self == _AccountStatusScope { - static var accountStatus: Self { .init() } - static func accountStatus(_ accountStatus: CKAccountStatus) -> Self { - .init(accountStatus) - } - } -#endif diff --git a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift index 144f7871..e48d76c2 100644 --- a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift @@ -31,21 +31,20 @@ class BaseCloudKitTests: @unchecked Sendable { } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - init( - accountStatus: CKAccountStatus = _AccountStatusScope.accountStatus, - setUpUserDatabase: @Sendable (UserDatabase) async throws -> Void = { _ in }, - startImmediately: Bool = true - ) async throws { + init() async throws { let testContainerIdentifier = "iCloud.co.pointfree.Testing.\(UUID())" self.userDatabase = UserDatabase( - database: try SQLiteDataTests.database(containerIdentifier: testContainerIdentifier) + database: try SQLiteDataTests.database( + containerIdentifier: testContainerIdentifier, + attachMetadatabase: _AttachMetadatabaseTrait.attachMetadatabase + ) ) - try await setUpUserDatabase(userDatabase) + try await _PrepareDatabaseTrait.prepareDatabase(userDatabase) let privateDatabase = MockCloudDatabase(databaseScope: .private) let sharedDatabase = MockCloudDatabase(databaseScope: .shared) let container = MockCloudContainer( - accountStatus: accountStatus, + accountStatus: _AccountStatusScope.accountStatus, containerIdentifier: testContainerIdentifier, privateCloudDatabase: privateDatabase, sharedCloudDatabase: sharedDatabase @@ -72,9 +71,12 @@ class BaseCloudKitTests: @unchecked Sendable { privateTables: [ RemindersListPrivate.self ], - startImmediately: startImmediately + startImmediately: _StartImmediatelyTrait.startImmediately ) - if startImmediately, accountStatus == .available { + if + _StartImmediatelyTrait.startImmediately, + _AccountStatusScope.accountStatus == .available + { await syncEngine.handleEvent( .accountChange(changeType: .signIn(currentUser: currentUserRecordID)), syncEngine: syncEngine.private @@ -115,20 +117,14 @@ class BaseCloudKitTests: @unchecked Sendable { deinit { if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { - guard - let shared = syncEngine.syncEngines.shared as? MockSyncEngine, - let `private` = syncEngine.syncEngines.private as? MockSyncEngine - else { - return - } - shared.assertFetchChangesScopes([]) - shared.state.assertPendingDatabaseChanges([]) - shared.state.assertPendingRecordZoneChanges([]) - shared.assertAcceptedShareMetadata([]) - `private`.assertFetchChangesScopes([]) - `private`.state.assertPendingDatabaseChanges([]) - `private`.state.assertPendingRecordZoneChanges([]) - `private`.assertAcceptedShareMetadata([]) + syncEngine.shared.assertFetchChangesScopes([]) + syncEngine.shared.state.assertPendingDatabaseChanges([]) + syncEngine.shared.state.assertPendingRecordZoneChanges([]) + syncEngine.shared.assertAcceptedShareMetadata([]) + syncEngine.private.assertFetchChangesScopes([]) + syncEngine.private.state.assertPendingDatabaseChanges([]) + syncEngine.private.state.assertPendingRecordZoneChanges([]) + syncEngine.private.assertAcceptedShareMetadata([]) try! syncEngine.metadatabase.read { db in try #expect(UnsyncedRecordID.count().fetchOne(db) == 0) } diff --git a/Tests/SQLiteDataTests/Internal/Schema.swift b/Tests/SQLiteDataTests/Internal/Schema.swift index fdc4fe5d..92614e21 100644 --- a/Tests/SQLiteDataTests/Internal/Schema.swift +++ b/Tests/SQLiteDataTests/Internal/Schema.swift @@ -73,10 +73,15 @@ import SQLiteData } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -func database(containerIdentifier: String) throws -> DatabasePool { +func database( + containerIdentifier: String, + attachMetadatabase: Bool +) throws -> DatabasePool { var configuration = Configuration() configuration.prepareDatabase { db in - //try db.attachMetadatabase(containerIdentifier: containerIdentifier) + if attachMetadatabase { + try db.attachMetadatabase(containerIdentifier: containerIdentifier) + } } let url = URL.temporaryDirectory.appending(path: "\(UUID().uuidString).sqlite") let database = try DatabasePool(path: url.path(), configuration: configuration) diff --git a/Tests/SQLiteDataTests/Internal/TestScopes.swift b/Tests/SQLiteDataTests/Internal/TestScopes.swift new file mode 100644 index 00000000..42ab482d --- /dev/null +++ b/Tests/SQLiteDataTests/Internal/TestScopes.swift @@ -0,0 +1,104 @@ +#if canImport(CloudKit) + import CloudKit + import Testing + import SQLiteData + + struct _PrepareDatabaseTrait: SuiteTrait, TestScoping, TestTrait { + @TaskLocal static var prepareDatabase: @Sendable (UserDatabase) async throws -> Void = + { _ in } + let prepareDatabase: @Sendable (UserDatabase) async throws -> Void + init(prepareDatabase: @escaping @Sendable (UserDatabase) async throws -> Void = { _ in }) { + self.prepareDatabase = prepareDatabase + } + func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: () async throws -> Void + ) async throws { + try await Self.$prepareDatabase.withValue(prepareDatabase) { + try await function() + } + } + } + + extension Trait where Self == _PrepareDatabaseTrait { + static func prepareDatabase( + _ prepareDatabase: @escaping @Sendable (UserDatabase) async throws -> Void + ) -> Self { + .init(prepareDatabase: prepareDatabase) + } + } + + struct _StartImmediatelyTrait: SuiteTrait, TestScoping, TestTrait { + @TaskLocal static var startImmediately = true + let startImmediately: Bool + init(startImmediately: Bool = true) { + self.startImmediately = startImmediately + } + func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: () async throws -> Void + ) async throws { + try await Self.$startImmediately.withValue(startImmediately) { + try await function() + } + } + } + + extension Trait where Self == _StartImmediatelyTrait { + static func startImmediately(_ startImmediately: Bool) -> Self { + .init(startImmediately: startImmediately) + } + } + + struct _AttachMetadatabaseTrait: SuiteTrait, TestScoping, TestTrait { + @TaskLocal static var attachMetadatabase = false + let attachMetadatabase: Bool + init(attachMetadatabase: Bool = false) { + self.attachMetadatabase = attachMetadatabase + } + func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: () async throws -> Void + ) async throws { + try await Self.$attachMetadatabase.withValue(attachMetadatabase) { + try await function() + } + } + } + + extension Trait where Self == _AttachMetadatabaseTrait { + static var attachMetadatabase: Self { .init(attachMetadatabase: true) } + static func attachMetadatabase(_ attachMetadatabase: Bool) -> Self { + .init(attachMetadatabase: attachMetadatabase) + } + } + + struct _AccountStatusScope: SuiteTrait, TestScoping, TestTrait { + @TaskLocal static var accountStatus = CKAccountStatus.available + + let accountStatus: CKAccountStatus + init(_ accountStatus: CKAccountStatus = .available) { + self.accountStatus = accountStatus + } + + func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: @Sendable () async throws -> Void + ) async throws { + try await Self.$accountStatus.withValue(accountStatus) { + try await function() + } + } + } + + extension Trait where Self == _AccountStatusScope { + static var accountStatus: Self { .init() } + static func accountStatus(_ accountStatus: CKAccountStatus) -> Self { + .init(accountStatus) + } + } +#endif From 6c1f92d17c33090f2000f37a3738ef5eeeb6cbda Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 5 Sep 2025 17:39:12 -0500 Subject: [PATCH 6/8] wip --- Sources/SQLiteData/CloudKit/Internal/RecordType.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SQLiteData/CloudKit/Internal/RecordType.swift b/Sources/SQLiteData/CloudKit/Internal/RecordType.swift index e18e772c..5e9e8a93 100644 --- a/Sources/SQLiteData/CloudKit/Internal/RecordType.swift +++ b/Sources/SQLiteData/CloudKit/Internal/RecordType.swift @@ -1,7 +1,7 @@ import CustomDump @Table("sqlitedata_icloud_recordTypes") -package struct RecordType: Hashable, Sendable { +package struct RecordType: Hashable { @Column(primaryKey: true) package let tableName: String package let schema: String From 7b4894b4480e378a5f278c1d72347a11356b85a8 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 5 Sep 2025 17:39:50 -0500 Subject: [PATCH 7/8] format --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 2 +- .../Documentation.docc/Articles/CloudKit.md | 18 ++++---- .../SyncEngineLifecycleTests.swift | 42 +++++++++---------- .../Internal/BaseCloudKitTests.swift | 3 +- 4 files changed, 32 insertions(+), 33 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 644876a4..af81fb75 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -247,7 +247,7 @@ !hasSchemaChanges, """ A previously run migration has been removed or edited. - + Metadatabase migrations must not be modified after release. """ ) diff --git a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md index 3ecfac86..7b4ab1b8 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md @@ -658,13 +658,13 @@ And in preivews you can use it like so: ### Convert Int primary keys to UUID The most important step for migrating an existing SQLite database to be compatible with CloudKit -synchronization is converting any `Int` primary keys in your tables to UUID, or some other +synchronization is converting any `Int` primary keys in your tables to UUID, or some other globally unique identifier. This can be done in a new migration that is registered when provisioning -your database, but it does take a few queries to accomplish because SQLite does not support -changing the definition of an existing column. +your database, but it does take a few queries to accomplish because SQLite does not support +changing the definition of an existing column. -The steps are roughly: 1) create a table with the new schema, 2) copy data over from old -table to new table and convert integer IDs to UUIDs, 3) drop the old table, and finally 4) rename +The steps are roughly: 1) create a table with the new schema, 2) copy data over from old +table to new table and convert integer IDs to UUIDs, 3) drop the old table, and finally 4) rename the new table to have the same name as the old table. ```swift @@ -683,7 +683,7 @@ migrator.registerMigration("Convert 'remindersLists' table primary key to UUID") try #sql(""" INSERT INTO "new_remindersLists" ( - "id", + "id", -- all other columns from 'remindersLists' table ) SELECT @@ -708,7 +708,7 @@ migrator.registerMigration("Convert 'remindersLists' table primary key to UUID") } ``` -This will need to be done for every table that uses an integer for its primary key. Further, +This will need to be done for every table that uses an integer for its primary key. Further, for tables with foreign keys, you will need to adapt step 1 to change the types of those columns to TEXT and will need to perform the integer-to-UUID conversion for those columns in step 2: @@ -730,7 +730,7 @@ migrator.registerMigration("Convert 'reminders' table primary key to UUID") { db try #sql(""" INSERT INTO "new_reminders" ( - "id", + "id", "remindersListID", -- all other columns from 'reminders' table ) @@ -866,7 +866,7 @@ from CloudKit. ### Developing in the simulator It is possible to develop your app with CloudKit synchronization using the iOS simulator, but -you must be aware that simulators do not support push notifications, and so changes do not +you must be aware that simulators do not support push notifications, and so changes do not synchronize from CloudKit to simulator automatically. Sometimes you can simply close and re-open the app to have the simulator sync with CloudKit, but the most certain way to force synchronization is to kill the app and relaunch it fresh. diff --git a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift index fa09d9f5..24d6f633 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift @@ -208,27 +208,27 @@ try await syncEngine.processPendingRecordZoneChanges(scope: .private) 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: [] - ) + """ + 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: [] ) - """ + ) + """ } assertQuery(PendingRecordZoneChange.all, database: syncEngine.metadatabase) } @@ -413,7 +413,7 @@ """ } } - + // * Start with sync engine off // * Write a few rows // * Verify sync metadata is created. diff --git a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift index e48d76c2..659acd77 100644 --- a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift @@ -73,8 +73,7 @@ class BaseCloudKitTests: @unchecked Sendable { ], startImmediately: _StartImmediatelyTrait.startImmediately ) - if - _StartImmediatelyTrait.startImmediately, + if _StartImmediatelyTrait.startImmediately, _AccountStatusScope.accountStatus == .available { await syncEngine.handleEvent( From 4b96762b3c1cd103b6f66622d836c390ca14f737 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 5 Sep 2025 17:41:28 -0500 Subject: [PATCH 8/8] wip --- .../CloudKitTests/AccountLifecycleTests.swift | 35 +++++++++---------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift index 399e0e87..e547a132 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift @@ -107,8 +107,8 @@ } } - @MainActor - @Suite( + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test( .accountStatus(.noAccount), .prepareDatabase { userDatabase in try await userDatabase.write { db in @@ -121,24 +121,21 @@ } } ) - final class SignedOutTests: BaseCloudKitTests, @unchecked Sendable { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func doNotUploadExistingDataToCloudKitWhenSignedOut() { - assertQuery(SyncMetadata.all, database: userDatabase.database) - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) + func doNotUploadExistingDataToCloudKitWhenSignedOut() { + assertQuery(SyncMetadata.all, database: userDatabase.database) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] ) - """ - } + ) + """ } } }