From d4d1c7863ab97f793ab606f5c3bc17a5790d29cd Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 7 Sep 2025 20:21:18 -0500 Subject: [PATCH 1/7] Upload unknown records to CloudKit on sign in. --- .../CloudKit/Internal/Metadatabase.swift | 8 + Sources/SQLiteData/CloudKit/SyncEngine.swift | 58 +++- .../SQLiteData/CloudKit/SyncMetadata.swift | 4 + Tests/SQLiteDataTests/AssertQueryTests.swift | 286 +++++++++--------- .../CloudKitTests/AccountLifecycleTests.swift | 14 + .../CloudKitTests/NewTableSyncTests.swift | 154 +++++----- .../SQLiteDataTests/CustomFunctionTests.swift | 62 ++-- 7 files changed, 324 insertions(+), 262 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift b/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift index e1912ad7..38dd6289 100644 --- a/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift @@ -61,6 +61,7 @@ "lastKnownServerRecord" BLOB, "_lastKnownServerRecordAllFields" BLOB, "share" BLOB, + "hasLastKnownServerRecord" INTEGER NOT NULL AS ("lastKnownServerRecord" IS NOT NULL), "isShared" INTEGER NOT NULL AS ("share" IS NOT NULL), "userModificationDate" TEXT NOT NULL DEFAULT (\($datetime())), "_isDeleted" INTEGER NOT NULL DEFAULT 0, @@ -85,6 +86,13 @@ """ ) .execute(db) + try #sql( + """ + CREATE INDEX IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_metadata_hasLastKnownServerRecord" + ON "\(raw: .sqliteDataCloudKitSchemaName)_metadata"("hasLastKnownServerRecord") + """ + ) + .execute(db) try #sql( """ CREATE TABLE IF NOT EXISTS "\(raw: .sqliteDataCloudKitSchemaName)_recordTypes" ( diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 06450fc1..8317a262 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -439,6 +439,23 @@ previousRecordTypeByTableName: [String: RecordType], currentRecordTypeByTableName: [String: RecordType] ) async throws { + try await enqueueLocallyPendingChanges() + try await userDatabase.write { db in + try PendingRecordZoneChange.delete().execute(db) + + let newTableNames = currentRecordTypeByTableName.keys.filter { tableName in + previousRecordTypeByTableName[tableName] == nil + } + + try Self.$_isSynchronizingChanges.withValue(false) { + for tableName in newTableNames { + try self.uploadRecordsToCloudKit(tableName: tableName, db: db) + } + } + } + } + + private func enqueueLocallyPendingChanges() async throws { let pendingRecordZoneChanges = try await metadatabase.read { db in try PendingRecordZoneChange .select(\.pendingRecordZoneChange) @@ -456,17 +473,31 @@ $0.private?.state.add(pendingRecordZoneChanges: changesByIsPrivate[true] ?? []) $0.shared?.state.add(pendingRecordZoneChanges: changesByIsPrivate[false] ?? []) } + } + private func enqueueUnknownRecordsForCloudKit() async throws { try await userDatabase.write { db in - try PendingRecordZoneChange.delete().execute(db) - - let newTableNames = currentRecordTypeByTableName.keys.filter { tableName in - previousRecordTypeByTableName[tableName] == nil - } - - try Self.$_isSynchronizingChanges.withValue(false) { - for tableName in newTableNames { - try self.uploadRecordsToCloudKit(tableName: tableName, db: db) + try SyncEngine.$_isSynchronizingChanges.withValue(false) { + let recordPrimaryKeysAndRecordTypes = + try SyncMetadata + .where { !$0.hasLastKnownServerRecord } + .select { ($0.recordPrimaryKey, $0.recordType) } + .fetchAll(db) + let recordPrimaryKeysByRecordType = Dictionary( + grouping: recordPrimaryKeysAndRecordTypes, + by: { _, recordType in recordType } + ) + .mapValues { $0.map(\.0) } + for (recordType, recordPrimaryKeys) in recordPrimaryKeysByRecordType { + guard let table = tablesByName[recordType] + else { continue } + func open(_: T.Type) throws { + try T + .where { #sql("\($0.primaryKey)").in(recordPrimaryKeys) } + .update { $0.primaryKey = $0.primaryKey } + .execute(db) + } + try open(table) } } } @@ -1010,6 +1041,9 @@ switch changeType { case .signIn: syncEngine.state.add(pendingDatabaseChanges: [.saveZone(defaultZone)]) + await withErrorReporting { + try await enqueueUnknownRecordsForCloudKit() + } case .signOut, .switchAccounts: withErrorReporting(.sqliteDataCloudKitFailure) { try deleteLocalData() @@ -1069,6 +1103,7 @@ func deleteRecords(in zoneID: CKRecordZone.ID, db: Database) throws { let recordTypes = Set( try SyncMetadata + .where(\.hasLastKnownServerRecord) .select(\.lastKnownServerRecord) .fetchAll(db) .compactMap { $0?.recordID.zoneID == zoneID ? $0?.recordType : nil } @@ -1088,6 +1123,7 @@ func uploadRecords(in zoneID: CKRecordZone.ID, db: Database) throws { let recordTypes = Set( try SyncMetadata + .where(\.hasLastKnownServerRecord) .select(\.lastKnownServerRecord) .fetchAll(db) .compactMap { $0?.recordID.zoneID == zoneID ? $0?.recordType : nil } @@ -1875,8 +1911,8 @@ throw SyncEngine.SchemaError( reason: .uniquenessConstraint, debugDescription: """ - Uniqueness constraints are not supported for synchronized tables. - """ + Uniqueness constraints are not supported for synchronized tables. + """ ) } } diff --git a/Sources/SQLiteData/CloudKit/SyncMetadata.swift b/Sources/SQLiteData/CloudKit/SyncMetadata.swift index 316aa9fc..54ee721c 100644 --- a/Sources/SQLiteData/CloudKit/SyncMetadata.swift +++ b/Sources/SQLiteData/CloudKit/SyncMetadata.swift @@ -64,6 +64,9 @@ /// next batch of pending changes is processed. public var _isDeleted = false + @Column(generated: .virtual) + public let hasLastKnownServerRecord: Bool + @Column(generated: .virtual) public let isShared: Bool @@ -125,6 +128,7 @@ self.lastKnownServerRecord = lastKnownServerRecord self._lastKnownServerRecordAllFields = _lastKnownServerRecordAllFields self.share = share + self.hasLastKnownServerRecord = lastKnownServerRecord != nil self.isShared = share != nil self.userModificationDate = userModificationDate } diff --git a/Tests/SQLiteDataTests/AssertQueryTests.swift b/Tests/SQLiteDataTests/AssertQueryTests.swift index 17179471..a2bf1f97 100644 --- a/Tests/SQLiteDataTests/AssertQueryTests.swift +++ b/Tests/SQLiteDataTests/AssertQueryTests.swift @@ -1,143 +1,143 @@ -import DependenciesTestSupport -import Foundation -import SQLiteData -import SQLiteDataTestSupport -import SnapshotTesting -import Testing - -@Suite( - .dependency(\.defaultDatabase, try .database()), - .snapshots(record: .failed), -) -struct AssertQueryTests { - @Test func assertQueryBasic() throws { - assertQuery( - Record.all.select(\.id) - ) { - """ - ┌───┐ - │ 1 │ - │ 2 │ - │ 3 │ - └───┘ - """ - } - } - @Test func assertQueryRecord() throws { - assertQuery( - Record.where { $0.id == 1 } - ) { - """ - ┌────────────────────────────────────────┐ - │ Record( │ - │ id: 1, │ - │ date: Date(1970-01-01T00:00:42.000Z) │ - │ ) │ - └────────────────────────────────────────┘ - """ - } - } - @Test func assertQueryBasicUpdate() throws { - assertQuery( - Record.all - .update { $0.date = Date(timeIntervalSince1970: 45) } - .returning { ($0.id, $0.date) } - ) { - """ - ┌───┬────────────────────────────────┐ - │ 1 │ Date(1970-01-01T00:00:45.000Z) │ - │ 2 │ Date(1970-01-01T00:00:45.000Z) │ - │ 3 │ Date(1970-01-01T00:00:45.000Z) │ - └───┴────────────────────────────────┘ - """ - } - } - @Test func assertQueryRecordUpdate() throws { - assertQuery( - Record - .where { $0.id == 1 } - .update { $0.date = Date(timeIntervalSince1970: 45) } - .returning(\.self) - ) { - """ - ┌────────────────────────────────────────┐ - │ Record( │ - │ id: 1, │ - │ date: Date(1970-01-01T00:00:45.000Z) │ - │ ) │ - └────────────────────────────────────────┘ - """ - } - } - #if DEBUG - @Test func assertQueryBasicIncludeSQL() throws { - assertQuery( - includeSQL: true, - Record.all.select(\.id) - ) { - """ - SELECT "records"."id" - FROM "records" - """ - } results: { - """ - ┌───┐ - │ 1 │ - │ 2 │ - │ 3 │ - └───┘ - """ - } - } - #endif - #if DEBUG - @Test func assertQueryRecordIncludeSQL() throws { - assertQuery( - includeSQL: true, - Record.where { $0.id == 1 } - ) { - """ - SELECT "records"."id", "records"."date" - FROM "records" - WHERE ("records"."id" = 1) - """ - } results: { - """ - ┌────────────────────────────────────────┐ - │ Record( │ - │ id: 1, │ - │ date: Date(1970-01-01T00:00:42.000Z) │ - │ ) │ - └────────────────────────────────────────┘ - """ - } - } - #endif -} - -@Table -private struct Record: Equatable { - let id: Int - @Column(as: Date.UnixTimeRepresentation.self) - var date = Date(timeIntervalSince1970: 42) -} -extension DatabaseWriter where Self == DatabaseQueue { - fileprivate static func database() throws -> DatabaseQueue { - let database = try DatabaseQueue() - try database.write { db in - try #sql( - """ - CREATE TABLE "records" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT, - "date" INTEGER NOT NULL DEFAULT 42 - ) - """ - ) - .execute(db) - for _ in 1...3 { - _ = try Record.insert { Record.Draft() }.execute(db) - } - } - return database - } -} +//import DependenciesTestSupport +//import Foundation +//import SQLiteData +//import SQLiteDataTestSupport +//import SnapshotTesting +//import Testing +// +//@Suite( +// .dependency(\.defaultDatabase, try .database()), +// .snapshots(record: .failed), +//) +//struct AssertQueryTests { +// @Test func assertQueryBasic() throws { +// assertQuery( +// Record.all.select(\.id) +// ) { +// """ +// ┌───┐ +// │ 1 │ +// │ 2 │ +// │ 3 │ +// └───┘ +// """ +// } +// } +// @Test func assertQueryRecord() throws { +// assertQuery( +// Record.where { $0.id == 1 } +// ) { +// """ +// ┌────────────────────────────────────────┐ +// │ Record( │ +// │ id: 1, │ +// │ date: Date(1970-01-01T00:00:42.000Z) │ +// │ ) │ +// └────────────────────────────────────────┘ +// """ +// } +// } +// @Test func assertQueryBasicUpdate() throws { +// assertQuery( +// Record.all +// .update { $0.date = Date(timeIntervalSince1970: 45) } +// .returning { ($0.id, $0.date) } +// ) { +// """ +// ┌───┬────────────────────────────────┐ +// │ 1 │ Date(1970-01-01T00:00:45.000Z) │ +// │ 2 │ Date(1970-01-01T00:00:45.000Z) │ +// │ 3 │ Date(1970-01-01T00:00:45.000Z) │ +// └───┴────────────────────────────────┘ +// """ +// } +// } +// @Test func assertQueryRecordUpdate() throws { +// assertQuery( +// Record +// .where { $0.id == 1 } +// .update { $0.date = Date(timeIntervalSince1970: 45) } +// .returning(\.self) +// ) { +// """ +// ┌────────────────────────────────────────┐ +// │ Record( │ +// │ id: 1, │ +// │ date: Date(1970-01-01T00:00:45.000Z) │ +// │ ) │ +// └────────────────────────────────────────┘ +// """ +// } +// } +// #if DEBUG +// @Test func assertQueryBasicIncludeSQL() throws { +// assertQuery( +// includeSQL: true, +// Record.all.select(\.id) +// ) { +// """ +// SELECT "records"."id" +// FROM "records" +// """ +// } results: { +// """ +// ┌───┐ +// │ 1 │ +// │ 2 │ +// │ 3 │ +// └───┘ +// """ +// } +// } +// #endif +// #if DEBUG +// @Test func assertQueryRecordIncludeSQL() throws { +// assertQuery( +// includeSQL: true, +// Record.where { $0.id == 1 } +// ) { +// """ +// SELECT "records"."id", "records"."date" +// FROM "records" +// WHERE ("records"."id" = 1) +// """ +// } results: { +// """ +// ┌────────────────────────────────────────┐ +// │ Record( │ +// │ id: 1, │ +// │ date: Date(1970-01-01T00:00:42.000Z) │ +// │ ) │ +// └────────────────────────────────────────┘ +// """ +// } +// } +// #endif +//} +// +//@Table +//private struct Record: Equatable { +// let id: Int +// @Column(as: Date.UnixTimeRepresentation.self) +// var date = Date(timeIntervalSince1970: 42) +//} +//extension DatabaseWriter where Self == DatabaseQueue { +// fileprivate static func database() throws -> DatabaseQueue { +// let database = try DatabaseQueue() +// try database.write { db in +// try #sql( +// """ +// CREATE TABLE "records" ( +// "id" INTEGER PRIMARY KEY AUTOINCREMENT, +// "date" INTEGER NOT NULL DEFAULT 42 +// ) +// """ +// ) +// .execute(db) +// for _ in 1...3 { +// _ = try Record.insert { Record.Draft() }.execute(db) +// } +// } +// return database +// } +//} diff --git a/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift index e547a132..d9fab09a 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift @@ -57,6 +57,20 @@ try await syncEngine.metadatabase.read { db in try #expect(SyncMetadata.count().fetchOne(db) == 3) } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } await signIn() diff --git a/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift index 6c0868fb..30dab8ff 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift @@ -1,77 +1,77 @@ -#if canImport(CloudKit) - import CloudKit - import CustomDump - import SQLiteDataTestSupport - import Foundation - import InlineSnapshotTesting - import SQLiteData - import SnapshotTestingCustomDump - import Testing - - extension BaseCloudKitTests { - @MainActor - @Suite( - .prepareDatabase { userDatabase in - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - Reminder(id: 1, title: "Write blog post", remindersListID: 1) - } - } - } - ) - final class NewTableSyncTests: BaseCloudKitTests, @unchecked Sendable { - // * Create records before sync engine starts - // => Records are sent to CloudKit - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func initialSync() async throws { - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - id: 1, - isCompleted: 0, - remindersListID: 1, - title: "Write blog post" - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Personal" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - - assertQuery( - SyncMetadata.order(by: \.recordName).select(\.recordName), - database: syncEngine.metadatabase - ) { - """ - ┌────────────────────┐ - │ "1:reminders" │ - │ "1:remindersLists" │ - └────────────────────┘ - """ - } - } - } - } -#endif +//#if canImport(CloudKit) +// import CloudKit +// import CustomDump +// import SQLiteDataTestSupport +// import Foundation +// import InlineSnapshotTesting +// import SQLiteData +// import SnapshotTestingCustomDump +// import Testing +// +// extension BaseCloudKitTests { +// @MainActor +// @Suite( +// .prepareDatabase { userDatabase in +// try await userDatabase.userWrite { db in +// try db.seed { +// RemindersList(id: 1, title: "Personal") +// Reminder(id: 1, title: "Write blog post", remindersListID: 1) +// } +// } +// } +// ) +// final class NewTableSyncTests: BaseCloudKitTests, @unchecked Sendable { +// // * Create records before sync engine starts +// // => Records are sent to CloudKit +// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +// @Test func initialSync() async throws { +// try await syncEngine.processPendingRecordZoneChanges(scope: .private) +// assertInlineSnapshot(of: container, as: .customDump) { +// """ +// MockCloudContainer( +// privateCloudDatabase: MockCloudDatabase( +// databaseScope: .private, +// storage: [ +// [0]: CKRecord( +// recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), +// recordType: "reminders", +// parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), +// share: nil, +// id: 1, +// isCompleted: 0, +// remindersListID: 1, +// title: "Write blog post" +// ), +// [1]: CKRecord( +// recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), +// recordType: "remindersLists", +// parent: nil, +// share: nil, +// id: 1, +// title: "Personal" +// ) +// ] +// ), +// sharedCloudDatabase: MockCloudDatabase( +// databaseScope: .shared, +// storage: [] +// ) +// ) +// """ +// } +// +// assertQuery( +// SyncMetadata.order(by: \.recordName).select(\.recordName), +// database: syncEngine.metadatabase +// ) { +// """ +// ┌────────────────────┐ +// │ "1:reminders" │ +// │ "1:remindersLists" │ +// └────────────────────┘ +// """ +// } +// } +// } +// } +//#endif diff --git a/Tests/SQLiteDataTests/CustomFunctionTests.swift b/Tests/SQLiteDataTests/CustomFunctionTests.swift index 60eda159..abf60e9d 100644 --- a/Tests/SQLiteDataTests/CustomFunctionTests.swift +++ b/Tests/SQLiteDataTests/CustomFunctionTests.swift @@ -1,31 +1,31 @@ -import Foundation -import SQLiteData -import Testing - -@Suite struct CustomFunctionsTests { - @DatabaseFunction func customDate() -> Date { - Date(timeIntervalSinceReferenceDate: 0) - } - - @Test func basics() throws { - var configuration = Configuration() - configuration.prepareDatabase { db in - db.add(function: $customDate) - } - let database = try DatabaseQueue(configuration: configuration) - let date = try database.read { db in - try Values($customDate()) - .fetchOne(db) - } - #expect(date?.timeIntervalSinceReferenceDate == 0) - - try database.write { db in - db.remove(function: $customDate) - } - #expect(throws: (any Error).self) { - try database.read { db in - _ = try Values($customDate()).fetchOne(db) - } - } - } -} +//import Foundation +//import SQLiteData +//import Testing +// +//@Suite struct CustomFunctionsTests { +// @DatabaseFunction func customDate() -> Date { +// Date(timeIntervalSinceReferenceDate: 0) +// } +// +// @Test func basics() throws { +// var configuration = Configuration() +// configuration.prepareDatabase { db in +// db.add(function: $customDate) +// } +// let database = try DatabaseQueue(configuration: configuration) +// let date = try database.read { db in +// try Values($customDate()) +// .fetchOne(db) +// } +// #expect(date?.timeIntervalSinceReferenceDate == 0) +// +// try database.write { db in +// db.remove(function: $customDate) +// } +// #expect(throws: (any Error).self) { +// try database.read { db in +// _ = try Values($customDate()).fetchOne(db) +// } +// } +// } +//} From d31a0425931aa2c56e7efa073160402d3dc853c5 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 7 Sep 2025 20:51:33 -0500 Subject: [PATCH 2/7] fix tests --- .../CloudKitTests/FetchRecordZoneChangesTests.swift | 3 +++ .../SQLiteDataTests/CloudKitTests/MetadataTests.swift | 10 ++++++++++ Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift | 1 + .../CloudKitTests/SyncEngineLifecycleTests.swift | 6 ++++++ 4 files changed, 20 insertions(+) diff --git a/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift index 1e6beb65..7bd6a539 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -539,6 +539,7 @@ │ ), │ │ share: nil, │ │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ │ isShared: false, │ │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ │ ) │ @@ -618,6 +619,7 @@ │ ), │ │ share: nil, │ │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ │ isShared: false, │ │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ │ ) │ @@ -684,6 +686,7 @@ │ ), │ │ share: nil, │ │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ │ isShared: false, │ │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ │ ) │ diff --git a/Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift index 6559843e..d33e4d74 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift @@ -232,6 +232,7 @@ │ ), │ │ share: nil, │ │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ │ isShared: false, │ │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ │ ) │ @@ -261,6 +262,7 @@ │ ), │ │ share: nil, │ │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ │ isShared: false, │ │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ │ ) │ @@ -288,6 +290,7 @@ │ ), │ │ share: nil, │ │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ │ isShared: false, │ │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ │ ) │ @@ -316,6 +319,7 @@ │ ), │ │ share: nil, │ │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ │ isShared: false, │ │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ │ ) │ @@ -345,6 +349,7 @@ │ ), │ │ share: nil, │ │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ │ isShared: false, │ │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ │ ) │ @@ -372,6 +377,7 @@ │ ), │ │ share: nil, │ │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ │ isShared: false, │ │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ │ ) │ @@ -400,6 +406,7 @@ │ ), │ │ share: nil, │ │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ │ isShared: false, │ │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ │ ) │ @@ -429,6 +436,7 @@ │ ), │ │ share: nil, │ │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ │ isShared: false, │ │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ │ ) │ @@ -455,6 +463,7 @@ │ ), │ │ share: nil, │ │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ │ isShared: false, │ │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ │ ) │ @@ -481,6 +490,7 @@ │ ), │ │ share: nil, │ │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ │ isShared: false, │ │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ │ ) │ diff --git a/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift index ed2856e5..4b31cc15 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift @@ -316,6 +316,7 @@ │ share: nil │ │ ), │ │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ │ isShared: true, │ │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ │ ) │ diff --git a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift index 24d6f633..8ed166d6 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift @@ -45,6 +45,7 @@ │ _lastKnownServerRecordAllFields: nil, │ │ share: nil, │ │ _isDeleted: false, │ + │ hasLastKnownServerRecord: false, │ │ isShared: false, │ │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ │ ) │ @@ -60,6 +61,7 @@ │ _lastKnownServerRecordAllFields: nil, │ │ share: nil, │ │ _isDeleted: false, │ + │ hasLastKnownServerRecord: false, │ │ isShared: false, │ │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ │ ) │ @@ -444,6 +446,7 @@ │ _lastKnownServerRecordAllFields: nil, │ │ share: nil, │ │ _isDeleted: false, │ + │ hasLastKnownServerRecord: false, │ │ isShared: false, │ │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ │ ) │ @@ -459,6 +462,7 @@ │ _lastKnownServerRecordAllFields: nil, │ │ share: nil, │ │ _isDeleted: false, │ + │ hasLastKnownServerRecord: false, │ │ isShared: false, │ │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ │ ) │ @@ -511,6 +515,7 @@ │ ), │ │ share: nil, │ │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ │ isShared: false, │ │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ │ ) │ @@ -540,6 +545,7 @@ │ ), │ │ share: nil, │ │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ │ isShared: false, │ │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ │ ) │ From f0b63f891724670f25c77e0abcd70c8862a41d36 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 8 Sep 2025 13:27:09 -0500 Subject: [PATCH 3/7] wip --- .../CloudKit/Internal/MockSyncEngine.swift | 5 + Sources/SQLiteData/CloudKit/SyncEngine.swift | 21 +- .../SQLiteData/CloudKit/SyncMetadata.swift | 7 +- .../CloudKitTests/AccountLifecycleTests.swift | 445 ++++++++++++++++++ .../CloudKitTests/SharingTests.swift | 9 +- .../Internal/BaseCloudKitTests.swift | 8 + 6 files changed, 472 insertions(+), 23 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift index 82d4819d..65ed1899 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift @@ -205,6 +205,11 @@ package final class MockSyncEngineState: CKSyncEngineStateProtocol, CustomDumpRe _pendingDatabaseChanges.withValue { Array($0) } } + package func removePendingChanges() { + _pendingDatabaseChanges.withValue { $0.removeAll() } + _pendingRecordZoneChanges.withValue { $0.removeAll() } + } + package func add(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { self._pendingRecordZoneChanges.withValue { $0.append(contentsOf: pendingRecordZoneChanges) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 8317a262..3db5fdfa 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -478,27 +478,10 @@ private func enqueueUnknownRecordsForCloudKit() async throws { try await userDatabase.write { db in try SyncEngine.$_isSynchronizingChanges.withValue(false) { - let recordPrimaryKeysAndRecordTypes = try SyncMetadata .where { !$0.hasLastKnownServerRecord } - .select { ($0.recordPrimaryKey, $0.recordType) } - .fetchAll(db) - let recordPrimaryKeysByRecordType = Dictionary( - grouping: recordPrimaryKeysAndRecordTypes, - by: { _, recordType in recordType } - ) - .mapValues { $0.map(\.0) } - for (recordType, recordPrimaryKeys) in recordPrimaryKeysByRecordType { - guard let table = tablesByName[recordType] - else { continue } - func open(_: T.Type) throws { - try T - .where { #sql("\($0.primaryKey)").in(recordPrimaryKeys) } - .update { $0.primaryKey = $0.primaryKey } - .execute(db) - } - try open(table) - } + .update { $0.recordPrimaryKey = $0.recordPrimaryKey } + .execute(db) } } } diff --git a/Sources/SQLiteData/CloudKit/SyncMetadata.swift b/Sources/SQLiteData/CloudKit/SyncMetadata.swift index 54ee721c..ef24f814 100644 --- a/Sources/SQLiteData/CloudKit/SyncMetadata.swift +++ b/Sources/SQLiteData/CloudKit/SyncMetadata.swift @@ -66,7 +66,12 @@ @Column(generated: .virtual) public let hasLastKnownServerRecord: Bool - + + /// Determines if the record associated with this metadata is currently shared in CloudKit. + /// + /// This can only return `true` for root records. For example, the metadata associated with a + /// `RemindersList` can have `isShared == true`, but a `Reminder` associated with the list + /// will have `isShared == false`. @Column(generated: .virtual) public let isShared: Bool diff --git a/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift index d9fab09a..e3c0cbe8 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift @@ -121,6 +121,451 @@ } } + // * Create reminders list + // * Soft log out + // * Create reminder in list + // * Sign in + // * Reminder is sync'd to CloudKit + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func signInUploadsLocalRecordsToCloudKit_SkipExistingCloudKitRecords() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + await softSignOut() + + try await userDatabase.userWrite { db in + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + + 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, │ + │ hasLastKnownServerRecord: true, │ + │ 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, │ + │ hasLastKnownServerRecord: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + └────────────────────────────────────────────────────────────────────┘ + """ + } + 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: [] + ) + ) + """ + } + + await signIn() + 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, │ + │ hasLastKnownServerRecord: true, │ + │ 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, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + └─────────────────────────────────────────────────────────────────────────────────────────┘ + """ + } + 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: "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: [] + ) + ) + """ + } + } + + // * Join shared reminders list + // * Soft log out + // * Create reminder in list + // * Sign in + // * Reminder is sync'd to CloudKit with proper metadata + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func createSharedRecordWhileSoftLoggedOut() async throws { + let externalZone = CKRecordZone( + zoneID: CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + ) + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZone.zoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + let share = CKShare( + rootRecord: remindersListRecord, + shareID: CKRecord.ID( + recordName: "share-\(remindersListRecord.recordID.recordName)", + zoneID: remindersListRecord.recordID.zoneID + ) + ) + + _ = try syncEngine.modifyRecords(scope: .shared, saving: [share, remindersListRecord]) + let freshShare = try syncEngine.shared.database.record(for: share.recordID) as! CKShare + let freshRemindersListRecord = try syncEngine.shared.database.record(for: remindersListRecord.recordID) + + try await syncEngine + .acceptShare( + metadata: ShareMetadata( + containerIdentifier: container.containerIdentifier!, + hierarchicalRootRecordID: freshRemindersListRecord.recordID, + rootRecord: freshRemindersListRecord, + share: freshShare + ) + ) + + await softSignOut() + + try await userDatabase.userWrite { db in + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + + 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/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, │ + │ title: "Personal" │ + │ ), │ + │ share: CKRecord( │ + │ recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), │ + │ recordType: "cloudkit.share", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: true, │ + │ 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, │ + │ hasLastKnownServerRecord: false, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + └─────────────────────────────────────────────────────────────────────────────────────────────────────┘ + """ + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), + recordType: "remindersLists", + parent: nil, + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner)), + id: 1, + title: "Personal" + ) + ] + ) + ) + """ + } + + await signIn() + try await syncEngine.processPendingDatabaseChanges(scope: .private) + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + + 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/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, │ + │ title: "Personal" │ + │ ), │ + │ share: CKRecord( │ + │ recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), │ + │ recordType: "cloudkit.share", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: true, │ + │ 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/external.zone/external.owner), │ + │ recordType: "reminders", │ + │ parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:reminders/external.zone/external.owner), │ + │ recordType: "reminders", │ + │ parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), │ + │ share: nil, │ + │ id: 1, │ + │ isCompleted: 0, │ + │ remindersListID: 1, │ + │ title: "Get milk" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ + │ ) │ + └─────────────────────────────────────────────────────────────────────────────────────────────────────┘ + """ + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:reminders/external.zone/external.owner), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 1, + title: "Get milk" + ), + [2]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), + recordType: "remindersLists", + parent: nil, + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/external.zone/external.owner)), + id: 1, + title: "Personal" + ) + ] + ) + ) + """ + } + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test( .accountStatus(.noAccount), diff --git a/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift index 4b31cc15..12ce91a5 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift @@ -627,14 +627,17 @@ zoneID: remindersListRecord.recordID.zoneID ) ) + _ = try syncEngine.modifyRecords(scope: .shared, saving: [share, remindersListRecord]) + let freshShare = try syncEngine.shared.database.record(for: share.recordID) as! CKShare + let freshRemindersListRecord = try syncEngine.shared.database.record(for: remindersListRecord.recordID) try await syncEngine .acceptShare( metadata: ShareMetadata( containerIdentifier: container.containerIdentifier!, - hierarchicalRootRecordID: remindersListRecord.recordID, - rootRecord: remindersListRecord, - share: share + hierarchicalRootRecordID: freshRemindersListRecord.recordID, + rootRecord: freshRemindersListRecord, + share: freshShare ) ) diff --git a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift index 659acd77..953ef2b3 100644 --- a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift @@ -101,9 +101,17 @@ class BaseCloudKitTests: @unchecked Sendable { ) } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + func softSignOut() async { + container._accountStatus.withValue { $0 = .temporarilyUnavailable } + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) func signIn() async { container._accountStatus.withValue { $0 = .available } + // NB: Emulates what CKSyncEngine does when signing in + syncEngine.private.state.removePendingChanges() + syncEngine.shared.state.removePendingChanges() await syncEngine.handleEvent( .accountChange(changeType: .signIn(currentUser: currentUserRecordID)), syncEngine: syncEngine.private From d1c7045dc602c6891d22a406a65c787a30806a2c Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 8 Sep 2025 13:27:45 -0500 Subject: [PATCH 4/7] wip --- Tests/SQLiteDataTests/AssertQueryTests.swift | 286 +++++++++--------- .../SQLiteDataTests/CustomFunctionTests.swift | 62 ++-- 2 files changed, 174 insertions(+), 174 deletions(-) diff --git a/Tests/SQLiteDataTests/AssertQueryTests.swift b/Tests/SQLiteDataTests/AssertQueryTests.swift index a2bf1f97..17179471 100644 --- a/Tests/SQLiteDataTests/AssertQueryTests.swift +++ b/Tests/SQLiteDataTests/AssertQueryTests.swift @@ -1,143 +1,143 @@ -//import DependenciesTestSupport -//import Foundation -//import SQLiteData -//import SQLiteDataTestSupport -//import SnapshotTesting -//import Testing -// -//@Suite( -// .dependency(\.defaultDatabase, try .database()), -// .snapshots(record: .failed), -//) -//struct AssertQueryTests { -// @Test func assertQueryBasic() throws { -// assertQuery( -// Record.all.select(\.id) -// ) { -// """ -// ┌───┐ -// │ 1 │ -// │ 2 │ -// │ 3 │ -// └───┘ -// """ -// } -// } -// @Test func assertQueryRecord() throws { -// assertQuery( -// Record.where { $0.id == 1 } -// ) { -// """ -// ┌────────────────────────────────────────┐ -// │ Record( │ -// │ id: 1, │ -// │ date: Date(1970-01-01T00:00:42.000Z) │ -// │ ) │ -// └────────────────────────────────────────┘ -// """ -// } -// } -// @Test func assertQueryBasicUpdate() throws { -// assertQuery( -// Record.all -// .update { $0.date = Date(timeIntervalSince1970: 45) } -// .returning { ($0.id, $0.date) } -// ) { -// """ -// ┌───┬────────────────────────────────┐ -// │ 1 │ Date(1970-01-01T00:00:45.000Z) │ -// │ 2 │ Date(1970-01-01T00:00:45.000Z) │ -// │ 3 │ Date(1970-01-01T00:00:45.000Z) │ -// └───┴────────────────────────────────┘ -// """ -// } -// } -// @Test func assertQueryRecordUpdate() throws { -// assertQuery( -// Record -// .where { $0.id == 1 } -// .update { $0.date = Date(timeIntervalSince1970: 45) } -// .returning(\.self) -// ) { -// """ -// ┌────────────────────────────────────────┐ -// │ Record( │ -// │ id: 1, │ -// │ date: Date(1970-01-01T00:00:45.000Z) │ -// │ ) │ -// └────────────────────────────────────────┘ -// """ -// } -// } -// #if DEBUG -// @Test func assertQueryBasicIncludeSQL() throws { -// assertQuery( -// includeSQL: true, -// Record.all.select(\.id) -// ) { -// """ -// SELECT "records"."id" -// FROM "records" -// """ -// } results: { -// """ -// ┌───┐ -// │ 1 │ -// │ 2 │ -// │ 3 │ -// └───┘ -// """ -// } -// } -// #endif -// #if DEBUG -// @Test func assertQueryRecordIncludeSQL() throws { -// assertQuery( -// includeSQL: true, -// Record.where { $0.id == 1 } -// ) { -// """ -// SELECT "records"."id", "records"."date" -// FROM "records" -// WHERE ("records"."id" = 1) -// """ -// } results: { -// """ -// ┌────────────────────────────────────────┐ -// │ Record( │ -// │ id: 1, │ -// │ date: Date(1970-01-01T00:00:42.000Z) │ -// │ ) │ -// └────────────────────────────────────────┘ -// """ -// } -// } -// #endif -//} -// -//@Table -//private struct Record: Equatable { -// let id: Int -// @Column(as: Date.UnixTimeRepresentation.self) -// var date = Date(timeIntervalSince1970: 42) -//} -//extension DatabaseWriter where Self == DatabaseQueue { -// fileprivate static func database() throws -> DatabaseQueue { -// let database = try DatabaseQueue() -// try database.write { db in -// try #sql( -// """ -// CREATE TABLE "records" ( -// "id" INTEGER PRIMARY KEY AUTOINCREMENT, -// "date" INTEGER NOT NULL DEFAULT 42 -// ) -// """ -// ) -// .execute(db) -// for _ in 1...3 { -// _ = try Record.insert { Record.Draft() }.execute(db) -// } -// } -// return database -// } -//} +import DependenciesTestSupport +import Foundation +import SQLiteData +import SQLiteDataTestSupport +import SnapshotTesting +import Testing + +@Suite( + .dependency(\.defaultDatabase, try .database()), + .snapshots(record: .failed), +) +struct AssertQueryTests { + @Test func assertQueryBasic() throws { + assertQuery( + Record.all.select(\.id) + ) { + """ + ┌───┐ + │ 1 │ + │ 2 │ + │ 3 │ + └───┘ + """ + } + } + @Test func assertQueryRecord() throws { + assertQuery( + Record.where { $0.id == 1 } + ) { + """ + ┌────────────────────────────────────────┐ + │ Record( │ + │ id: 1, │ + │ date: Date(1970-01-01T00:00:42.000Z) │ + │ ) │ + └────────────────────────────────────────┘ + """ + } + } + @Test func assertQueryBasicUpdate() throws { + assertQuery( + Record.all + .update { $0.date = Date(timeIntervalSince1970: 45) } + .returning { ($0.id, $0.date) } + ) { + """ + ┌───┬────────────────────────────────┐ + │ 1 │ Date(1970-01-01T00:00:45.000Z) │ + │ 2 │ Date(1970-01-01T00:00:45.000Z) │ + │ 3 │ Date(1970-01-01T00:00:45.000Z) │ + └───┴────────────────────────────────┘ + """ + } + } + @Test func assertQueryRecordUpdate() throws { + assertQuery( + Record + .where { $0.id == 1 } + .update { $0.date = Date(timeIntervalSince1970: 45) } + .returning(\.self) + ) { + """ + ┌────────────────────────────────────────┐ + │ Record( │ + │ id: 1, │ + │ date: Date(1970-01-01T00:00:45.000Z) │ + │ ) │ + └────────────────────────────────────────┘ + """ + } + } + #if DEBUG + @Test func assertQueryBasicIncludeSQL() throws { + assertQuery( + includeSQL: true, + Record.all.select(\.id) + ) { + """ + SELECT "records"."id" + FROM "records" + """ + } results: { + """ + ┌───┐ + │ 1 │ + │ 2 │ + │ 3 │ + └───┘ + """ + } + } + #endif + #if DEBUG + @Test func assertQueryRecordIncludeSQL() throws { + assertQuery( + includeSQL: true, + Record.where { $0.id == 1 } + ) { + """ + SELECT "records"."id", "records"."date" + FROM "records" + WHERE ("records"."id" = 1) + """ + } results: { + """ + ┌────────────────────────────────────────┐ + │ Record( │ + │ id: 1, │ + │ date: Date(1970-01-01T00:00:42.000Z) │ + │ ) │ + └────────────────────────────────────────┘ + """ + } + } + #endif +} + +@Table +private struct Record: Equatable { + let id: Int + @Column(as: Date.UnixTimeRepresentation.self) + var date = Date(timeIntervalSince1970: 42) +} +extension DatabaseWriter where Self == DatabaseQueue { + fileprivate static func database() throws -> DatabaseQueue { + let database = try DatabaseQueue() + try database.write { db in + try #sql( + """ + CREATE TABLE "records" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "date" INTEGER NOT NULL DEFAULT 42 + ) + """ + ) + .execute(db) + for _ in 1...3 { + _ = try Record.insert { Record.Draft() }.execute(db) + } + } + return database + } +} diff --git a/Tests/SQLiteDataTests/CustomFunctionTests.swift b/Tests/SQLiteDataTests/CustomFunctionTests.swift index abf60e9d..60eda159 100644 --- a/Tests/SQLiteDataTests/CustomFunctionTests.swift +++ b/Tests/SQLiteDataTests/CustomFunctionTests.swift @@ -1,31 +1,31 @@ -//import Foundation -//import SQLiteData -//import Testing -// -//@Suite struct CustomFunctionsTests { -// @DatabaseFunction func customDate() -> Date { -// Date(timeIntervalSinceReferenceDate: 0) -// } -// -// @Test func basics() throws { -// var configuration = Configuration() -// configuration.prepareDatabase { db in -// db.add(function: $customDate) -// } -// let database = try DatabaseQueue(configuration: configuration) -// let date = try database.read { db in -// try Values($customDate()) -// .fetchOne(db) -// } -// #expect(date?.timeIntervalSinceReferenceDate == 0) -// -// try database.write { db in -// db.remove(function: $customDate) -// } -// #expect(throws: (any Error).self) { -// try database.read { db in -// _ = try Values($customDate()).fetchOne(db) -// } -// } -// } -//} +import Foundation +import SQLiteData +import Testing + +@Suite struct CustomFunctionsTests { + @DatabaseFunction func customDate() -> Date { + Date(timeIntervalSinceReferenceDate: 0) + } + + @Test func basics() throws { + var configuration = Configuration() + configuration.prepareDatabase { db in + db.add(function: $customDate) + } + let database = try DatabaseQueue(configuration: configuration) + let date = try database.read { db in + try Values($customDate()) + .fetchOne(db) + } + #expect(date?.timeIntervalSinceReferenceDate == 0) + + try database.write { db in + db.remove(function: $customDate) + } + #expect(throws: (any Error).self) { + try database.read { db in + _ = try Values($customDate()).fetchOne(db) + } + } + } +} From 51b84e7908bf6640a428572a42029cde1ea0bde7 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 8 Sep 2025 13:28:10 -0500 Subject: [PATCH 5/7] wip --- .../CloudKitTests/NewTableSyncTests.swift | 154 +++++++++--------- 1 file changed, 77 insertions(+), 77 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift index 30dab8ff..6c0868fb 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift @@ -1,77 +1,77 @@ -//#if canImport(CloudKit) -// import CloudKit -// import CustomDump -// import SQLiteDataTestSupport -// import Foundation -// import InlineSnapshotTesting -// import SQLiteData -// import SnapshotTestingCustomDump -// import Testing -// -// extension BaseCloudKitTests { -// @MainActor -// @Suite( -// .prepareDatabase { userDatabase in -// try await userDatabase.userWrite { db in -// try db.seed { -// RemindersList(id: 1, title: "Personal") -// Reminder(id: 1, title: "Write blog post", remindersListID: 1) -// } -// } -// } -// ) -// final class NewTableSyncTests: BaseCloudKitTests, @unchecked Sendable { -// // * Create records before sync engine starts -// // => Records are sent to CloudKit -// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -// @Test func initialSync() async throws { -// try await syncEngine.processPendingRecordZoneChanges(scope: .private) -// assertInlineSnapshot(of: container, as: .customDump) { -// """ -// MockCloudContainer( -// privateCloudDatabase: MockCloudDatabase( -// databaseScope: .private, -// storage: [ -// [0]: CKRecord( -// recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), -// recordType: "reminders", -// parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), -// share: nil, -// id: 1, -// isCompleted: 0, -// remindersListID: 1, -// title: "Write blog post" -// ), -// [1]: CKRecord( -// recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), -// recordType: "remindersLists", -// parent: nil, -// share: nil, -// id: 1, -// title: "Personal" -// ) -// ] -// ), -// sharedCloudDatabase: MockCloudDatabase( -// databaseScope: .shared, -// storage: [] -// ) -// ) -// """ -// } -// -// assertQuery( -// SyncMetadata.order(by: \.recordName).select(\.recordName), -// database: syncEngine.metadatabase -// ) { -// """ -// ┌────────────────────┐ -// │ "1:reminders" │ -// │ "1:remindersLists" │ -// └────────────────────┘ -// """ -// } -// } -// } -// } -//#endif +#if canImport(CloudKit) + import CloudKit + import CustomDump + import SQLiteDataTestSupport + import Foundation + import InlineSnapshotTesting + import SQLiteData + import SnapshotTestingCustomDump + import Testing + + extension BaseCloudKitTests { + @MainActor + @Suite( + .prepareDatabase { userDatabase in + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "Write blog post", remindersListID: 1) + } + } + } + ) + final class NewTableSyncTests: BaseCloudKitTests, @unchecked Sendable { + // * Create records before sync engine starts + // => Records are sent to CloudKit + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func initialSync() async throws { + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 1, + title: "Write blog post" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + assertQuery( + SyncMetadata.order(by: \.recordName).select(\.recordName), + database: syncEngine.metadatabase + ) { + """ + ┌────────────────────┐ + │ "1:reminders" │ + │ "1:remindersLists" │ + └────────────────────┘ + """ + } + } + } + } +#endif From 14a32b0d9c42982ab4ee6b90a5bd92e3630a0a6f Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 8 Sep 2025 13:30:28 -0500 Subject: [PATCH 6/7] merge fix --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index f60bf3c4..b5893c21 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -445,7 +445,7 @@ previousRecordTypeByTableName[tableName] == nil } - try Self.$_isSynchronizingChanges.withValue(false) { + try $_isSynchronizingChanges.withValue(false) { for tableName in newTableNames { try self.uploadRecordsToCloudKit(tableName: tableName, db: db) } @@ -475,7 +475,7 @@ private func enqueueUnknownRecordsForCloudKit() async throws { try await userDatabase.write { db in - try SyncEngine.$_isSynchronizingChanges.withValue(false) { + try $_isSynchronizingChanges.withValue(false) { try SyncMetadata .where { !$0.hasLastKnownServerRecord } .update { $0.recordPrimaryKey = $0.recordPrimaryKey } From 53c4d9410aa80cc91e1ddae06dda5de195217684 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 8 Sep 2025 14:21:49 -0500 Subject: [PATCH 7/7] wip; --- Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift index 9b67e86b..0a927d32 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift @@ -540,6 +540,7 @@ │ │ ), │ │ │ share: nil, │ │ │ _isDeleted: false, │ + │ │ hasLastKnownServerRecord: true, │ │ │ isShared: false, │ │ │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ │ │ ) │ @@ -567,6 +568,7 @@ │ │ ), │ │ │ share: nil, │ │ │ _isDeleted: false, │ + │ │ hasLastKnownServerRecord: true, │ │ │ isShared: false, │ │ │ userModificationDate: Date(1970-01-01T00:00:00.000Z) │ │ │ ) │